From d459178ad12b40f98911260f661de990bdaf5293 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 19 Jul 2026 04:20:31 +0800 Subject: [PATCH] feat(extensions): support OpenCode MCP configuration sources Add workspace-scoped lifecycle, approval and conflict UX, and fail-closed host boundaries for local stdio and HTTPS MCP sources. --- .../external-ai-work-sources-design.md | 28 + .../opencode-config-assets-adapter-design.md | 13 +- .../opencode-extension-compatibility.md | 2 +- .../extensions/plugin-runtime-host-design.md | 10 +- .../rules/source/forbidden-rules.mjs | 1 + .../rules/source/public-api-rules.mjs | 82 ++ scripts/core-boundaries/self-test.mjs | 26 +- src/apps/cli/src/main.rs | 1 + src/apps/cli/src/modes/chat.rs | 8 +- src/apps/cli/src/modes/chat/input.rs | 35 +- src/apps/cli/src/modes/chat/mcp.rs | 401 +++++- src/apps/cli/src/modes/chat/run.rs | 12 + src/apps/cli/src/ui/chat/mouse.rs | 2 +- src/apps/cli/src/ui/chat/popups.rs | 14 +- src/apps/cli/src/ui/chat/state.rs | 2 +- src/apps/cli/src/ui/mcp_selector.rs | 386 ++++- .../desktop/src/api/external_sources_api.rs | 84 +- src/apps/desktop/src/api/mcp_api.rs | 88 +- .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 2 + .../adapters/opencode-adapter/src/lib.rs | 2 + .../opencode-adapter/src/mcp_source.rs | 1266 +++++++++++++++++ .../tests/opencode_mcp_adapter.rs | 346 +++++ .../tools/implementations/mcp_tools.rs | 11 +- src/crates/assembly/core/src/external_mcp.rs | 591 ++++++++ .../assembly/core/src/external_mcp_tests.rs | 353 +++++ .../assembly/core/src/external_sources.rs | 979 ++++++++++++- .../assembly/core/src/external_tools.rs | 2 +- src/crates/assembly/core/src/lib.rs | 4 + .../core/src/service/mcp/adapter/mod.rs | 1 + .../core/src/service/mcp/adapter/tool.rs | 130 +- .../core/src/service/mcp/config/service.rs | 3 + .../service/mcp/server/manager/interaction.rs | 39 + .../service/mcp/server/manager/lifecycle.rs | 418 +++++- .../src/service/mcp/server/manager/mod.rs | 94 +- .../src/service/mcp/server/manager/tests.rs | 36 + .../src/service/mcp/server/manager/tools.rs | 18 +- .../core/src/service/mcp/server/process.rs | 66 + .../assembly/external-sources/src/lib.rs | 13 + .../assembly/external-sources/src/mcp.rs | 449 ++++++ .../external-sources/tests/mcp_coordinator.rs | 305 ++++ .../product-domains/src/external_sources.rs | 493 ++++++- .../tests/external_source_contracts.rs | 240 +++- src/crates/interfaces/acp/src/runtime/mcp.rs | 6 + .../src/mcp/config/cursor_format.rs | 3 + .../src/mcp/server/mod.rs | 45 + .../src/mcp/server/process.rs | 160 ++- .../src/mcp/server/registry.rs | 3 + .../tests/mcp_contracts.rs | 89 ++ .../api/service-api/ExternalSourcesAPI.ts | 116 +- .../components/ExternalSourcesConfig.test.tsx | 164 +++ .../components/ExternalSourcesConfig.tsx | 441 ++++++ .../en-US/settings/external-sources.json | 63 +- .../zh-CN/settings/external-sources.json | 63 +- .../zh-TW/settings/external-sources.json | 63 +- 55 files changed, 8131 insertions(+), 149 deletions(-) create mode 100644 src/crates/adapters/opencode-adapter/src/mcp_source.rs create mode 100644 src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs create mode 100644 src/crates/assembly/core/src/external_mcp.rs create mode 100644 src/crates/assembly/core/src/external_mcp_tests.rs create mode 100644 src/crates/assembly/external-sources/src/mcp.rs create mode 100644 src/crates/assembly/external-sources/tests/mcp_coordinator.rs diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index f6aa966974..f5c92b6b73 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -328,6 +328,34 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 延后;本机 Node 进程不是 OS 沙箱,VM realm 与隐藏响应令牌只保护协议稳健性。脚本继承当前用户的文件、网络、 环境与进程权限;本阶段没有 Job Object/process group,脚本直接启动的后代进程和系统资源风险必须持续显示。 +第三阶段以 OpenCode MCP 配置来源验证声明式外部连接的接纳与生命周期: + +1. OpenCode adapter 按冻结版本解释用户全局、自定义文件/目录和项目 JSON/JSONC 的 MCP 合并顺序,只输出脱敏静态 + 候选;远程地址只展示 HTTPS origin,环境变量只展示变量名,值只在确认后的准备阶段解析。为避免一次确认在环境变化后 + 改写可执行文件、参数、工作目录或网络目标,本阶段只允许 `{env:NAME}` 出现在显式 environment 或 Header 值中;其他 + 位置明确标记不支持。展开后再次执行大小、HTTPS 与契约校验。 +2. MCP coordinator 与 Command、Tool、Subagent 平行,负责 provider 隔离、last-success、稳定删除和 watcher roots; + 审批、同名冲突和 BitFun 原生优先级由产品组装层决定,具体进程、连接、工具注册和回收仍由 MCP owner 负责。 +3. 首次启用或命令、参数、工作目录、环境声明、URL origin、Header 名、认证方式等行为变化后重新确认;拒绝在行为变化前不重复 + 提示。同名候选未选择时保留 BitFun 当前实现,不把显示顺序当成用户决定,也不在外部失效时静默切换。 +4. Desktop 在“外部 AI 应用”设置页显示安全摘要、状态、审批和冲突;交互式 TUI 复用 `/mcps`,在同一列表中用来源 + 标签区分 BitFun 与外部候选,不新增 `external-*` 命令。两端使用同一 generation、preference revision 和操作接口。 +5. 已确认候选按规范化 workspace 建立独立运行实例;工具包装器同时校验当前 workspace route,Remote owner 未实现前直接 + fail-closed。同名冲突选择外部候选时,只在当前 workspace 隐藏对应 BitFun 原生工具,不停止其他 workspace 的原生实例。 +6. 更新、停用、空闲回收或稳定删除先撤下 workspace route 和新工具入口,再有界等待在途连接释放;慢启动在发布连接、 + 工具和目录前再次检查撤销标记,不能在撤销后重新发布。第三方启动、握手和回收在后台有界执行,不要求全量重启 + MCP 或 BitFun,也不中断无关 session。 +7. 产品快照区分“正在启动”“已启用”“暂时不可用”。后台启动失败会触发快照更新;暂时不可用的候选仍可由用户停用, + 当前恢复入口是先停用该服务器,修复来源配置或认证后再启用;普通刷新不会自动重试失败进程,避免持续拉起故障或恶意 + 扩展。删除项的一次性用户通知/有界墓碑尚未实现,当前行会在稳定重扫后消失;该展示增强 + 留待后续 PR,不能改变“先撤 route、禁止新调用”的运行语义。 +8. 外部本地进程不继承 BitFun 的完整父进程环境,只保留启动所需的系统基线和配置显式声明的变量;这仍不是 OS + 沙箱,进程继续拥有当前用户的文件、网络和子进程权限。Remote 执行域、OpenCode OAuth client 配置、SSE、完整 + `timeout`/Agent 范围和通用凭据 owner 明确延后。 +9. 本阶段只把外部 MCP 的 Tool 目录接入 Agent Tool owner。通用 Resource/Prompt/MCP App Desktop 接口不接受无工作区 + 上下文的外部 runtime id;外部服务器发起的 roots、sampling 和 elicitation 请求也 fail-closed,防止跨工作区读取或借用 + BitFun 宿主能力。后续若接入这些能力,必须先补独立契约、工作区路由与权限交互,不能复用全局连接绕过当前边界。 + 验收至少覆盖: - 项目打开和无关会话不会因发现、解析、依赖准备或确认等待被阻塞。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 4e0e4dd475..769361df34 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -95,6 +95,10 @@ PR1 只实现上述本地 Command 子集:XDG 用户配置根、`OPENCODE_CONFI 扫描。`OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源仍保留在目标来源图中,不得在产品状态中误报 为已加载。路径按规范化来源身份去重,显式环境路径与默认路径相同时只保留 OpenCode 顺序中的最后一个阶段。 +当前 MCP 子集复用同一来源解释,但只读取本地用户全局配置、`OPENCODE_CONFIG`、root-first 项目配置以及作为全局 +晚覆盖的 `OPENCODE_CONFIG_DIR`;设置该目录后不再叠加默认全局目录。`OPENCODE_CONFIG_CONTENT`、远程、组织、 +系统管理员和 MDM 来源尚未接入,不能因 MCP 候选可运行就把目标来源图标记为完整实现。 + ### 3.2 TUI 独立来源顺序 `tui.json/jsonc` 不是主配置来源图的附属字段。稳定版使用独立顺序: @@ -185,7 +189,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | Skill 归属模块复用按需加载并补齐规则顺序 | 说明和索引按需加载;URL、脚本或外部依赖按 L2 确认 | URL 或可执行资源失败只降级对应 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,交互式 TUI(ChatMode)只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | -| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 首次按命令、网络、凭据和作用域确认,再按来源启用状态运行 | 凭据或网络不可用时单个 Server 不可用。 | +| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 当前支持 local stdio 和 HTTPS remote 的静态发现、首次/行为变化审批、冲突选择与 workspace 隔离的运行期接纳;外部本地进程只继承系统启动基线和显式环境 | `{env:NAME}` 当前只允许用于 environment/Header 值;SSE、OpenCode OAuth client 配置、完整 timeout/Agent 范围与 Remote 执行域保持明确不支持;凭据或网络失败只影响单个 Server。 | | LSP | command、extensions、env、initialization | LSP 归属模块注册兼容实例 | 首次确认外部进程和作用域后按文件类型启动 | 自定义 Server 缺少 extensions 或启动失败时只禁用该项。 | | Formatters | command、environment、extensions、`$FILE` | **基础能力缺失**:先补文件写入后的 Formatter 执行消费点,再做格式转换 | 首次确认命令后执行匹配 Formatter | 超时后标记未格式化,文件写入结果保留。 | | Themes | builtin/user/project/cwd JSON | **部分已有**:GUI Theme 已有;TUI 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | @@ -263,7 +267,12 @@ provider 候选提供 `/external::`。一次外部候选选择 这些能力使用 BitFun 原生归属模块,但“原生已有”不自动等于兼容: -- MCP 必须覆盖 local 的 `environment`,remote 的 Headers、OAuth(含 clientId/clientSecret/scope/callbackPort/redirectUri)、超时、启用状态和 Agent 范围。 +- MCP 当前覆盖 local 的 `command/environment/cwd/enabled` 与 remote 的 HTTPS URL、Headers、动态 OAuth 开关和 + `enabled`,并在批准后按 workspace 交给现有 MCP owner;工具在调用前复核 workspace route,Remote 不回退到本机实例。 + 远端静态摘要只展示 HTTPS origin,环境引用只展示变量名;为避免审批后通过环境变量替换执行包络,`{env:NAME}` 仅 + 支持 environment/Header 值,展开后重新校验大小和协议。未配置 `cwd` 时遵循 OpenCode,使用当前 workspace。 + 外部本地进程默认不继承 BitFun 的完整父进程环境。SSE、OpenCode + `clientId/clientSecret/scope/callbackPort/redirectUri`、完整超时和 Agent 范围仍需后续接入,不能静默忽略。 - LSP 必须覆盖 initialization、扩展名匹配、环境变量和工作区生命周期。 - Formatter 必须覆盖写入后时机、`$FILE` 替换、`environment`、多个 Formatter 顺序和失败行为。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index c4e1bd5858..757404deb9 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -108,7 +108,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only 子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、`$ARGUMENTS` 与位置参数,并接入动态命令目录、运行时刷新和显式冲突选择;`@file`、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | | Models / Providers 配置 | 融合现有能力 | 未实现 | 可主要适配 | OC-R1 | 静态字段进入模型归属模块;动态模型、鉴权和请求头交给插件运行时 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| MCP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换本地命令、远程 URL、Headers、OAuth、超时和 Agent 选择并由 MCP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | +| MCP | 转换参数 | 部分实现:local stdio 与 HTTPS remote 子集 | 可完整适配 | OC-R2 | 已支持用户全局、自定义目录/文件和项目 JSON/JSONC 的静态发现、显式审批、原生/外部冲突、workspace 隔离、启动失败回推、更新撤下及现有 MCP owner 运行;环境引用仅用于 environment/Header 值,Remote 不回退本机实例;SSE、OpenCode OAuth client 配置、完整 timeout/Agent 范围和其他配置来源继续明确降级 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | LSP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换 command、extensions、env 和 initialization 并由 LSP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | Formatters | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 补文件写入后的格式化执行能力,再映射 command/environment/extensions/`$FILE` | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | Themes | 转换参数 | 未实现 | 可主要适配 | OC-R1 | 保留 builtin/user/project/cwd 覆盖顺序,分别映射 GUI 和 TUI 色彩能力 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | diff --git a/docs/architecture/extensions/plugin-runtime-host-design.md b/docs/architecture/extensions/plugin-runtime-host-design.md index e67b2d9650..2b23c32afc 100644 --- a/docs/architecture/extensions/plugin-runtime-host-design.md +++ b/docs/architecture/extensions/plugin-runtime-host-design.md @@ -298,12 +298,16 @@ BitFun 受管插件包的 P0-C.1/P0-C.2 链路当前只验证了原生来源和 因此,受管包链路只能表述为“来源可识别、静态候选可预览”,不能表述为“OpenCode package plugin 可运行”。 CLI 已允许在包文件缺失或损坏时清理残留启用状态,这是现有链路必须保留的恢复能力。与此独立的外部来源轨道 -已经接入 prompt-only Command、受支持的单文件 `.js` standalone Tool 和 Subagent 安全子集;其中 standalone Tool +已经接入 prompt-only Command、受支持的单文件 `.js` standalone Tool、Subagent 安全子集和 OpenCode MCP 配置 +安全子集;其中 standalone Tool 通过窄 `ScriptToolRuntime` 真实执行,但这不证明 package plugin、Hook、完整 Client 或 TUI target 已经可运行。 -三个切片当前都只支持本地执行域;Remote 外部来源发现、审批与冲突决策明确返回不支持,不回退读取本机来源。 +四个切片当前都只支持本地执行域;Remote 外部来源发现、审批与冲突决策明确返回不支持,不回退读取本机来源。MCP +运行实例和工具 route 额外按规范化 workspace 隔离;更新、停用、空闲回收和删除先撤 route,再异步回收连接与进程, +慢启动在发布工具前复核撤销状态。 `bitfun.plugin.json` 继续作为 BitFun 原生包格式。现有外部来源轨道已直接发现用户和项目的 Command、standalone -Tool 与 Subagent 安全子集,包括配置文件中的受支持声明;完整 OpenCode 配置来源图尚未接入。后续 package plugin +Tool、Subagent 与 MCP 安全子集,包括配置文件中的受支持声明;远程、组织、系统管理员、MDM 和内联内容等完整 +OpenCode 配置来源图尚未接入。后续 package plugin 路径还需直接发现 `.opencode/plugins` 和软件包配置并记录当前执行版本,不得要求作者先复制到 `.bitfun/plugins` 或维护另一份清单。 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index ffd6a97fff..8dc741c792 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4118,6 +4118,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', ], diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index e2789e9a63..a6abb5c445 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -8,6 +8,7 @@ export const publicApiContractSlices = [ 'external-source-command-contract', 'external-source-tool-contract', 'external-source-subagent-contract', + 'external-source-mcp-contract', ]; const contractSlices = { @@ -18,6 +19,7 @@ const contractSlices = { externalSourceCommandContract: 'external-source-command-contract', externalSourceToolContract: 'external-source-tool-contract', externalSourceSubagentContract: 'external-source-subagent-contract', + externalSourceMcpContract: 'external-source-mcp-contract', }; function pluginRuntimeEntry(symbol, p0, consumer, verification, contractSlice, wireImpact = true) { @@ -202,6 +204,14 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeSubagentProviderOptions', 'OpenCode subagent adapter fixture tests and explicit environment injection', ), + opencodeAdapterEntry( + 'OpenCodeMcpProvider', + 'bitfun-core external source composition root and OpenCode MCP adapter tests', + ), + opencodeAdapterEntry( + 'OpenCodeMcpProviderOptions', + 'OpenCode MCP adapter fixture tests and explicit environment injection', + ), ]; function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { @@ -253,6 +263,23 @@ function externalSubagentEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalMcpEntry(symbol, owner, consumer, wireImpact = false) { + return { + symbol, + owner, + consumer, + verification: + 'external MCP contract, coordinator, OpenCode adapter, MCP owner lifecycle, TUI, Desktop, and Web tests', + p0: 'PR6 ecosystem-neutral MCP source activation and OpenCode MCP configuration vertical slice', + contractSlice: contractSlices.externalSourceMcpContract, + wireImpact, + rationale: + 'PR6 needs typed static discovery, versioned approval, conflict, preparation, and runtime status contracts without OpenCode or MCP-owner payload leakage', + exit: + 'remove only through a reviewed MCP source contract migration with equivalent fail-closed activation and lifecycle tests', + }; +} + export const externalSourceContractPublicApiEntries = [ 'ExternalSourceContractError', 'SourceKey', @@ -316,6 +343,33 @@ export const externalSourceContractPublicApiEntries = [ true, ), ), + [ + 'SourceQualifiedMcpServerId', + 'ExternalMcpTransportKind', + 'ExternalMcpStaticStatus', + 'ExternalMcpServerDefinition', + 'ExternalMcpActivationState', + 'ExternalMcpCatalogEntry', + 'ExternalMcpApprovalRequest', + 'ExternalMcpConflictCandidate', + 'ExternalMcpConflict', + 'SecretValue', + 'PreparedExternalMcpTransport', + 'PreparedExternalMcpServer', + 'ExternalMcpProviderIdentity', + 'ExternalMcpProviderSnapshot', + 'ExternalMcpSourceProvider', + 'external_mcp_approval_key', + 'external_mcp_conflict_key', + 'ExternalMcpDiscoveryInput', + ].map((symbol) => + externalMcpEntry( + symbol, + 'product-domains external MCP contract owner', + 'ecosystem MCP providers, external-MCP coordinator, product reconciliation, and MCP runtime owner', + true, + ), + ), ); export const externalSubagentContractPublicApiEntries = [ @@ -390,6 +444,18 @@ export const externalSourceCoordinatorPublicApiEntries = [ 'bitfun-core bounded concurrent external-subagent provider scheduler', ), ), + ...[ + 'ExternalMcpCoordinator', + 'ExternalMcpCoordinatorSnapshot', + 'ExternalMcpDiscoveryRequest', + 'ExternalMcpDiscoveryResult', + ].map((symbol) => + externalMcpEntry( + symbol, + 'external-sources assembly owner', + 'bitfun-core bounded concurrent external-MCP provider scheduler', + ), + ), ]; export const externalSourceCorePublicApiEntries = [ @@ -452,6 +518,22 @@ export const externalSourceCorePublicApiEntries = [ 'bitfun-cli and desktop host APIs', ), ), + ...[ + 'ExternalMcpActivationState', + 'ExternalMcpApprovalRequest', + 'ExternalMcpCatalogEntry', + 'ExternalMcpConflict', + 'ExternalMcpTransportKind', + 'native_mcp_candidate_id', + 'set_external_mcp_server_decision', + 'choose_external_mcp_conflict', + ].map((symbol) => + externalMcpEntry( + symbol, + 'bitfun-core external MCP composition facade', + 'bitfun-cli and desktop host APIs', + ), + ), ]; function pluginSourceEntry(symbol, owner, consumer, verification, wireImpact) { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 262d8b908c..e8a7e9edd7 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -857,6 +857,9 @@ export function runManifestParserSelfTest({ const externalSubagentPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_subagents.rs', ); + const externalSourcePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_sources.rs', + ); const managedPluginActivationPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/plugin_runtime.rs', ); @@ -969,10 +972,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, tool, and subagent providers', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, tool, subagent, and MCP providers', ); } for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { @@ -994,6 +997,25 @@ export function runManifestParserSelfTest({ if (!publicApiContractSlices.includes('external-source-subagent-contract')) { throw new Error('external subagent contracts must have an independent contract slice'); } + if (!publicApiContractSlices.includes('external-source-mcp-contract')) { + throw new Error('external MCP contracts must have an independent contract slice'); + } + for (const requiredSymbol of [ + 'ExternalMcpServerDefinition', + 'ExternalMcpSourceProvider', + 'PreparedExternalMcpServer', + 'external_mcp_approval_key', + 'external_mcp_conflict_key', + ]) { + if (!externalSourcePublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-mcp-contract' + && entry.consumer + && entry.verification, + )) { + throw new Error(`external MCP public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'ExternalSubagentDefinition', 'ExternalSubagentSourceProvider', diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 1a293c01f1..3351a2ef44 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -536,6 +536,7 @@ async fn initialize_core_services( Ok(mcp_service) => { let mcp_service = std::sync::Arc::new(mcp_service); MCP_SERVICE.set(mcp_service.clone()).ok(); + bitfun_core::service::mcp::set_global_mcp_service(mcp_service.clone()); // Mark as in progress get_mcp_init_status().store(1, Ordering::Relaxed); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 827af671c7..006886cba7 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -37,7 +37,7 @@ use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionP use crate::ui::command_palette::PaletteAction; use crate::ui::login_form::LoginFormAction; use crate::ui::mcp_add_dialog::McpAddAction; -use crate::ui::mcp_selector::McpItem; +use crate::ui::mcp_selector::{McpItem, McpItemAction}; use crate::ui::model_config_form::{ModelFormAction, ModelFormResult}; use crate::ui::model_selector::ModelItem; use crate::ui::permission::{PermissionAction, ALLOW_ALWAYS_RUNTIME_SCOPE}; @@ -120,6 +120,7 @@ pub(crate) enum ChatExitReason { /// Pending MCP operation (deferred to allow a render frame for loading state) enum PendingMcpOp { Toggle(String), + External(McpItem), Add { name: String, config_json: String }, Delete(String), } @@ -137,6 +138,11 @@ enum PendingMcpTask { server_id: String, handle: tokio::task::JoinHandle>, }, + External { + item_id: String, + item_name: String, + handle: tokio::task::JoinHandle>, + }, } struct PendingModeChange { diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index b2969ee92c..16be06ce2d 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -274,7 +274,14 @@ impl ChatMode { KeyCode::Down => chat_view.mcp_selector_down(), KeyCode::Enter | KeyCode::Char(' ') => { if let Some(selected) = chat_view.mcp_selector_confirm() { - self.toggle_mcp_server(&selected.id, chat_view); + if selected.requires_external_confirmation() + && !chat_view.mcp_selector_is_confirm_external(&selected.id) + { + chat_view.mcp_selector_start_confirm_external(selected.id.clone()); + } else { + chat_view.mcp_selector_cancel_confirm_external(); + self.activate_mcp_item(selected, chat_view, chat_state); + } } } KeyCode::Char('a') => { @@ -284,6 +291,13 @@ impl ChatMode { } KeyCode::Char('d') => { if let Some(selected) = chat_view.mcp_selector_confirm() { + if selected.is_external() { + chat_state.add_system_message( + "External MCP settings are read-only in BitFun. Disable the server here or edit it in the source application." + .to_string(), + ); + return Ok(None); + } // First press: enter confirm-delete mode // Second press: actually delete (handled by confirm_delete state) if chat_view.mcp_selector_is_confirm_delete(&selected.id) { @@ -294,13 +308,24 @@ impl ChatMode { } } KeyCode::Char('e') => { - chat_view.hide_mcp_selector(); - self.open_mcp_config(chat_state); + if chat_view + .mcp_selector_confirm() + .is_some_and(|selected| selected.is_external()) + { + chat_state.add_system_message( + "External MCP settings are read-only in BitFun. Edit them in the source application." + .to_string(), + ); + } else { + chat_view.hide_mcp_selector(); + self.open_mcp_config(chat_state); + } } // Note: Esc is handled globally for navigation back _ => { // Any other key cancels the confirm-delete state chat_view.mcp_selector_cancel_confirm_delete(); + chat_view.mcp_selector_cancel_confirm_external(); } } return Ok(None); @@ -608,10 +633,10 @@ impl ChatMode { .this .preview_theme_selection(&theme, context.chat_view); } - if let Some(server_id) = context.chat_view.take_pending_mcp_toggle() { + if let Some(item) = context.chat_view.take_pending_mcp_toggle() { context .this - .toggle_mcp_server(&server_id, context.chat_view); + .activate_mcp_item(item, context.chat_view, context.chat_state); } outcome.request_redraw = true; } diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index 1c32442e57..5e145a09e9 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -1,3 +1,34 @@ +fn bounded_mcp_terminal_text(value: &str) -> String { + let escaped = crate::plugin_diagnostics::escape_terminal_text(value); + let mut chars = escaped.chars(); + let bounded = chars.by_ref().take(512).collect::(); + if chars.next().is_some() { + format!("{bounded}…") + } else { + bounded + } +} + +fn external_mcp_state_label( + state: &bitfun_core::external_sources::ExternalMcpActivationState, +) -> &'static str { + use bitfun_core::external_sources::ExternalMcpActivationState as State; + match state { + State::ApprovalRequired => "Confirmation required", + State::Starting => "Starting", + State::Active => "Enabled", + State::Declined => "Kept disabled", + State::Conflict => "Choice required", + State::Covered { .. } => "Not selected", + State::SourceDisabled => "Source disabled", + State::ConfigurationChanged => "Changed; confirm again", + State::Unsupported { .. } => "Not supported", + State::RuntimeUnavailable { .. } => "Unavailable", + State::Removed => "Removed", + _ => "Unavailable", + } +} + impl ChatMode { /// Show MCP server selector popup fn show_mcp_selector( @@ -12,7 +43,7 @@ impl ChatMode { } /// Get MCP server items for display - fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + pub(super) fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { let mcp_service = match crate::get_mcp_service() { Some(svc) => svc, None => return Vec::new(), @@ -20,6 +51,7 @@ impl ChatMode { let server_manager = mcp_service.server_manager(); let config_service = mcp_service.config_service(); + let external_snapshot = self.external_source_snapshot.clone(); tokio::task::block_in_place(|| { rt_handle.block_on(async { @@ -63,12 +95,260 @@ impl ChatMode { let server_type = format!("{:?}", config.server_type).to_lowercase(); + let native_candidate_id = + bitfun_core::external_sources::native_mcp_candidate_id(&config.id); + let native_conflict = external_snapshot.as_ref().and_then(|snapshot| { + snapshot.mcp_conflicts.iter().find(|conflict| { + conflict.candidates.iter().any(|candidate| { + candidate.candidate_id == native_candidate_id + }) + }) + }); + let (status, action) = if let Some(conflict) = native_conflict { + let native_choice = conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == native_candidate_id); + if native_choice.is_some_and(|candidate| !candidate.available) { + ( + "Unavailable".to_string(), + McpItemAction::ReadOnly { + reason: native_choice + .and_then(|candidate| candidate.unavailable_reason.clone()) + .unwrap_or_else(|| { + "Enable this BitFun server in its MCP configuration, then reopen /mcps" + .to_string() + }), + }, + ) + } else if conflict.selected_candidate_id.as_deref() + == Some(&native_candidate_id) + { + (status, McpItemAction::NativeToggle) + } else { + ( + if conflict.selected_candidate_id.is_some() { + "Not selected".to_string() + } else { + "Choice required".to_string() + }, + McpItemAction::ConflictChoice { + conflict_key: conflict.conflict_key.clone(), + candidate_id: native_candidate_id, + approve_external: false, + expected_mcp_generation: external_snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.mcp_generation), + expected_preference_revision: external_snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.preference_revision), + }, + ) + } + } else { + (status, McpItemAction::NativeToggle) + }; + items.push(McpItem { id: config.id.clone(), - name: config.name.clone(), + name: bounded_mcp_terminal_text(&config.name), server_type, status, tool_count, + source_label: "BitFun".to_string(), + external: false, + detail: "BitFun configuration".to_string(), + action, + }); + } + + if let Some(snapshot) = external_snapshot.as_ref() { + for entry in &snapshot.mcp_servers { + let source = snapshot + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source) + .map(|source| source.record.clone()); + let source_label = source + .as_ref() + .map(|source| source.display_name.clone()) + .unwrap_or_else(|| "External AI app".to_string()); + let source_location = source + .as_ref() + .map(|source| source.location.as_str()) + .unwrap_or("unknown source"); + let conflict = snapshot.mcp_conflicts.iter().find(|conflict| { + conflict.candidates.iter().any(|candidate| { + candidate.candidate_id == entry.candidate_id + }) + }); + let action = match &entry.activation_state { + bitfun_core::external_sources::ExternalMcpActivationState::ApprovalRequired + | bitfun_core::external_sources::ExternalMcpActivationState::Declined + | bitfun_core::external_sources::ExternalMcpActivationState::ConfigurationChanged => { + McpItemAction::ExternalDecision { + candidate_id: entry.candidate_id.clone(), + decision_key: entry.decision_key.clone(), + approved: true, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + } + } + bitfun_core::external_sources::ExternalMcpActivationState::Starting + | bitfun_core::external_sources::ExternalMcpActivationState::Active + | bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { .. } => { + McpItemAction::ExternalDecision { + candidate_id: entry.candidate_id.clone(), + decision_key: entry.decision_key.clone(), + approved: false, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + } + } + bitfun_core::external_sources::ExternalMcpActivationState::Conflict + | bitfun_core::external_sources::ExternalMcpActivationState::Covered { .. } => { + if let Some(conflict) = conflict { + McpItemAction::ConflictChoice { + conflict_key: conflict.conflict_key.clone(), + candidate_id: entry.candidate_id.clone(), + approve_external: true, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + } + } else { + McpItemAction::ReadOnly { + reason: "Refresh to review the current conflict".to_string(), + } + } + } + bitfun_core::external_sources::ExternalMcpActivationState::Unsupported { reason } => { + McpItemAction::ReadOnly { + reason: format!( + "Not supported: {}. Change this server in the source application; the list refreshes automatically", + bounded_mcp_terminal_text(reason), + ), + } + } + bitfun_core::external_sources::ExternalMcpActivationState::SourceDisabled => { + McpItemAction::ReadOnly { + reason: "Enable this server in the source application; the list refreshes automatically" + .to_string(), + } + } + state => McpItemAction::ReadOnly { + reason: external_mcp_state_label(state).to_string(), + }, + }; + let status = match &entry.activation_state { + bitfun_core::external_sources::ExternalMcpActivationState::Active => { + if let Some(runtime_id) = entry.runtime_id.as_deref() { + match tokio::time::timeout( + Duration::from_millis(30), + server_manager.get_server_status(runtime_id), + ) + .await + { + Ok(Ok(status)) => format!("{status:?}"), + Ok(Err(_)) => "Unavailable".to_string(), + Err(_) => "Starting".to_string(), + } + } else { + "Enabled".to_string() + } + } + bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { reason } => { + format!( + "Unavailable - {}", + bounded_mcp_terminal_text(reason), + ) + } + state => external_mcp_state_label(state).to_string(), + }; + let tool_count = entry.runtime_id.as_deref().map_or(0, |runtime_id| { + let prefix = format!("mcp_{runtime_id}_"); + all_tools + .iter() + .filter(|tool| tool.name().starts_with(&prefix)) + .count() + }); + let mut detail = match entry.definition.transport { + bitfun_core::external_sources::ExternalMcpTransportKind::LocalStdio => format!( + "source: {}; local command: {}; arguments: {}; starts in: {}; environment variables set: {}; reads from BitFun environment: {}; security: runs with your user permissions without an additional OS sandbox", + bounded_mcp_terminal_text(source_location), + entry.definition.command_preview.as_deref().unwrap_or("unknown"), + entry.definition.argument_count, + entry.definition.working_directory.as_deref().unwrap_or("default"), + if entry.definition.environment_keys.is_empty() { + "none".to_string() + } else { + entry.definition.environment_keys.join(", ") + }, + if entry.definition.environment_reference_names.is_empty() { + "none".to_string() + } else { + entry.definition.environment_reference_names.join(", ") + }, + ), + bitfun_core::external_sources::ExternalMcpTransportKind::StreamableHttp => format!( + "source: {}; remote origin: {}; HTTP headers: {}; reads from BitFun environment: {}; security: connects to the shown service with your user permissions", + bounded_mcp_terminal_text(source_location), + entry.definition.remote_url_preview.as_deref().unwrap_or("unknown"), + if entry.definition.header_names.is_empty() { + "none".to_string() + } else { + entry.definition.header_names.join(", ") + }, + if entry.definition.environment_reference_names.is_empty() { + "none".to_string() + } else { + entry.definition.environment_reference_names.join(", ") + }, + ), + _ => "unsupported external MCP transport".to_string(), + }; + if let bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { reason } = &entry.activation_state { + detail.push_str(&format!( + "; unavailable reason: {}; next step: disable this server, fix its source configuration or authentication, then enable it", + bounded_mcp_terminal_text(reason), + )); + } + items.push(McpItem { + id: entry + .runtime_id + .clone() + .unwrap_or_else(|| entry.candidate_id.clone()), + name: bounded_mcp_terminal_text(&entry.definition.name), + server_type: match entry.definition.transport { + bitfun_core::external_sources::ExternalMcpTransportKind::LocalStdio => "local".to_string(), + bitfun_core::external_sources::ExternalMcpTransportKind::StreamableHttp => "remote".to_string(), + _ => "unsupported".to_string(), + }, + status, + tool_count, + source_label: bounded_mcp_terminal_text(&source_label), + external: true, + detail, + action, + }); + } + } + if items.is_empty() + && external_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.discovery_pending) + { + items.push(McpItem { + id: "external-mcp-discovery-pending".to_string(), + name: "External MCP servers".to_string(), + server_type: "external".to_string(), + status: "Checking".to_string(), + tool_count: 0, + source_label: "External AI applications".to_string(), + external: true, + detail: "BitFun is still checking compatible MCP settings".to_string(), + action: McpItemAction::ReadOnly { + reason: "Still checking; this list updates automatically".to_string(), + }, }); } items @@ -76,6 +356,27 @@ impl ChatMode { }) } + fn activate_mcp_item( + &mut self, + item: McpItem, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + ) { + match &item.action { + McpItemAction::NativeToggle => self.toggle_mcp_server(&item.id, chat_view), + McpItemAction::ReadOnly { reason } => { + chat_state.add_system_message(format!("{}: {}", item.name, reason)); + } + McpItemAction::ExternalDecision { .. } | McpItemAction::ConflictChoice { .. } => { + if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(&item.id) { + return; + } + chat_view.mcp_selector_set_loading(Some(item.id.clone())); + self.pending_mcp_op = Some(PendingMcpOp::External(item)); + } + } + } + /// Schedule an MCP server toggle (deferred to allow loading state to render) fn toggle_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { @@ -125,11 +426,76 @@ impl ChatMode { }); } + fn execute_external_mcp_action( + &mut self, + item: McpItem, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace = self.agent.workspace_path_buf(); + let action = item.action.clone(); + let item_id = item.id.clone(); + let item_name = item.name.clone(); + let handle = rt_handle.spawn(async move { + match action { + McpItemAction::ExternalDecision { + candidate_id, + decision_key, + approved, + expected_mcp_generation, + expected_preference_revision, + } => { + bitfun_core::external_sources::set_external_mcp_server_decision( + Some(workspace.as_path()), + &candidate_id, + &decision_key, + approved, + expected_mcp_generation, + expected_preference_revision, + ) + .await + } + McpItemAction::ConflictChoice { + conflict_key, + candidate_id, + approve_external, + expected_mcp_generation, + expected_preference_revision, + } => { + bitfun_core::external_sources::choose_external_mcp_conflict( + Some(workspace.as_path()), + &conflict_key, + &candidate_id, + approve_external, + expected_mcp_generation, + expected_preference_revision, + ) + .await + } + McpItemAction::NativeToggle | McpItemAction::ReadOnly { .. } => { + Err("The MCP action is no longer available; reopen /mcps".to_string()) + } + } + }); + self.pending_mcp_tasks.push(PendingMcpTask::External { + item_id, + item_name, + handle, + }); + chat_state.add_system_message( + "Saving the MCP server choice. Existing sessions continue running while it is applied." + .to_string(), + ); + chat_view.mcp_selector_cancel_confirm_external(); + } + fn is_mcp_server_task_running(&self, server_id: &str) -> bool { self.pending_mcp_tasks.iter().any(|task| match task { PendingMcpTask::Toggle { server_id: id, .. } | PendingMcpTask::Delete { server_id: id, .. } => id == server_id, PendingMcpTask::Add { .. } => false, + PendingMcpTask::External { item_id, .. } => item_id == server_id, }) } @@ -152,6 +518,7 @@ impl ChatMode { PendingMcpTask::Toggle { handle, .. } | PendingMcpTask::Add { handle, .. } | PendingMcpTask::Delete { handle, .. } => handle.is_finished(), + PendingMcpTask::External { handle, .. } => handle.is_finished(), }; if !finished { i += 1; @@ -238,6 +605,32 @@ impl ChatMode { chat_view.mcp_selector_update_items(updated_items); } } + PendingMcpTask::External { + item_id: _, + item_name, + handle, + } => { + let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); + match join_result { + Ok(Ok(snapshot)) => { + self.external_source_snapshot = Some(snapshot); + chat_state.add_system_message(format!( + "MCP server choice saved for '{}'", + item_name + )); + } + Ok(Err(error)) => chat_state.add_system_message(format!( + "Could not save the MCP server choice for '{}': {}", + item_name, error + )), + Err(error) => chat_state.add_system_message(format!( + "MCP server update failed for '{}': {}", + item_name, error + )), + } + chat_view.mcp_selector_set_loading(None); + chat_view.mcp_selector_update_items(self.get_mcp_items(rt_handle)); + } } } changed @@ -361,6 +754,8 @@ impl ChatMode { command, args, env, + working_directory: None, + inherit_parent_environment: None, headers, url, auto_start, @@ -372,6 +767,7 @@ impl ChatMode { .get("oauth") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + oauth_enabled: None, xaa: config_obj .get("xaa") .cloned() @@ -500,5 +896,4 @@ impl ChatMode { } } } - } diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index bb1f81912c..a4ece7466f 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -300,6 +300,10 @@ impl ChatMode { ))); } self.external_source_snapshot = Some(snapshot); + if chat_view.mcp_selector_visible() { + chat_view.mcp_selector_cancel_confirm_external(); + chat_view.mcp_selector_update_items(self.get_mcp_items(&rt_handle)); + } needs_redraw = true; } } @@ -340,6 +344,14 @@ impl ChatMode { &rt_handle, ); } + PendingMcpOp::External(item) => { + self.execute_external_mcp_action( + item, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } PendingMcpOp::Add { name, config_json } => { self.execute_mcp_add( &name, diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index c50b2ba74e..7443564830 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -63,7 +63,7 @@ impl ChatView { if self.mcp_selector.captures_mouse(mouse) { let action = self.mcp_selector.handle_mouse_event(mouse); if let McpAction::Toggle(item) = action { - self.pending_mcp_toggle = Some(item.id.clone()); + self.pending_mcp_toggle = Some(item); } return true; } diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 46a4eef469..1106e0150e 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -316,7 +316,7 @@ impl ChatView { } /// Take the pending MCP toggle (set by mouse click) - pub(crate) fn take_pending_mcp_toggle(&mut self) -> Option { + pub(crate) fn take_pending_mcp_toggle(&mut self) -> Option { self.pending_mcp_toggle.take() } @@ -332,6 +332,18 @@ impl ChatView { self.mcp_selector.is_confirm_delete(server_id) } + pub(crate) fn mcp_selector_start_confirm_external(&mut self, server_id: String) { + self.mcp_selector.start_confirm_external(server_id); + } + + pub(crate) fn mcp_selector_is_confirm_external(&self, server_id: &str) -> bool { + self.mcp_selector.is_confirm_external(server_id) + } + + pub(crate) fn mcp_selector_cancel_confirm_external(&mut self) { + self.mcp_selector.cancel_confirm_external(); + } + // ============ MCP add dialog methods ============ pub(crate) fn show_mcp_add_dialog(&mut self) { diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 0dc3346c10..104a61ef94 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -185,7 +185,7 @@ pub(crate) struct ChatView { theme_preview_original: Option, /// Pending MCP toggle from mouse click (consumed by caller) - pending_mcp_toggle: Option, + pending_mcp_toggle: Option, /// Pending skill selector action from mouse click (consumed by caller) pending_skill_action: Option, /// Pending Agent entry action from mouse click (consumed by caller) diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs index 1fb73cb276..33213096bf 100644 --- a/src/apps/cli/src/ui/mcp_selector.rs +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -16,9 +16,46 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, }; +use unicode_width::UnicodeWidthChar; use crate::ui::theme::{StyleKind, Theme}; +fn wrap_confirmation_detail(value: &str, width: usize) -> Vec { + let width = width.max(1); + let mut lines = Vec::new(); + for section in value.split("; ") { + if section.is_empty() { + continue; + } + let mut line = String::new(); + let mut line_width = 0; + for character in section.chars() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if !line.is_empty() && line_width + character_width > width { + lines.push(std::mem::take(&mut line)); + line_width = 0; + } + line.push(character); + line_width += character_width; + } + if !line.is_empty() { + lines.push(line); + } + } + lines +} + +fn confirmation_review_lines(name: &str, detail: &str, width: usize) -> Vec { + let mut lines = vec![format!("Review external MCP server: {name}"), String::new()]; + lines.extend(wrap_confirmation_detail(detail, width)); + lines +} + +fn confirmation_max_scroll(line_count: usize, popup_height: u16) -> u16 { + let visible_detail_height = popup_height.saturating_sub(3).max(1) as usize; + line_count.saturating_sub(visible_detail_height) as u16 +} + /// An MCP server item for display in the selector #[derive(Debug, Clone)] pub(crate) struct McpItem { @@ -27,6 +64,49 @@ pub(crate) struct McpItem { pub server_type: String, pub status: String, pub tool_count: usize, + pub source_label: String, + pub external: bool, + pub detail: String, + pub action: McpItemAction, +} + +#[derive(Debug, Clone)] +pub(crate) enum McpItemAction { + NativeToggle, + ExternalDecision { + candidate_id: String, + decision_key: String, + approved: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + }, + ConflictChoice { + conflict_key: String, + candidate_id: String, + approve_external: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + }, + ReadOnly { + reason: String, + }, +} + +impl McpItem { + pub(crate) fn is_external(&self) -> bool { + self.external + } + + pub(crate) fn requires_external_confirmation(&self) -> bool { + matches!( + self.action, + McpItemAction::ExternalDecision { approved: true, .. } + | McpItemAction::ConflictChoice { + approve_external: true, + .. + } + ) + } } /// Action returned from the MCP selector @@ -47,6 +127,10 @@ pub(super) struct McpSelectorState { pub(super) loading_id: Option, /// Server ID pending delete confirmation (double-tap 'd' to confirm) confirm_delete_id: Option, + confirm_external_id: Option, + confirmation_scroll: u16, + confirmation_max_scroll: u16, + confirmation_reviewed: bool, last_area: Option, } @@ -58,6 +142,10 @@ impl McpSelectorState { visible: false, loading_id: None, confirm_delete_id: None, + confirm_external_id: None, + confirmation_scroll: 0, + confirmation_max_scroll: 0, + confirmation_reviewed: false, last_area: None, } } @@ -76,14 +164,44 @@ impl McpSelectorState { /// Update items in-place (after toggle completes) without closing pub(super) fn update_items(&mut self, items: Vec) { - let selected_idx = self.list_state.selected(); + let selected_idx = self.list_state.selected().unwrap_or(0); + let selected_id = self + .list_state + .selected() + .and_then(|index| self.items.get(index)) + .map(|item| item.id.clone()); self.items = items; - // Preserve selection if possible - if let Some(idx) = selected_idx { - if idx >= self.items.len() { - self.list_state - .select(Some(self.items.len().saturating_sub(1))); - } + if self.items.is_empty() { + self.list_state.select(None); + } else if let Some(index) = selected_id + .as_ref() + .and_then(|id| self.items.iter().position(|item| &item.id == id)) + { + self.list_state.select(Some(index)); + } else { + self.list_state + .select(Some(selected_idx.min(self.items.len().saturating_sub(1)))); + } + let confirm_delete_removed = self + .confirm_delete_id + .as_deref() + .is_some_and(|id| !self.items.iter().any(|item| item.id == id)); + let confirm_external_removed = self + .confirm_external_id + .as_deref() + .is_some_and(|id| !self.items.iter().any(|item| item.id == id)); + let loading_removed = self + .loading_id + .as_deref() + .is_some_and(|id| !self.items.iter().any(|item| item.id == id)); + if confirm_delete_removed { + self.confirm_delete_id = None; + } + if confirm_external_removed { + self.cancel_confirm_external(); + } + if loading_removed { + self.loading_id = None; } } @@ -92,6 +210,7 @@ impl McpSelectorState { // Note: we don't clear items here to support back navigation self.loading_id = None; self.confirm_delete_id = None; + self.cancel_confirm_external(); self.last_area = None; } @@ -117,6 +236,25 @@ impl McpSelectorState { self.confirm_delete_id.as_deref() == Some(server_id) } + pub(super) fn start_confirm_external(&mut self, server_id: String) { + self.confirm_delete_id = None; + self.confirm_external_id = Some(server_id); + self.confirmation_scroll = 0; + self.confirmation_max_scroll = 0; + self.confirmation_reviewed = false; + } + + pub(super) fn is_confirm_external(&self, server_id: &str) -> bool { + self.confirm_external_id.as_deref() == Some(server_id) + } + + pub(super) fn cancel_confirm_external(&mut self) { + self.confirm_external_id = None; + self.confirmation_scroll = 0; + self.confirmation_max_scroll = 0; + self.confirmation_reviewed = false; + } + pub(super) fn is_visible(&self) -> bool { self.visible } @@ -125,6 +263,10 @@ impl McpSelectorState { if !self.visible || self.items.is_empty() { return; } + if self.confirm_external_id.is_some() { + self.confirmation_scroll = self.confirmation_scroll.saturating_sub(1); + return; + } let selected = self.list_state.selected().unwrap_or(0); let len = self.items.len(); let next = (selected + len - 1) % len; @@ -135,6 +277,16 @@ impl McpSelectorState { if !self.visible || self.items.is_empty() { return; } + if self.confirm_external_id.is_some() { + self.confirmation_scroll = self + .confirmation_scroll + .saturating_add(1) + .min(self.confirmation_max_scroll); + if self.confirmation_scroll >= self.confirmation_max_scroll { + self.confirmation_reviewed = true; + } + return; + } let selected = self.list_state.selected().unwrap_or(0); let next = (selected + 1) % self.items.len(); self.list_state.select(Some(next)); @@ -146,6 +298,9 @@ impl McpSelectorState { return None; } let idx = self.list_state.selected()?; + if self.confirm_external_id.is_some() && !self.confirmation_reviewed { + return None; + } self.items.get(idx).cloned() } @@ -158,13 +313,24 @@ impl McpSelectorState { let popup_width = area.width.saturating_sub(4).min(72); // +5 for border(2) + title(1) + hint(1) + padding(1) - let popup_height = (self.items.len() as u16 + 5) - .min(area.height.saturating_sub(2)) - .max(6); - if popup_height < 5 || popup_width < 30 { + let confirmation_height = self + .confirm_external_id + .as_ref() + .and_then(|id| self.items.iter().find(|item| &item.id == id)) + .map(|item| { + wrap_confirmation_detail(&item.detail, popup_width.saturating_sub(6) as usize).len() + as u16 + + 2 + }) + .unwrap_or(0); + let max_popup_height = area.height.saturating_sub(2); + if max_popup_height < 6 || popup_width < 30 { self.last_area = None; return; } + let popup_height = (self.items.len() as u16 + 5 + confirmation_height) + .min(max_popup_height) + .max(6); let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; @@ -177,6 +343,49 @@ impl McpSelectorState { }; self.last_area = Some(popup_area); + if let Some(item) = self + .confirm_external_id + .as_ref() + .and_then(|id| self.items.iter().find(|item| &item.id == id)) + .cloned() + { + let content_width = popup_width.saturating_sub(4).max(1) as usize; + let review_lines = confirmation_review_lines(&item.name, &item.detail, content_width); + let visible_detail_height = popup_height.saturating_sub(3).max(1) as usize; + let max_scroll = confirmation_max_scroll(review_lines.len(), popup_height); + self.confirmation_max_scroll = max_scroll; + self.confirmation_scroll = self.confirmation_scroll.min(max_scroll); + if max_scroll == 0 || self.confirmation_scroll >= max_scroll { + self.confirmation_reviewed = true; + } + let start = self.confirmation_scroll as usize; + let end = (start + visible_detail_height).min(review_lines.len()); + let mut visible_lines = review_lines[start..end] + .iter() + .map(|line| Line::from(Span::styled(line.clone(), theme.style(StyleKind::Warning)))) + .collect::>(); + let footer = if self.confirmation_reviewed { + "Enter:Approve and use Up/Down:Review Esc:Cancel" + } else { + "Up/Down:Review all details Esc:Cancel" + }; + visible_lines.push(Line::from(Span::styled( + footer, + theme.style(StyleKind::Muted), + ))); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Warning)) + .style(Style::default().bg(theme.background)) + .title(" Review External MCP Server "); + let list = List::new(vec![ListItem::new(visible_lines)]) + .block(block) + .style(Style::default().bg(theme.background)); + frame.render_widget(Clear, popup_area); + frame.render_widget(list, popup_area); + return; + } + let loading_id = self.loading_id.clone(); let confirm_delete_id = self.confirm_delete_id.clone(); let has_confirm_delete = confirm_delete_id.is_some(); @@ -256,6 +465,10 @@ impl McpSelectorState { Span::raw(" "), Span::styled(status_text, status_style), Span::styled(tool_text, theme.style(StyleKind::Muted)), + Span::styled( + format!(" [{}]", item.source_label), + theme.style(StyleKind::Muted), + ), ]); ListItem::new(line) }) @@ -269,8 +482,15 @@ impl McpSelectorState { } // Footer hint line — changes when in confirm-delete mode + let selected_external = self + .list_state + .selected() + .and_then(|index| self.items.get(index)) + .is_some_and(McpItem::is_external); let hint_text = if has_confirm_delete { " d:Confirm Delete Any key:Cancel" + } else if selected_external { + " Enter:Review or change External settings are read-only Esc:Close" } else { " a:Add d:Delete e:Edit Config Space:Toggle Esc:Close" }; @@ -331,9 +551,19 @@ impl McpSelectorState { McpAction::None } MouseEventKind::Down(MouseButton::Left) if in_popup => { + if self.confirm_external_id.is_some() { + return McpAction::None; + } if let Some(index) = self.item_index_at(mouse.row, area) { self.list_state.select(Some(index)); if let Some(item) = self.confirm_selection() { + if item.requires_external_confirmation() + && !self.is_confirm_external(&item.id) + { + self.start_confirm_external(item.id); + return McpAction::None; + } + self.cancel_confirm_external(); return McpAction::Toggle(item); } } @@ -371,3 +601,137 @@ impl McpSelectorState { Some(index) } } + +#[cfg(test)] +mod tests { + use super::{ + confirmation_max_scroll, confirmation_review_lines, wrap_confirmation_detail, McpItem, + McpItemAction, McpSelectorState, + }; + use unicode_width::UnicodeWidthStr; + + fn item(action: McpItemAction, external: bool) -> McpItem { + McpItem { + id: "github".to_string(), + name: "github".to_string(), + server_type: "local".to_string(), + status: "Confirmation required".to_string(), + tool_count: 0, + source_label: if external { "OpenCode" } else { "BitFun" }.to_string(), + external, + detail: "Safe summary".to_string(), + action, + } + } + + #[test] + fn enabling_an_external_server_requires_a_second_explicit_confirmation() { + let item = item( + McpItemAction::ExternalDecision { + candidate_id: "candidate".to_string(), + decision_key: "decision".to_string(), + approved: true, + expected_mcp_generation: 3, + expected_preference_revision: 7, + }, + true, + ); + + assert!(item.requires_external_confirmation()); + + let mut state = McpSelectorState::new(); + state.show(vec![item]); + state.start_confirm_external("github".to_string()); + assert!(state.is_confirm_external("github")); + state.cancel_confirm_external(); + assert!(!state.is_confirm_external("github")); + } + + #[test] + fn native_toggle_and_external_disable_do_not_add_extra_confirmation() { + assert!(!item(McpItemAction::NativeToggle, false).requires_external_confirmation()); + assert!(!item( + McpItemAction::ExternalDecision { + candidate_id: "candidate".to_string(), + decision_key: "decision".to_string(), + approved: false, + expected_mcp_generation: 3, + expected_preference_revision: 7, + }, + true, + ) + .requires_external_confirmation()); + } + + #[test] + fn long_external_confirmation_requires_reviewing_to_the_end() { + let external = item( + McpItemAction::ExternalDecision { + candidate_id: "candidate".to_string(), + decision_key: "decision".to_string(), + approved: true, + expected_mcp_generation: 3, + expected_preference_revision: 7, + }, + true, + ); + let mut state = McpSelectorState::new(); + state.show(vec![external]); + state.start_confirm_external("github".to_string()); + state.confirmation_max_scroll = 2; + assert!(state.confirm_selection().is_none()); + state.move_down(); + assert!(state.confirm_selection().is_none()); + state.move_down(); + assert!(state.confirm_selection().is_some()); + } + + #[test] + fn confirmation_wrap_uses_terminal_width_for_cjk_content() { + let lines = wrap_confirmation_detail("来源:项目配置;命令:测试工具", 10); + assert!(lines.len() > 1); + assert!(lines + .iter() + .all(|line| UnicodeWidthStr::width(line.as_str()) <= 10)); + } + + #[test] + fn forty_by_twenty_terminal_can_review_short_and_scroll_long_summaries() { + // A 40x20 terminal yields the selector's 36x18 popup after margins. + let short = confirmation_review_lines("server", "Safe summary", 32); + assert_eq!(confirmation_max_scroll(short.len(), 18), 0); + + let long = confirmation_review_lines("server", &"header: value; ".repeat(80), 32); + assert!(confirmation_max_scroll(long.len(), 18) > 0); + } + + #[test] + fn live_updates_preserve_selection_by_stable_id_and_clear_removed_state() { + let mut first = item(McpItemAction::NativeToggle, false); + first.id = "first".to_string(); + let mut selected = item(McpItemAction::NativeToggle, false); + selected.id = "selected".to_string(); + let mut state = McpSelectorState::new(); + state.show(vec![first.clone(), selected.clone()]); + state.move_down(); + state.loading_id = Some("selected".to_string()); + state.start_confirm_external("selected".to_string()); + + let mut inserted = item(McpItemAction::NativeToggle, false); + inserted.id = "inserted".to_string(); + state.update_items(vec![inserted, first, selected]); + assert_eq!( + state + .list_state + .selected() + .and_then(|index| state.items.get(index)) + .map(|item| item.id.as_str()), + Some("selected") + ); + + state.update_items(Vec::new()); + assert!(state.list_state.selected().is_none()); + assert!(state.loading_id.is_none()); + assert!(state.confirm_external_id.is_none()); + } +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 1666929fd0..5d303d453e 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,13 +1,14 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - choose_external_subagent_conflict, external_source_snapshot, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_tool_conflict_choice, - set_external_tool_target_decision, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, - ExternalSourceDiagnostic, ExternalSubagentConflict, ExternalSubagentSummary, - ExternalToolApprovalRequest, ExternalToolCatalogEntry, ExternalToolConflict, - PromptCommandAvailability, + choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, + set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_tool_conflict_choice, set_external_tool_target_decision, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, + ExternalSubagentConflict, ExternalSubagentSummary, ExternalToolApprovalRequest, + ExternalToolCatalogEntry, ExternalToolConflict, PromptCommandAvailability, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_product_domains::external_sources::{PromptCommandConflict, SourceQualifiedCommandId}; @@ -78,6 +79,29 @@ pub struct ChooseExternalSubagentConflictRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalMcpServerDecisionRequest { + pub workspace_path: Option, + pub candidate_id: String, + pub decision_key: String, + pub approved: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChooseExternalMcpConflictRequest { + pub workspace_path: Option, + pub conflict_key: String, + pub candidate_id: String, + #[serde(default)] + pub approve_external: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotResponse { @@ -94,6 +118,14 @@ pub struct ExternalSourceSnapshotResponse { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_conflicts: Vec, #[serde(default)] + pub mcp_generation: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_conflicts: Vec, + #[serde(default)] pub subagent_generation: u64, #[serde(default)] pub preference_revision: u64, @@ -146,6 +178,10 @@ impl From for ExternalSourceSnapshotResponse { tools: snapshot.tools, tool_approval_requests: snapshot.tool_approval_requests, tool_conflicts: snapshot.tool_conflicts, + mcp_generation: snapshot.mcp_generation, + mcp_servers: snapshot.mcp_servers, + mcp_approval_requests: snapshot.mcp_approval_requests, + mcp_conflicts: snapshot.mcp_conflicts, subagent_generation: snapshot.subagent_generation, preference_revision: snapshot.preference_revision, subagents: snapshot.subagents, @@ -265,6 +301,40 @@ pub async fn choose_external_subagent_conflict_command( .map(Into::into) } +#[tauri::command] +pub async fn set_external_mcp_server_decision_command( + request: SetExternalMcpServerDecisionRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_mcp_server_decision( + workspace, + &request.candidate_id, + &request.decision_key, + request.approved, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) +} + +#[tauri::command] +pub async fn choose_external_mcp_conflict_command( + request: ChooseExternalMcpConflictRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + choose_external_mcp_conflict( + workspace, + &request.conflict_key, + &request.candidate_id, + request.approve_external, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index b0cb9a7777..96a81b0a37 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -89,6 +89,7 @@ async fn load_mcp_resources( refresh: bool, ) -> Result, String> { let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, server_id).await?; let mut resources = manager.get_cached_resources(server_id).await; if refresh || resources.is_empty() { @@ -108,6 +109,7 @@ async fn load_mcp_prompts( refresh: bool, ) -> Result, String> { let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, server_id).await?; let mut prompts = manager.get_cached_prompts(server_id).await; if refresh || prompts.is_empty() { @@ -121,6 +123,17 @@ async fn load_mcp_prompts( Ok(prompts) } +async fn ensure_unscoped_host_mcp_access( + manager: &bitfun_core::service::mcp::MCPServerManager, + server_id: &str, +) -> Result<(), String> { + manager + .server_available_for_context(server_id, None, false) + .await + .then_some(()) + .ok_or_else(|| "MCP server is unavailable in this product surface".to_string()) +} + #[tauri::command] pub async fn initialize_mcp_servers( state: State<'_, AppState>, @@ -198,7 +211,8 @@ pub async fn get_mcp_servers(state: State<'_, AppState>) -> Result, server_id: String) -> .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &server_id).await?; + manager .start_server(&server_id) .await .map_err(|e| e.to_string())?; @@ -407,8 +424,9 @@ pub async fn stop_mcp_server(state: State<'_, AppState>, server_id: String) -> R .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &server_id).await?; + manager .stop_server(&server_id) .await .map_err(|e| e.to_string())?; @@ -426,8 +444,9 @@ pub async fn restart_mcp_server( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &server_id).await?; + manager .restart_server(&server_id) .await .map_err(|e| e.to_string())?; @@ -445,8 +464,9 @@ pub async fn get_mcp_server_status( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - let status = mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &server_id).await?; + let status = manager .get_server_status(&server_id) .await .map_err(|e| e.to_string())?; @@ -590,8 +610,9 @@ pub async fn fetch_mcp_app_resource( return Err("Resource URI must use ui:// scheme".to_string()); } - let connection = mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + let connection = manager .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; @@ -731,8 +752,9 @@ pub async fn send_mcp_app_message( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - let connection = mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + let connection = manager .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; @@ -836,8 +858,9 @@ pub async fn update_mcp_remote_auth( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + manager .reauthenticate_remote_server(&request.server_id, &request.authorization_value) .await .map_err(|e| e.to_string())?; @@ -855,8 +878,9 @@ pub async fn clear_mcp_remote_auth( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + manager .clear_remote_server_auth(&request.server_id) .await .map_err(|e| e.to_string())?; @@ -874,8 +898,9 @@ pub async fn delete_mcp_server( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + manager .remove_server(&request.server_id) .await .map_err(|e| e.to_string())?; @@ -893,8 +918,9 @@ pub async fn start_mcp_remote_oauth( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + manager .start_remote_oauth_authorization(&request.server_id) .await .map_err(|e| e.to_string()) @@ -910,10 +936,9 @@ pub async fn get_mcp_remote_oauth_session( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - Ok(mcp_service - .server_manager() - .get_remote_oauth_session(&request.server_id) - .await) + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + Ok(manager.get_remote_oauth_session(&request.server_id).await) } #[tauri::command] @@ -926,8 +951,9 @@ pub async fn cancel_mcp_remote_oauth( .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - mcp_service - .server_manager() + let manager = mcp_service.server_manager(); + ensure_unscoped_host_mcp_access(&manager, &request.server_id).await?; + manager .cancel_remote_oauth_authorization(&request.server_id) .await .map_err(|e| e.to_string()) diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 5d72932902..025820e138 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -217,6 +217,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("check_path_exists", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "choose_external_mcp_conflict_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "choose_external_subagent_conflict_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1355,6 +1359,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("set_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "set_external_mcp_server_decision_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_source_conflict_choice_command", RemoteWorkspacePolicy::RemoteUnsupported, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a15751550b..afea3b5045 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -935,6 +935,8 @@ pub async fn run() { set_external_tool_conflict_choice_command, set_external_subagent_activation_command, choose_external_subagent_conflict_command, + set_external_mcp_server_decision_command, + choose_external_mcp_conflict_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, get_readonly_tools_info, diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index d3f79e36fa..cbabfeb17c 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -7,10 +7,12 @@ mod agent_source; mod command_source; +mod mcp_source; mod source_adapter; mod tool_source; pub use agent_source::{OpenCodeSubagentProvider, OpenCodeSubagentProviderOptions}; pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; +pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; pub use source_adapter::load_opencode_package_adapter; pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/mcp_source.rs b/src/crates/adapters/opencode-adapter/src/mcp_source.rs new file mode 100644 index 0000000000..ab3b732652 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -0,0 +1,1266 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, + ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, + ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, + ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, + PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, + SourceQualifiedMcpServerId, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "opencode.mcp"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; +const MAX_MCP_SERVERS: usize = 256; +const MAX_COMMAND_PARTS: usize = 256; +const MAX_MAP_ENTRIES: usize = 128; +const MAX_RUNTIME_TEXT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct OpenCodeMcpProviderOptions { + pub user_config_dir: PathBuf, + pub legacy_user_config_dir: Option, + pub explicit_config_file: Option, + pub explicit_config_dir: Option, + pub project_config_enabled: bool, + /// Test/product-host override for an already-known project boundary. + pub project_root_override: Option, +} + +impl OpenCodeMcpProviderOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + let explicit_config_dir = std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from); + let user_config_dir = explicit_config_dir.clone().unwrap_or_else(|| { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")) + .join("opencode") + }); + Self { + user_config_dir, + legacy_user_config_dir: explicit_config_dir + .is_none() + .then(|| home.map(|home| home.join(".opencode"))) + .flatten(), + explicit_config_file: std::env::var_os("OPENCODE_CONFIG").map(PathBuf::from), + explicit_config_dir, + project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), + project_root_override: None, + } + } +} + +impl Default for OpenCodeMcpProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeMcpProvider { + options: OpenCodeMcpProviderOptions, +} + +impl OpenCodeMcpProvider { + pub fn new(options: OpenCodeMcpProviderOptions) -> Self { + Self { options } + } + + fn project_root(&self, workspace_root: &Path) -> PathBuf { + self.options + .project_root_override + .clone() + .unwrap_or_else(|| find_project_root(workspace_root)) + } + + fn discover_layers(&self, context: &ExternalSourceContext) -> Vec { + let mut layers = Vec::new(); + push_config_file( + &mut layers, + &self.options.user_config_dir.join("config.json"), + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + push_config_files( + &mut layers, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + if let Some(path) = &self.options.explicit_config_file { + push_config_file( + &mut layers, + path, + ExternalSourceScope::UserGlobal, + "OpenCode OPENCODE_CONFIG", + ); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + push_config_files( + &mut layers, + &directory, + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + push_config_files( + &mut layers, + &directory.join(".opencode"), + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + } + } + } + if let Some(legacy) = &self.options.legacy_user_config_dir { + if legacy != &self.options.user_config_dir { + push_config_files( + &mut layers, + legacy, + ExternalSourceScope::UserGlobal, + "OpenCode legacy configuration", + ); + } + } + if let Some(directory) = &self.options.explicit_config_dir { + push_config_files( + &mut layers, + directory, + ExternalSourceScope::UserGlobal, + "OpenCode OPENCODE_CONFIG_DIR", + ); + } + deduplicate_layers_keep_last(layers) + } + + fn materialize( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + if input + .context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let provider = self.identity(); + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut merged_servers = BTreeMap::::new(); + let mut provenance = BTreeMap::>::new(); + + for layer in self.discover_layers(&input.context) { + let key = source_key(&layer); + let parsed = parse_config_layer(&layer.path); + let mut layer_diagnostics = parsed + .diagnostics + .into_iter() + .map(|diagnostic| ExternalSourceDiagnostic { + source: Some(key.clone()), + ..diagnostic + }) + .collect::>(); + let health = if parsed.fatal { + ExternalSourceHealth::Unavailable + } else if layer_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Degraded + }; + sources.push(ExternalSourceRecord { + key: key.clone(), + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static OpenCode ecosystem id must be valid"), + display_name: layer.display_name.clone(), + source_kind: "opencode_mcp_config".to_string(), + scope: layer.scope, + location: layer.path.to_string_lossy().to_string(), + execution_domain_id: input.context.execution_domain_id.clone(), + health, + content_version: parsed.content_version, + diagnostics: layer_diagnostics.clone(), + }); + diagnostics.append(&mut layer_diagnostics); + + if parsed.fatal || input.suppressed_sources.contains(&key) { + continue; + } + for (name, patch) in parsed.servers { + if merged_servers.len() >= MAX_MCP_SERVERS && !merged_servers.contains_key(&name) { + diagnostics.push( + ExternalSourceDiagnostic::warning( + "opencode.mcp.server_limit", + format!( + "OpenCode MCP configuration exceeds the {MAX_MCP_SERVERS} server limit" + ), + Some(key.clone()), + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ); + continue; + } + let current = merged_servers + .entry(name.clone()) + .or_insert_with(|| Value::Object(Map::new())); + deep_merge(current, patch); + let entries = provenance.entry(name).or_default(); + if entries.last() != Some(&key) { + entries.push(key.clone()); + } + } + } + + let mut servers = Vec::new(); + let mut prepared = BTreeMap::new(); + for (name, value) in merged_servers { + let server_provenance = provenance.remove(&name).unwrap_or_default(); + let Some(effective_source) = server_provenance.last().cloned() else { + continue; + }; + match materialize_server( + &input.context, + effective_source, + server_provenance, + name, + value, + ) { + Ok(server) => { + let stable_key = server.definition.id.stable_key(); + prepared.insert(stable_key, server.prepared); + servers.push(server.definition); + } + Err(error) => diagnostics.push( + ExternalSourceDiagnostic::warning(error.code, error.message, None) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ), + } + } + servers.sort_by(|left, right| left.name.cmp(&right.name)); + + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources, + servers, + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.mcp.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(MaterializedMcpSnapshot { snapshot, prepared }) + } +} + +impl Default for OpenCodeMcpProvider { + fn default() -> Self { + Self::new(OpenCodeMcpProviderOptions::default()) + } +} + +impl ExternalMcpSourceProvider for OpenCodeMcpProvider { + fn identity(&self) -> ExternalMcpProviderIdentity { + ExternalMcpProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode") + .expect("static OpenCode MCP provider identity must be valid") + } + + fn discover( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + self.materialize(input) + .map(|materialized| materialized.snapshot) + } + + fn prepare_server( + &self, + input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + if server_id.source.provider_id.as_str() != PROVIDER_ID { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.identity_mismatch", + "MCP server is not owned by the OpenCode MCP provider", + false, + )); + } + let materialized = self.materialize(input)?; + let definition = materialized + .snapshot + .servers + .iter() + .find(|definition| &definition.id == server_id) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.mcp.stale_revision", + "MCP server is no longer available at the requested revision", + true, + ) + })?; + if definition.behavior_version != expected_behavior_version { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.stale_revision", + "MCP server behavior changed before activation", + true, + )); + } + if !definition.source_enabled + || !matches!(definition.static_status, ExternalMcpStaticStatus::Ready) + { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.not_activatable", + "MCP server is disabled or unsupported", + false, + )); + } + let prepared = materialized + .prepared + .get(&server_id.stable_key()) + .cloned() + .ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.mcp.preparation_missing", + "MCP runtime preparation is unavailable", + false, + ) + })?; + resolve_runtime_values(prepared, server_id.clone(), expected_behavior_version) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + let mut roots = BTreeMap::new(); + add_directory_watch_roots(&mut roots, &self.options.user_config_dir); + if let Some(legacy) = &self.options.legacy_user_config_dir { + add_directory_watch_roots(&mut roots, legacy); + } + if let Some(path) = &self.options.explicit_config_file { + if let Some(parent) = path.parent() { + add_nearest_existing_watch_root(&mut roots, parent); + } + } + if let Some(directory) = &self.options.explicit_config_dir { + add_directory_watch_roots(&mut roots, directory); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + add_watch_root(&mut roots, directory.clone(), false); + add_directory_watch_roots(&mut roots, &directory.join(".opencode")); + } + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } +} + +struct MaterializedMcpSnapshot { + snapshot: ExternalMcpProviderSnapshot, + prepared: BTreeMap, +} + +#[derive(Clone)] +enum PreparedTransportTemplate { + Local { + command: String, + args: Vec, + environment: BTreeMap, + working_directory: Option, + }, + Remote { + url: String, + headers: BTreeMap, + oauth_enabled: bool, + }, +} + +struct MaterializedServer { + definition: ExternalMcpServerDefinition, + prepared: PreparedTransportTemplate, +} + +fn materialize_server( + context: &ExternalSourceContext, + effective_source: SourceKey, + provenance: Vec, + name: String, + value: Value, +) -> Result { + let behavior_version = behavior_version(&name, &value); + let object = value.as_object().ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.mcp.server_invalid", + format!("OpenCode MCP server '{name}' must be an object"), + false, + ) + })?; + let source_enabled = object + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(true); + let server_type = object.get("type").and_then(Value::as_str); + let inferred_type = server_type.or_else(|| { + if object.contains_key("command") { + Some("local") + } else if object.contains_key("url") { + Some("remote") + } else { + None + } + }); + let id = SourceQualifiedMcpServerId::new(effective_source, name.clone()).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.mcp.name_invalid", + format!("OpenCode MCP server name is invalid: {error}"), + false, + ) + })?; + match inferred_type { + Some("local") => materialize_local_server( + context, + id, + provenance, + name, + object, + source_enabled, + behavior_version, + ), + Some("remote") => materialize_remote_server( + id, + provenance, + name, + object, + source_enabled, + behavior_version, + ), + _ => { + let reason = "OpenCode MCP server type must be 'local' or 'remote'".to_string(); + Ok(MaterializedServer { + definition: ExternalMcpServerDefinition { + id, + provenance, + name, + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("unsupported".to_string()), + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + source_enabled, + behavior_version, + static_status: ExternalMcpStaticStatus::Unsupported { reason }, + }, + prepared: PreparedTransportTemplate::Local { + command: String::new(), + args: Vec::new(), + environment: BTreeMap::new(), + working_directory: None, + }, + }) + } + } +} + +fn materialize_local_server( + context: &ExternalSourceContext, + id: SourceQualifiedMcpServerId, + provenance: Vec, + name: String, + object: &Map, + source_enabled: bool, + behavior_version: String, +) -> Result { + let command_parts = string_array(object.get("command")); + let mut reason = command_parts + .as_ref() + .err() + .cloned() + .or_else(|| timeout_unsupported_reason(object)) + .or_else(|| unsupported_variable_reason(object)); + let command_parts = command_parts.unwrap_or_default(); + if command_parts.is_empty() { + reason.get_or_insert_with(|| "Local MCP command must not be empty".to_string()); + } + if command_parts.len() > MAX_COMMAND_PARTS { + reason.get_or_insert_with(|| { + format!("Local MCP command exceeds the {MAX_COMMAND_PARTS} part limit") + }); + } + let environment = string_map(object.get("environment")); + if let Err(error) = &environment { + reason.get_or_insert(error.clone()); + } + let environment = environment.unwrap_or_default(); + let environment_reference_names = + collect_environment_reference_names(environment.values().map(String::as_str)); + if let Err(error) = &environment_reference_names { + reason.get_or_insert(error.clone()); + } + let environment_reference_names = environment_reference_names.unwrap_or_default(); + let cwd = match object.get("cwd") { + None => context + .workspace_root + .as_ref() + .map(|path| normalize_path_lexically(path)), + Some(Value::String(value)) => { + let path = PathBuf::from(value); + if path.is_absolute() { + Some(normalize_path_lexically(&path)) + } else if let Some(workspace_root) = &context.workspace_root { + Some(normalize_path_lexically(&workspace_root.join(path))) + } else { + reason.get_or_insert_with(|| { + "Relative MCP working directory requires a workspace".to_string() + }); + None + } + } + Some(_) => { + reason.get_or_insert_with(|| "MCP cwd must be a string".to_string()); + None + } + }; + let runtime_bytes = command_parts.iter().map(String::len).sum::() + + environment + .iter() + .map(|(key, value)| key.len() + value.len()) + .sum::(); + if runtime_bytes > MAX_RUNTIME_TEXT_BYTES { + reason.get_or_insert_with(|| { + format!("Local MCP runtime values exceed the {MAX_RUNTIME_TEXT_BYTES} byte limit") + }); + } + let command = command_parts.first().cloned().unwrap_or_default(); + let args = command_parts.iter().skip(1).cloned().collect::>(); + let static_status = if !source_enabled { + ExternalMcpStaticStatus::DisabledBySource + } else if let Some(reason) = reason { + ExternalMcpStaticStatus::Unsupported { reason } + } else { + ExternalMcpStaticStatus::Ready + }; + Ok(MaterializedServer { + definition: ExternalMcpServerDefinition { + id, + provenance, + name, + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some(if command.is_empty() { + "unsupported".to_string() + } else { + command.clone() + }), + argument_count: args.len(), + working_directory: cwd + .as_ref() + .map(|directory| directory.to_string_lossy().to_string()), + environment_keys: environment.keys().cloned().collect(), + environment_reference_names, + remote_url_preview: None, + header_names: Vec::new(), + source_enabled, + behavior_version, + static_status, + }, + prepared: PreparedTransportTemplate::Local { + command, + args, + environment, + working_directory: cwd, + }, + }) +} + +fn materialize_remote_server( + id: SourceQualifiedMcpServerId, + provenance: Vec, + name: String, + object: &Map, + source_enabled: bool, + behavior_version: String, +) -> Result { + let raw_url = object + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let mut reason = + timeout_unsupported_reason(object).or_else(|| unsupported_variable_reason(object)); + let preview_url = match sanitized_https_url(&raw_url) { + Ok(url) => url, + Err(error) => { + reason.get_or_insert(error); + "https://unsupported.invalid/mcp".to_string() + } + }; + let headers = string_map(object.get("headers")); + if let Err(error) = &headers { + reason.get_or_insert(error.clone()); + } + let headers = headers.unwrap_or_default(); + let environment_reference_names = + collect_environment_reference_names(headers.values().map(String::as_str)); + if let Err(error) = &environment_reference_names { + reason.get_or_insert(error.clone()); + } + let environment_reference_names = environment_reference_names.unwrap_or_default(); + let oauth_enabled = match object.get("oauth") { + None => true, + Some(Value::Bool(false)) => false, + Some(Value::Object(oauth)) if oauth.is_empty() => true, + Some(Value::Object(_)) => { + reason.get_or_insert_with(|| { + "Pre-registered OpenCode OAuth client configuration is not supported yet" + .to_string() + }); + true + } + Some(_) => { + reason + .get_or_insert_with(|| "OpenCode MCP oauth must be an object or false".to_string()); + true + } + }; + let runtime_bytes = raw_url.len() + + headers + .iter() + .map(|(key, value)| key.len() + value.len()) + .sum::(); + if runtime_bytes > MAX_RUNTIME_TEXT_BYTES { + reason.get_or_insert_with(|| { + format!("Remote MCP runtime values exceed the {MAX_RUNTIME_TEXT_BYTES} byte limit") + }); + } + let static_status = if !source_enabled { + ExternalMcpStaticStatus::DisabledBySource + } else if let Some(reason) = reason { + ExternalMcpStaticStatus::Unsupported { reason } + } else { + ExternalMcpStaticStatus::Ready + }; + Ok(MaterializedServer { + definition: ExternalMcpServerDefinition { + id, + provenance, + name, + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names, + remote_url_preview: Some(preview_url), + header_names: headers.keys().cloned().collect(), + source_enabled, + behavior_version, + static_status, + }, + prepared: PreparedTransportTemplate::Remote { + url: raw_url, + headers, + oauth_enabled, + }, + }) +} + +fn resolve_runtime_values( + template: PreparedTransportTemplate, + id: SourceQualifiedMcpServerId, + behavior_version: &str, +) -> Result { + let transport = match template { + PreparedTransportTemplate::Local { + command, + args, + environment, + working_directory, + } => { + let command = expand_environment_references(&command)?; + let args = args + .iter() + .map(|value| expand_environment_references(value)) + .collect::, _>>()?; + let environment = environment + .into_iter() + .map(|(key, value)| { + expand_environment_references(&value) + .map(|value| (key, SecretValue::new(value))) + }) + .collect::, _>>()?; + let runtime_bytes = command.len() + + args.iter().map(String::len).sum::() + + environment + .iter() + .map(|(key, value)| key.len() + value.expose().len()) + .sum::(); + if runtime_bytes > MAX_RUNTIME_TEXT_BYTES { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.runtime_too_large", + format!( + "Expanded MCP runtime values exceed the {MAX_RUNTIME_TEXT_BYTES} byte limit" + ), + false, + )); + } + PreparedExternalMcpTransport::Local { + command, + args, + environment, + working_directory, + } + } + PreparedTransportTemplate::Remote { + url, + headers, + oauth_enabled, + } => { + let url = expand_environment_references(&url)?; + sanitized_https_url(&url).map_err(|message| { + ExternalSourceProviderError::new("opencode.mcp.url_invalid", message, false) + })?; + let headers = headers + .into_iter() + .map(|(key, value)| { + expand_environment_references(&value) + .map(|value| (key, SecretValue::new(value))) + }) + .collect::, _>>()?; + let runtime_bytes = url.len() + + headers + .iter() + .map(|(key, value)| key.len() + value.expose().len()) + .sum::(); + if runtime_bytes > MAX_RUNTIME_TEXT_BYTES { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.runtime_too_large", + format!( + "Expanded MCP runtime values exceed the {MAX_RUNTIME_TEXT_BYTES} byte limit" + ), + false, + )); + } + PreparedExternalMcpTransport::Remote { + url, + headers, + oauth_enabled, + } + } + }; + Ok(PreparedExternalMcpServer { + id, + behavior_version: behavior_version.to_string(), + transport, + }) +} + +fn replace_environment_references( + value: &str, + mut resolve: impl FnMut(&str) -> Result, +) -> Result { + let mut output = String::with_capacity(value.len()); + let mut remainder = value; + while let Some(start) = remainder.find("{env:") { + output.push_str(&remainder[..start]); + let after_start = &remainder[start + 5..]; + let Some(end) = after_start.find('}') else { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.variable_invalid", + "OpenCode environment reference is not closed", + false, + )); + }; + let name = &after_start[..end]; + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(ExternalSourceProviderError::new( + "opencode.mcp.variable_invalid", + "OpenCode environment reference name is invalid", + false, + )); + } + let resolved = resolve(name)?; + output.push_str(&resolved); + remainder = &after_start[end + 1..]; + } + output.push_str(remainder); + Ok(output) +} + +fn expand_environment_references(value: &str) -> Result { + replace_environment_references(value, |name| { + std::env::var(name).map_err(|_| { + ExternalSourceProviderError::new( + "opencode.mcp.environment_missing", + format!("Required environment variable '{name}' is not available"), + true, + ) + }) + }) +} + +fn collect_environment_reference_names<'a>( + values: impl IntoIterator, +) -> Result, String> { + let mut names = BTreeSet::new(); + for value in values { + replace_environment_references(value, |name| { + names.insert(name.to_string()); + Ok(String::new()) + }) + .map_err(|error| error.message)?; + } + if names.len() > MAX_MAP_ENTRIES { + return Err(format!( + "MCP environment references exceed the {MAX_MAP_ENTRIES} entry limit" + )); + } + Ok(names.into_iter().collect()) +} + +fn timeout_unsupported_reason(object: &Map) -> Option { + match object.get("timeout") { + None => None, + Some(Value::Number(number)) if number.as_u64() == Some(5000) => None, + Some(_) => { + Some("Custom OpenCode MCP initialization timeout is not supported yet".to_string()) + } + } +} + +fn unsupported_variable_reason(object: &Map) -> Option { + let encoded = serde_json::to_string(object).ok()?; + if encoded.contains("{file:") { + return Some( + "OpenCode file variable references are not supported for MCP servers".to_string(), + ); + } + let executable_reference = + object + .get("command") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts + .iter() + .filter_map(Value::as_str) + .any(|part| part.contains("{env:")) + }); + let address_or_cwd_reference = ["url", "cwd"].into_iter().any(|key| { + object + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| value.contains("{env:")) + }); + (executable_reference || address_or_cwd_reference).then(|| { + "Environment references are supported only in MCP environment and header values".to_string() + }) +} + +fn string_array(value: Option<&Value>) -> Result, String> { + let values = value + .and_then(Value::as_array) + .ok_or_else(|| "Local MCP command must be an array of strings".to_string())?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| "Local MCP command must contain only strings".to_string()) + }) + .collect() +} + +fn string_map(value: Option<&Value>) -> Result, String> { + let Some(value) = value else { + return Ok(BTreeMap::new()); + }; + let object = value + .as_object() + .ok_or_else(|| "MCP environment or headers must be an object".to_string())?; + if object.len() > MAX_MAP_ENTRIES { + return Err(format!( + "MCP environment or headers exceed the {MAX_MAP_ENTRIES} entry limit" + )); + } + object + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| "MCP environment and header values must be strings".to_string()) + }) + .collect() +} + +fn sanitized_https_url(value: &str) -> Result { + let mut url = url::Url::parse(value).map_err(|_| "Remote MCP URL is invalid".to_string())?; + if url.scheme() != "https" { + return Err("Remote MCP URL must use HTTPS".to_string()); + } + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + Ok(url.to_string()) +} + +struct ConfigLayer { + path: PathBuf, + scope: ExternalSourceScope, + display_name: String, +} + +struct ParsedConfigLayer { + servers: BTreeMap, + diagnostics: Vec, + content_version: String, + fatal: bool, +} + +fn parse_config_layer(path: &Path) -> ParsedConfigLayer { + match fs::metadata(path) { + Ok(metadata) if metadata.len() > MAX_CONFIG_FILE_BYTES => { + return ParsedConfigLayer { + servers: BTreeMap::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.mcp.config_too_large", + "OpenCode config exceeds the 1 MiB compatibility limit", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp)], + content_version: format!("too-large:{}", metadata.len()), + fatal: true, + }; + } + Ok(_) => {} + Err(error) => { + return ParsedConfigLayer { + servers: BTreeMap::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.mcp.config_unreadable", + format!("Failed to inspect OpenCode MCP config: {error}"), + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp)], + content_version: "unreadable".to_string(), + fatal: true, + }; + } + } + match fs::read_to_string(path) { + Ok(content) => { + let content_version = content_version(path, content.as_bytes()); + let value = match serde_json::from_str::(&strip_jsonc(&content)) { + Ok(value) => value, + Err(error) => { + return ParsedConfigLayer { + servers: BTreeMap::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.mcp.config_invalid", + format!("Failed to parse OpenCode MCP config: {error}"), + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp)], + content_version, + fatal: true, + }; + } + }; + let servers = match value.get("mcp") { + None => BTreeMap::new(), + Some(Value::Object(servers)) => servers + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + Some(_) => { + return ParsedConfigLayer { + servers: BTreeMap::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.mcp.config_invalid", + "OpenCode top-level mcp field must be an object", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp)], + content_version, + fatal: true, + }; + } + }; + ParsedConfigLayer { + servers, + diagnostics: Vec::new(), + content_version, + fatal: false, + } + } + Err(error) => ParsedConfigLayer { + servers: BTreeMap::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.mcp.config_unreadable", + format!("Failed to read OpenCode MCP config: {error}"), + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp)], + content_version: "unreadable".to_string(), + fatal: true, + }, + } +} + +fn deep_merge(current: &mut Value, patch: Value) { + match (current, patch) { + (Value::Object(current), Value::Object(patch)) => { + for (key, value) in patch { + match current.get_mut(&key) { + Some(existing) => deep_merge(existing, value), + None => { + current.insert(key, value); + } + } + } + } + (current, patch) => *current = patch, + } +} + +fn behavior_version(name: &str, value: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + hasher.update([0]); + hasher.update(serde_json::to_vec(value).unwrap_or_default()); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn content_version(path: &Path, content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update(content); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn source_key(layer: &ConfigLayer) -> SourceKey { + let identity_path = + dunce::canonicalize(&layer.path).unwrap_or_else(|_| normalize_path_lexically(&layer.path)); + let mut hasher = Sha256::new(); + hasher.update(b"opencode_mcp_config"); + hasher.update([0]); + hasher.update(identity_path.to_string_lossy().as_bytes()); + let digest = hex::encode(hasher.finalize()); + SourceKey::new( + PROVIDER_ID, + format!("opencode_mcp_config-{}", &digest[..24]), + ) + .expect("hashed OpenCode MCP source id must be valid") +} + +fn push_config_files( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + for name in ["opencode.json", "opencode.jsonc"] { + push_config_file(layers, &directory.join(name), scope, display_name); + } +} + +fn push_config_file( + layers: &mut Vec, + path: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + let should_inspect = match fs::metadata(path) { + Ok(metadata) => metadata.is_file(), + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + }; + if should_inspect { + layers.push(ConfigLayer { + path: path.to_path_buf(), + scope, + display_name: display_name.to_string(), + }); + } +} + +fn deduplicate_layers_keep_last(layers: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut layers = layers + .into_iter() + .rev() + .filter(|layer| seen.insert(source_key(layer))) + .collect::>(); + layers.reverse(); + layers +} + +fn strip_jsonc(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let chars = input.chars().collect::>(); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + while index < chars.len() { + let current = chars[index]; + if in_string { + output.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + if current == '"' { + in_string = true; + output.push(current); + index += 1; + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'/') { + index += 2; + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + output.push('\n'); + index += usize::from(index < chars.len()); + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'*') { + index += 2; + while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { + if chars[index] == '\n' { + output.push('\n'); + } + index += 1; + } + index = (index + 2).min(chars.len()); + continue; + } + if current == ',' { + let mut lookahead = index + 1; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if matches!(chars.get(lookahead), Some('}') | Some(']')) { + index += 1; + continue; + } + } + output.push(current); + index += 1; + } + output +} + +fn environment_truthy(key: &str) -> bool { + std::env::var(key) + .ok() + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) +} + +fn find_project_root(start: &Path) -> PathBuf { + let start = if start.is_file() { + start.parent().unwrap_or(start) + } else { + start + }; + start + .ancestors() + .find(|path| path.join(".git").exists()) + .unwrap_or(start) + .to_path_buf() +} + +fn directories_between(root: &Path, opened: &Path) -> Vec { + let opened = if opened.is_file() { + opened.parent().unwrap_or(opened) + } else { + opened + }; + let mut directories = opened + .ancestors() + .take_while(|path| path.starts_with(root)) + .map(Path::to_path_buf) + .collect::>(); + directories.reverse(); + directories +} + +fn normalize_path_lexically(path: &Path) -> PathBuf { + use std::path::Component; + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn nearest_existing_path(mut path: PathBuf) -> Option { + loop { + if path.exists() { + return Some(path); + } + if !path.pop() { + return None; + } + } +} + +fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { + roots + .entry(path) + .and_modify(|existing| *existing |= recursive) + .or_insert(recursive); +} + +fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { + if let Some(path) = nearest_existing_path(path.to_path_buf()) { + add_watch_root(roots, path, false); + } +} + +fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { + if let Some(parent) = directory.parent() { + add_nearest_existing_watch_root(roots, parent); + } + add_watch_root(roots, directory.to_path_buf(), true); +} diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs new file mode 100644 index 0000000000..cbaaaa0b49 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs @@ -0,0 +1,346 @@ +use bitfun_opencode_adapter::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalMcpDiscoveryInput, ExternalMcpSourceProvider, + ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceContext, ExternalSourceScope, + PreparedExternalMcpTransport, +}; +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn context(workspace_root: PathBuf) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace_root), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +fn options(user_config_dir: PathBuf) -> OpenCodeMcpProviderOptions { + OpenCodeMcpProviderOptions { + user_config_dir, + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: None, + } +} + +#[test] +fn discovery_deep_merges_layers_without_exposing_or_executing_runtime_values() { + let temp = TempDir::new().unwrap(); + let user = temp.path().join("user"); + let project = temp.path().join("project"); + fs::create_dir_all(&user).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + let marker = temp.path().join("must-not-exist.txt"); + fs::write( + user.join("opencode.jsonc"), + format!( + r#"{{ + // Static discovery must not launch this command. + "mcp": {{ + "local-tools": {{ + "type": "local", + "command": ["powershell", "-NoProfile", "-Command", "Set-Content", "{}", "executed"], + "cwd": "tools", + "environment": {{ + "PRIVATE_TOKEN": "literal-secret", + "READ_TOKEN": "{{env:OPENCODE_MCP_TEST_TOKEN}}" + }} + }}, + "github": {{ + "type": "remote", + "url": "https://global.example.test/private/token-path?token=hidden", + "headers": {{"Authorization": "Bearer secret"}} + }} + }} + }}"#, + marker.display().to_string().replace('\\', "\\\\") + ), + ) + .unwrap(); + fs::write( + project.join("opencode.json"), + r#"{ + "mcp": { + "github": { + "url": "https://project.example.test/mcp", + "headers": {"X-Project": "enabled"} + } + } + }"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user.clone())); + let input = ExternalMcpDiscoveryInput { + context: context(project.clone()), + suppressed_sources: BTreeSet::new(), + }; + let snapshot = provider.discover(&input).unwrap(); + + assert!(!marker.exists(), "discovery must remain static"); + assert_eq!(snapshot.servers.len(), 2); + let github = snapshot + .servers + .iter() + .find(|server| server.name == "github") + .unwrap(); + assert_eq!(github.transport, ExternalMcpTransportKind::StreamableHttp); + assert_eq!( + github.remote_url_preview.as_deref(), + Some("https://project.example.test/") + ); + assert_eq!( + github.header_names, + vec!["Authorization".to_string(), "X-Project".to_string()] + ); + assert_eq!(github.provenance.len(), 2); + let local = snapshot + .servers + .iter() + .find(|server| server.name == "local-tools") + .unwrap(); + assert_eq!(local.command_preview.as_deref(), Some("powershell")); + assert_eq!(local.argument_count, 5); + assert_eq!( + local.environment_keys, + vec!["PRIVATE_TOKEN".to_string(), "READ_TOKEN".to_string()] + ); + assert_eq!( + local.environment_reference_names, + vec!["OPENCODE_MCP_TEST_TOKEN".to_string()] + ); + assert_eq!( + local.working_directory.as_deref(), + Some(project.join("tools").to_string_lossy().as_ref()) + ); + + let encoded = serde_json::to_string(&snapshot).unwrap(); + assert!(!encoded.contains("literal-secret")); + assert!(!encoded.contains("Bearer secret")); + assert!(!encoded.contains("token=hidden")); + assert!(!encoded.contains("private/token-path")); + + let prepared = provider + .prepare_server(&input, &github.id, &github.behavior_version) + .unwrap(); + match prepared.transport { + PreparedExternalMcpTransport::Remote { headers, url, .. } => { + assert_eq!(url, "https://project.example.test/mcp"); + assert_eq!(headers["Authorization"].expose(), "Bearer secret"); + assert_eq!(headers["X-Project"].expose(), "enabled"); + } + other => panic!("expected remote transport, got {other:?}"), + } +} + +#[test] +fn local_server_without_cwd_uses_the_workspace_like_opencode() { + let temp = TempDir::new().unwrap(); + let user = temp.path().join("user"); + let project = temp.path().join("project"); + fs::create_dir_all(&user).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::write( + user.join("opencode.json"), + r#"{"mcp":{"local":{"type":"local","command":["node","server.js"]}}}"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user)); + let input = ExternalMcpDiscoveryInput { + context: context(project.clone()), + suppressed_sources: BTreeSet::new(), + }; + let snapshot = provider.discover(&input).unwrap(); + let server = &snapshot.servers[0]; + + assert_eq!( + server.working_directory.as_deref(), + Some(project.to_string_lossy().as_ref()) + ); + let prepared = provider + .prepare_server(&input, &server.id, &server.behavior_version) + .unwrap(); + match prepared.transport { + PreparedExternalMcpTransport::Local { + working_directory, .. + } => assert_eq!(working_directory.as_deref(), Some(project.as_path())), + other => panic!("expected local transport, got {other:?}"), + } +} + +#[test] +fn suppression_recomputes_the_opencode_merge_and_stale_prepare_fails_closed() { + let temp = TempDir::new().unwrap(); + let user = temp.path().join("user"); + let project = temp.path().join("project"); + fs::create_dir_all(&user).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::write( + user.join("opencode.json"), + r#"{"mcp":{"github":{"type":"remote","url":"https://global.example.test/mcp"}}}"#, + ) + .unwrap(); + fs::write( + project.join("opencode.json"), + r#"{"mcp":{"github":{"url":"https://project.example.test/mcp"}}}"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user)); + let base_input = ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + }; + let initial = provider.discover(&base_input).unwrap(); + let initial_github = initial + .servers + .iter() + .find(|server| server.name == "github") + .unwrap(); + let old_version = initial_github.behavior_version.clone(); + let project_source = initial + .sources + .iter() + .find(|source| source.scope == ExternalSourceScope::Project) + .unwrap() + .key + .clone(); + let suppressed_input = ExternalMcpDiscoveryInput { + context: base_input.context.clone(), + suppressed_sources: [project_source].into_iter().collect(), + }; + let suppressed = provider.discover(&suppressed_input).unwrap(); + let github = suppressed + .servers + .iter() + .find(|server| server.name == "github") + .unwrap(); + assert_eq!( + github.remote_url_preview.as_deref(), + Some("https://global.example.test/") + ); + assert_ne!(github.behavior_version, old_version); + + let error = provider + .prepare_server(&suppressed_input, &github.id, &old_version) + .unwrap_err(); + assert_eq!(error.code, "opencode.mcp.stale_revision"); +} + +#[test] +fn unsupported_or_source_disabled_servers_remain_visible_but_cannot_be_prepared() { + let temp = TempDir::new().unwrap(); + let user = temp.path().join("user"); + let project = temp.path().join("project"); + fs::create_dir_all(&user).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::write( + user.join("opencode.json"), + r#"{ + "mcp": { + "disabled": {"type":"local","command":["node","server.js"],"enabled":false}, + "insecure": {"type":"remote","url":"http://example.test/mcp"}, + "custom-timeout": {"type":"remote","url":"https://example.test/mcp","timeout":1000}, + "client-secret": { + "type":"remote", + "url":"https://example.test/mcp", + "oauth":{"clientId":"id","clientSecret":"secret"} + }, + "mutable-command": {"type":"local","command":["{env:MCP_COMMAND}"]}, + "mutable-host": {"type":"remote","url":"https://{env:MCP_HOST}/mcp"} + } + }"#, + ) + .unwrap(); + let provider = OpenCodeMcpProvider::new(options(user)); + let input = ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + }; + let snapshot = provider.discover(&input).unwrap(); + + assert!(matches!( + snapshot + .servers + .iter() + .find(|server| server.name == "disabled") + .unwrap() + .static_status, + ExternalMcpStaticStatus::DisabledBySource + )); + for name in [ + "insecure", + "custom-timeout", + "client-secret", + "mutable-command", + "mutable-host", + ] { + let server = snapshot + .servers + .iter() + .find(|server| server.name == name) + .unwrap(); + assert!(matches!( + server.static_status, + ExternalMcpStaticStatus::Unsupported { .. } + )); + assert!(provider + .prepare_server(&input, &server.id, &server.behavior_version) + .is_err()); + } +} + +#[test] +fn opencode_config_dir_is_a_global_late_override_like_the_source_application() { + let temp = TempDir::new().unwrap(); + let user = temp.path().join("user"); + let project = temp.path().join("project"); + let explicit = temp.path().join("explicit"); + fs::create_dir_all(&user).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::create_dir_all(&explicit).unwrap(); + fs::write( + user.join("opencode.json"), + r#"{"mcp":{"github":{"type":"remote","url":"https://global.example.test/mcp"}}}"#, + ) + .unwrap(); + fs::write( + project.join("opencode.json"), + r#"{"mcp":{"github":{"url":"https://project.example.test/mcp"}}}"#, + ) + .unwrap(); + fs::write( + explicit.join("opencode.jsonc"), + r#"{"mcp":{"github":{"url":"https://explicit.example.test/mcp"}}}"#, + ) + .unwrap(); + let mut provider_options = options(user); + provider_options.explicit_config_dir = Some(explicit.clone()); + let provider = OpenCodeMcpProvider::new(provider_options); + let snapshot = provider + .discover(&ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + }) + .unwrap(); + let github = snapshot + .servers + .iter() + .find(|server| server.name == "github") + .unwrap(); + assert_eq!( + github.remote_url_preview.as_deref(), + Some("https://explicit.example.test/") + ); + let explicit_source = snapshot + .sources + .iter() + .find(|source| source.location == explicit.join("opencode.jsonc").to_string_lossy()) + .unwrap(); + assert_eq!(explicit_source.scope, ExternalSourceScope::UserGlobal); +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs index e10423128f..5778bfe1c6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs @@ -64,8 +64,17 @@ async fn list_prompts_for_server( async fn ensure_mcp_server_available_for_context( manager: &Arc, server_id: &str, - _context: &ToolUseContext, + context: &ToolUseContext, ) -> BitFunResult<()> { + if !manager + .server_available_for_context(server_id, context.workspace_root(), context.is_remote()) + .await + { + return Err(tool_error(format!( + "MCP server is unavailable in the current workspace: {}", + server_id + ))); + } manager .get_connection(server_id) .await diff --git a/src/crates/assembly/core/src/external_mcp.rs b/src/crates/assembly/core/src/external_mcp.rs new file mode 100644 index 0000000000..8feb2a440c --- /dev/null +++ b/src/crates/assembly/core/src/external_mcp.rs @@ -0,0 +1,591 @@ +use crate::service::mcp::{ + get_global_mcp_service, ConfigLocation, MCPServerConfig, MCPServerStatus, MCPServerTransport, + MCPServerType, +}; +use async_trait::async_trait; +use bitfun_external_sources::ExternalMcpCoordinatorSnapshot; +use bitfun_product_domains::external_sources::{ + external_mcp_approval_key, external_mcp_conflict_key, ExternalMcpActivationState, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalMcpConflictCandidate, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalSourceDiagnostic, PreparedExternalMcpServer, PreparedExternalMcpTransport, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::time::Duration; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ExternalMcpDecision { + pub decision_key: String, + pub approved: bool, +} + +pub(super) struct ExternalMcpDecisions<'a> { + pub server_decisions: &'a BTreeMap, + pub conflict_choices: &'a BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeMcpCandidate { + pub candidate_id: String, + pub server_id: String, + pub name: String, + pub display_name: String, + pub behavior_version: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ActiveExternalMcpCandidate { + pub runtime_id: String, + pub definition: ExternalMcpServerDefinition, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct ExternalMcpProductState { + pub entries: Vec, + pub approval_requests: Vec, + pub conflicts: Vec, + pub active: Vec, + pub suppressed_native_server_ids: std::collections::BTreeSet, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ExternalMcpRuntimeStatus { + Active, + Loading, + Unavailable(String), +} + +/// Narrow product-to-runtime port. Product reconciliation works only with +/// source-neutral prepared MCP data; the concrete BitFun MCP manager remains +/// behind this implementation boundary. +#[async_trait] +pub(super) trait ExternalMcpRuntimePort: Send + Sync { + async fn install( + &self, + candidate: &ActiveExternalMcpCandidate, + prepared: PreparedExternalMcpServer, + workspace_key: &str, + ) -> Result<(), String>; + + async fn retire(&self, runtime_id: &str) -> Result<(), String>; + + async fn status(&self, runtime_id: &str) -> Result; + + async fn replace_workspace_route( + &self, + workspace_key: &str, + active_external_server_ids: std::collections::BTreeSet, + suppressed_native_server_ids: std::collections::BTreeSet, + ) -> Result<(), String>; +} + +pub(super) struct BitFunExternalMcpRuntime; + +#[async_trait] +impl ExternalMcpRuntimePort for BitFunExternalMcpRuntime { + async fn install( + &self, + candidate: &ActiveExternalMcpCandidate, + prepared: PreparedExternalMcpServer, + workspace_key: &str, + ) -> Result<(), String> { + if prepared.id != candidate.definition.id + || prepared.behavior_version != candidate.definition.behavior_version + { + return Err("The external MCP configuration changed before activation".to_string()); + } + let config = prepared_mcp_config(candidate, prepared)?; + mcp_manager()? + .install_external_ephemeral_server(config, workspace_key.to_string()) + .await + // Runtime errors may contain a URL or command line. Keep the + // product-facing error actionable without echoing sensitive data. + .map_err(|_| "The external MCP server could not be started".to_string()) + } + + async fn retire(&self, runtime_id: &str) -> Result<(), String> { + mcp_manager()? + .retire_external_ephemeral_server(runtime_id) + .await + .map_err(|_| "The external MCP server could not be stopped cleanly".to_string()) + } + + async fn status(&self, runtime_id: &str) -> Result { + let manager = mcp_manager()?; + if manager.external_server_readiness(runtime_id).await == Some(false) { + return Ok(ExternalMcpRuntimeStatus::Loading); + } + let status = match tokio::time::timeout( + Duration::from_millis(100), + manager.get_server_status(runtime_id), + ) + .await + { + Ok(Ok(status)) => status, + Ok(Err(_)) => return Err("The external MCP server is no longer available".to_string()), + Err(_) => return Ok(ExternalMcpRuntimeStatus::Loading), + }; + Ok(match status { + MCPServerStatus::Connected | MCPServerStatus::Healthy => { + ExternalMcpRuntimeStatus::Active + } + MCPServerStatus::Uninitialized + | MCPServerStatus::Starting + | MCPServerStatus::Reconnecting => ExternalMcpRuntimeStatus::Loading, + MCPServerStatus::NeedsAuth => ExternalMcpRuntimeStatus::Unavailable( + "Authentication is required for this MCP server".to_string(), + ), + MCPServerStatus::Failed => ExternalMcpRuntimeStatus::Unavailable( + "The MCP server failed to start or stopped unexpectedly".to_string(), + ), + MCPServerStatus::Stopping | MCPServerStatus::Stopped => { + ExternalMcpRuntimeStatus::Unavailable( + "The MCP server is not currently running".to_string(), + ) + } + }) + } + + async fn replace_workspace_route( + &self, + workspace_key: &str, + active_external_server_ids: std::collections::BTreeSet, + suppressed_native_server_ids: std::collections::BTreeSet, + ) -> Result<(), String> { + mcp_manager()? + .replace_external_workspace_tool_route( + workspace_key.to_string(), + active_external_server_ids, + suppressed_native_server_ids, + ) + .await; + Ok(()) + } +} + +fn mcp_manager() -> Result, String> { + get_global_mcp_service() + .map(|service| service.server_manager()) + .ok_or_else(|| "The BitFun MCP runtime is not available in this product host".to_string()) +} + +pub(super) fn prepared_mcp_config( + candidate: &ActiveExternalMcpCandidate, + prepared: PreparedExternalMcpServer, +) -> Result { + let ( + server_type, + transport, + command, + args, + env, + working_directory, + headers, + url, + oauth_enabled, + ) = match prepared.transport { + PreparedExternalMcpTransport::Local { + command, + args, + environment, + working_directory, + } => ( + MCPServerType::Local, + MCPServerTransport::Stdio, + Some(command), + args, + environment + .into_iter() + .map(|(key, value)| (key, value.expose().to_string())) + .collect(), + working_directory.map(|path| path.to_string_lossy().to_string()), + Default::default(), + None, + None, + ), + PreparedExternalMcpTransport::Remote { + url, + headers, + oauth_enabled, + } => ( + MCPServerType::Remote, + MCPServerTransport::StreamableHttp, + None, + Vec::new(), + Default::default(), + None, + headers + .into_iter() + .map(|(key, value)| (key, value.expose().to_string())) + .collect(), + Some(url), + Some(oauth_enabled), + ), + }; + let config = MCPServerConfig { + id: candidate.runtime_id.clone(), + name: candidate.definition.name.clone(), + server_type, + transport: Some(transport), + command, + args, + env, + working_directory, + inherit_parent_environment: matches!(server_type, MCPServerType::Local).then_some(false), + headers, + url, + auto_start: true, + enabled: true, + location: ConfigLocation::BuiltIn, + capabilities: Vec::new(), + settings: Default::default(), + oauth: None, + oauth_enabled, + xaa: None, + }; + config.validate().map_err(|_| { + "The external MCP configuration is not valid for the BitFun runtime".to_string() + })?; + Ok(config) +} + +struct CandidateGroup<'a> { + native: Vec<&'a NativeMcpCandidate>, + external: Vec<&'a ExternalMcpServerDefinition>, +} + +impl<'a> Default for CandidateGroup<'a> { + fn default() -> Self { + Self { + native: Vec::new(), + external: Vec::new(), + } + } +} + +/// Produces the source-neutral product decision for external MCP candidates. +/// This function is pure: no provider preparation, process launch, credential +/// access, or network request can occur while a user decision is pending. +pub(super) fn reconcile_external_mcp_catalog( + execution_domain_id: &str, + workspace_key: &str, + snapshot: &ExternalMcpCoordinatorSnapshot, + native_candidates: &[NativeMcpCandidate], + decisions: ExternalMcpDecisions<'_>, +) -> ExternalMcpProductState { + let mut groups = BTreeMap::>::new(); + for native in native_candidates { + groups + .entry(native.name.to_ascii_lowercase()) + .or_default() + .native + .push(native); + } + for external in &snapshot.servers { + groups + .entry(external.name.to_ascii_lowercase()) + .or_default() + .external + .push(external); + } + + let source_names = snapshot + .sources + .iter() + .map(|source| { + ( + source.record.key.clone(), + source.record.display_name.clone(), + ) + }) + .collect::>(); + let mut state = ExternalMcpProductState::default(); + + for (_, mut group) in groups { + group + .native + .sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id)); + group + .external + .sort_by(|left, right| left.candidate_id().cmp(&right.candidate_id())); + let participant_count = group.native.len() + group.external.len(); + let server_name = group + .native + .first() + .map(|candidate| candidate.name.as_str()) + .or_else(|| group.external.first().map(|definition| definition.name.as_str())) + .unwrap_or_default(); + let pending_conflict = build_conflict( + execution_domain_id, + workspace_key, + &group, + &source_names, + decisions.conflict_choices, + ); + let conflict_lineage = pending_conflict + .conflict_key + .rsplit_once(':') + .map(|(lineage, _)| lineage) + .unwrap_or_default(); + let prior_external_selection = decisions.conflict_choices.iter().any(|(key, selected)| { + key.rsplit_once(':') + .is_some_and(|(lineage, _)| lineage == conflict_lineage) + && selected.starts_with("external_mcp:") + }); + let conflict = if participant_count > 1 + || decisions.conflict_choices.keys().any(|key| { + key.rsplit_once(':') + .is_some_and(|(lineage, _)| lineage == conflict_lineage) + }) + { + Some(pending_conflict) + } else { + None + }; + let selected_candidate_id = conflict + .as_ref() + .and_then(|conflict| conflict.selected_candidate_id.as_deref()); + let selected_external = selected_candidate_id.is_some_and(|selected| { + group + .external + .iter() + .any(|definition| definition.candidate_id() == selected) + }); + if selected_external || (selected_candidate_id.is_none() && prior_external_selection) { + state.suppressed_native_server_ids.extend( + group + .native + .iter() + .map(|candidate| candidate.server_id.clone()), + ); + } + + for definition in group.external { + let candidate_id = definition.candidate_id(); + let approval_key = external_mcp_approval_key( + execution_domain_id, + workspace_key, + &definition.id, + &definition.behavior_version, + ); + let decision = current_or_previous_mcp_decision( + decisions.server_decisions, + &approval_key, + ); + let static_unavailable = static_unavailable_reason(definition); + let activation_state = if let Some(reason) = static_unavailable { + if !definition.source_enabled + || matches!( + definition.static_status, + ExternalMcpStaticStatus::DisabledBySource + ) + { + ExternalMcpActivationState::SourceDisabled + } else { + ExternalMcpActivationState::Unsupported { reason } + } + } else if conflict.is_some() && selected_candidate_id.is_none() { + ExternalMcpActivationState::Conflict + } else if let Some(selected) = selected_candidate_id { + if selected != candidate_id { + ExternalMcpActivationState::Covered { + selected_candidate_id: selected.to_string(), + } + } else { + decision_state(decision, &approval_key) + } + } else { + decision_state(decision, &approval_key) + }; + let runtime_id = matches!(activation_state, ExternalMcpActivationState::Active) + .then(|| external_mcp_runtime_id(workspace_key, definition)); + let entry = ExternalMcpCatalogEntry { + candidate_id: candidate_id.clone(), + definition: definition.clone(), + approval_key: approval_key.clone(), + decision_key: approval_key.clone(), + runtime_id: runtime_id.clone(), + activation_state: activation_state.clone(), + }; + if matches!( + activation_state, + ExternalMcpActivationState::ApprovalRequired + | ExternalMcpActivationState::ConfigurationChanged + ) { + state.approval_requests.push(ExternalMcpApprovalRequest { + candidate_id: candidate_id.clone(), + approval_key: approval_key.clone(), + decision_key: approval_key, + definition: definition.clone(), + }); + } + if let Some(runtime_id) = runtime_id { + state.active.push(ActiveExternalMcpCandidate { + runtime_id, + definition: definition.clone(), + }); + } + state.entries.push(entry); + } + if let Some(conflict) = conflict { + state.conflicts.push(conflict); + } + } + + state.entries.sort_by(|left, right| { + left.definition + .name + .cmp(&right.definition.name) + .then(left.candidate_id.cmp(&right.candidate_id)) + }); + state + .approval_requests + .sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id)); + state + .conflicts + .sort_by(|left, right| left.server_name.cmp(&right.server_name)); + state +} + +fn decision_state( + decision: Option<&ExternalMcpDecision>, + current_decision_key: &str, +) -> ExternalMcpActivationState { + match decision { + Some(decision) if decision.decision_key == current_decision_key && decision.approved => { + ExternalMcpActivationState::Active + } + Some(decision) if decision.decision_key == current_decision_key => { + ExternalMcpActivationState::Declined + } + Some(_) => ExternalMcpActivationState::ConfigurationChanged, + None => ExternalMcpActivationState::ApprovalRequired, + } +} + +fn current_or_previous_mcp_decision<'a>( + decisions: &'a BTreeMap, + current_decision_key: &str, +) -> Option<&'a ExternalMcpDecision> { + decisions.get(current_decision_key).or_else(|| { + let (lineage, _) = current_decision_key.rsplit_once(':')?; + decisions.iter().find_map(|(key, decision)| { + key.rsplit_once(':') + .is_some_and(|(candidate_lineage, _)| candidate_lineage == lineage) + .then_some(decision) + }) + }) +} + +fn build_conflict( + execution_domain_id: &str, + workspace_key: &str, + group: &CandidateGroup<'_>, + source_names: &BTreeMap, + conflict_choices: &BTreeMap, +) -> ExternalMcpConflict { + let server_name = group + .native + .first() + .map(|candidate| candidate.name.clone()) + .or_else(|| { + group + .external + .first() + .map(|candidate| candidate.name.clone()) + }) + .unwrap_or_default(); + let mut participants = Vec::with_capacity(group.native.len() + group.external.len()); + let mut candidates = Vec::with_capacity(participants.capacity()); + for candidate in &group.native { + participants.push(( + candidate.candidate_id.clone(), + candidate.behavior_version.clone(), + )); + candidates.push(ExternalMcpConflictCandidate { + candidate_id: candidate.candidate_id.clone(), + display_name: candidate.display_name.clone(), + external: false, + source: None, + behavior_version: candidate.behavior_version.clone(), + available: candidate.enabled, + unavailable_reason: (!candidate.enabled) + .then(|| "This BitFun MCP server is disabled".to_string()), + }); + } + for definition in &group.external { + participants.push(( + definition.candidate_id(), + definition.behavior_version.clone(), + )); + } + let conflict_key = external_mcp_conflict_key( + execution_domain_id, + workspace_key, + &server_name, + participants + .iter() + .map(|(candidate_id, version)| (candidate_id.as_str(), version.as_str())), + ); + for definition in &group.external { + let reason = static_unavailable_reason(definition); + let source_name = source_names + .get(&definition.id.source) + .cloned() + .unwrap_or_else(|| "External AI application".to_string()); + candidates.push(ExternalMcpConflictCandidate { + candidate_id: definition.candidate_id(), + display_name: format!("{source_name}: {}", definition.name), + external: true, + source: Some(definition.id.source.clone()), + behavior_version: definition.behavior_version.clone(), + available: reason.is_none(), + unavailable_reason: reason, + }); + } + let selected_candidate_id = conflict_choices.get(&conflict_key).and_then(|selected| { + candidates + .iter() + .any(|candidate| candidate.candidate_id == *selected && candidate.available) + .then(|| selected.clone()) + }); + ExternalMcpConflict { + conflict_key, + server_name, + candidates, + selected_candidate_id, + } +} + +fn static_unavailable_reason(definition: &ExternalMcpServerDefinition) -> Option { + if !definition.source_enabled { + return Some("This MCP server is disabled in its source configuration".to_string()); + } + match &definition.static_status { + ExternalMcpStaticStatus::Ready => None, + ExternalMcpStaticStatus::DisabledBySource => { + Some("This MCP server is disabled in its source configuration".to_string()) + } + ExternalMcpStaticStatus::Unsupported { reason } + | ExternalMcpStaticStatus::Invalid { reason } => Some(reason.clone()), + _ => Some("This MCP server configuration is not supported".to_string()), + } +} + +fn external_mcp_runtime_id( + workspace_key: &str, + definition: &ExternalMcpServerDefinition, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace_key.as_bytes()); + hasher.update([0]); + hasher.update(definition.id.stable_key().as_bytes()); + hasher.update([0]); + hasher.update(definition.behavior_version.as_bytes()); + format!("external-mcp-{}", &hex::encode(hasher.finalize())[..32]) +} diff --git a/src/crates/assembly/core/src/external_mcp_tests.rs b/src/crates/assembly/core/src/external_mcp_tests.rs new file mode 100644 index 0000000000..09f60ad2a7 --- /dev/null +++ b/src/crates/assembly/core/src/external_mcp_tests.rs @@ -0,0 +1,353 @@ +use crate::external_mcp::{ + prepared_mcp_config, reconcile_external_mcp_catalog, ActiveExternalMcpCandidate, + ExternalMcpDecision, ExternalMcpDecisions, NativeMcpCandidate, +}; +use bitfun_external_sources::ExternalMcpCoordinatorSnapshot; +use bitfun_product_domains::external_sources::{ + external_mcp_approval_key, external_mcp_conflict_key, EcosystemId, ExecutionDomainId, + ExternalMcpActivationState, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalMcpTransportKind, ExternalSourceCatalogEntry, ExternalSourceHealth, + ExternalSourceLifecycleState, ExternalSourceRecord, ExternalSourceScope, + PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, + SourceQualifiedMcpServerId, +}; +use std::collections::BTreeMap; + +#[test] +fn unavailable_external_mcp_can_be_disabled_but_not_silently_reapproved() { + let state = ExternalMcpActivationState::RuntimeUnavailable { + reason: "failed".to_string(), + }; + assert!(crate::external_sources::external_mcp_decision_allowed( + &state, false + )); + assert!(!crate::external_sources::external_mcp_decision_allowed( + &state, true + )); +} + +fn snapshot(behavior_version: &str) -> ExternalMcpCoordinatorSnapshot { + let source = ExternalSourceRecord { + key: SourceKey::new("opencode.mcp", "project").unwrap(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode project configuration".to_string(), + source_kind: "opencode_mcp_config".to_string(), + scope: ExternalSourceScope::Project, + location: "/workspace/opencode.json".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + }; + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 1, + working_directory: Some("".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + source_enabled: true, + behavior_version: behavior_version.to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + ExternalMcpCoordinatorSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: source.preference_key(), + record: source, + lifecycle: ExternalSourceLifecycleState::Available, + }], + servers: vec![definition], + diagnostics: Vec::new(), + } +} + +fn decisions<'a>( + server_decisions: &'a BTreeMap, + conflict_choices: &'a BTreeMap, +) -> ExternalMcpDecisions<'a> { + ExternalMcpDecisions { + server_decisions, + conflict_choices, + } +} + +#[test] +fn approval_is_once_per_behavior_and_a_changed_behavior_fails_closed() { + let snapshot_v1 = snapshot("behavior-v1"); + let definition = &snapshot_v1.servers[0]; + let approval_v1 = external_mcp_approval_key( + "local-user", + "/workspace-a", + &definition.id, + &definition.behavior_version, + ); + let mut server_decisions = BTreeMap::new(); + let conflict_choices = BTreeMap::new(); + + let pending = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot_v1, + &[], + decisions(&server_decisions, &conflict_choices), + ); + assert_eq!(pending.approval_requests.len(), 1); + assert_eq!( + pending.entries[0].activation_state, + ExternalMcpActivationState::ApprovalRequired + ); + assert!(pending.active.is_empty()); + + server_decisions.insert( + approval_v1.clone(), + ExternalMcpDecision { + decision_key: approval_v1, + approved: true, + }, + ); + let active = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot_v1, + &[], + decisions(&server_decisions, &conflict_choices), + ); + assert_eq!( + active.entries[0].activation_state, + ExternalMcpActivationState::Active + ); + assert_eq!(active.active.len(), 1); + assert!(active.entries[0].runtime_id.is_some()); + let other_workspace = reconcile_external_mcp_catalog( + "local-user", + "/workspace-b", + &snapshot_v1, + &[], + decisions(&server_decisions, &conflict_choices), + ); + assert_ne!( + active.entries[0].runtime_id, other_workspace.entries[0].runtime_id, + "runtime instances must be workspace scoped" + ); + assert_eq!( + other_workspace.entries[0].activation_state, + ExternalMcpActivationState::ApprovalRequired, + "approval in one workspace must not authorize another workspace" + ); + assert!(other_workspace.active.is_empty()); + + let changed = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot("behavior-v2"), + &[], + decisions(&server_decisions, &conflict_choices), + ); + assert_eq!( + changed.entries[0].activation_state, + ExternalMcpActivationState::ConfigurationChanged + ); + assert_eq!(changed.approval_requests.len(), 1); + assert!(changed.active.is_empty()); +} + +#[test] +fn decline_is_not_reasked_until_behavior_changes() { + let snapshot = snapshot("behavior-v1"); + let definition = &snapshot.servers[0]; + let decision_key = external_mcp_approval_key( + "local-user", + "/workspace-a", + &definition.id, + &definition.behavior_version, + ); + let server_decisions = [( + decision_key.clone(), + ExternalMcpDecision { + decision_key, + approved: false, + }, + )] + .into_iter() + .collect(); + let conflict_choices = BTreeMap::new(); + + let state = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot, + &[], + decisions(&server_decisions, &conflict_choices), + ); + assert_eq!( + state.entries[0].activation_state, + ExternalMcpActivationState::Declined + ); + assert!(state.approval_requests.is_empty()); +} + +#[test] +fn native_conflict_stays_active_by_default_but_is_not_recorded_as_a_choice() { + let snapshot_v1 = snapshot("behavior-v1"); + let external = &snapshot_v1.servers[0]; + let native = NativeMcpCandidate { + candidate_id: "native_mcp:bitfun-github".to_string(), + server_id: "bitfun-github".to_string(), + name: "github".to_string(), + display_name: "BitFun: github".to_string(), + behavior_version: "native-v1".to_string(), + enabled: true, + }; + let mut decisions_by_server = BTreeMap::new(); + let mut conflict_choices = BTreeMap::new(); + let pending = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot_v1, + std::slice::from_ref(&native), + decisions(&decisions_by_server, &conflict_choices), + ); + assert_eq!( + pending.entries[0].activation_state, + ExternalMcpActivationState::Conflict + ); + assert_eq!( + pending.conflicts[0].candidates[0].candidate_id, + native.candidate_id + ); + assert!(pending.conflicts[0].selected_candidate_id.is_none()); + assert!(pending.approval_requests.is_empty()); + assert!(pending.suppressed_native_server_ids.is_empty()); + + let external_id = external.candidate_id(); + let conflict_key = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ( + native.candidate_id.as_str(), + native.behavior_version.as_str(), + ), + (external_id.as_str(), external.behavior_version.as_str()), + ], + ); + conflict_choices.insert(conflict_key, external_id.clone()); + let external_approval_key = external_mcp_approval_key( + "local-user", + "/workspace-a", + &external.id, + &external.behavior_version, + ); + decisions_by_server.insert( + external_approval_key.clone(), + ExternalMcpDecision { + decision_key: external_approval_key, + approved: true, + }, + ); + let selected = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot_v1, + std::slice::from_ref(&native), + decisions(&decisions_by_server, &conflict_choices), + ); + assert_eq!( + selected.entries[0].activation_state, + ExternalMcpActivationState::Active + ); + assert_eq!(selected.active.len(), 1); + assert_eq!( + selected.suppressed_native_server_ids, + ["bitfun-github".to_string()].into_iter().collect() + ); + + let other_workspace = reconcile_external_mcp_catalog( + "local-user", + "/workspace-b", + &snapshot_v1, + std::slice::from_ref(&native), + decisions(&decisions_by_server, &conflict_choices), + ); + assert!(other_workspace.conflicts[0].selected_candidate_id.is_none()); + assert!(other_workspace.suppressed_native_server_ids.is_empty()); + + let changed = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &snapshot("behavior-v2"), + std::slice::from_ref(&native), + decisions(&decisions_by_server, &conflict_choices), + ); + assert!(changed.conflicts[0].selected_candidate_id.is_none()); + assert_eq!( + changed.suppressed_native_server_ids, + ["bitfun-github".to_string()].into_iter().collect(), + "a changed external selection must not silently fall back to the native server" + ); + + let removed_external = ExternalMcpCoordinatorSnapshot { + generation: 2, + discovery_pending: false, + sources: Vec::new(), + servers: Vec::new(), + diagnostics: Vec::new(), + }; + let removed = reconcile_external_mcp_catalog( + "local-user", + "/workspace-a", + &removed_external, + std::slice::from_ref(&native), + decisions(&decisions_by_server, &conflict_choices), + ); + assert_eq!(removed.conflicts.len(), 1); + assert_eq!(removed.conflicts[0].candidates.len(), 1); + assert!(removed.conflicts[0].selected_candidate_id.is_none()); + assert_eq!( + removed.suppressed_native_server_ids, + ["bitfun-github".to_string()].into_iter().collect(), + "deleting the selected external server must require an explicit native re-selection" + ); +} + +#[test] +fn prepared_local_external_mcp_keeps_cwd_and_does_not_inherit_bitfun_secrets() { + let mut source_snapshot = snapshot("behavior-v1"); + let definition = source_snapshot.servers.remove(0); + let candidate = ActiveExternalMcpCandidate { + runtime_id: "external-mcp-runtime".to_string(), + definition: definition.clone(), + }; + let prepared = PreparedExternalMcpServer { + id: definition.id, + behavior_version: definition.behavior_version, + transport: PreparedExternalMcpTransport::Local { + command: "npx".to_string(), + args: vec!["-y".to_string(), "example-mcp".to_string()], + environment: [("EXPLICIT_TOKEN".to_string(), SecretValue::new("secret"))] + .into_iter() + .collect(), + working_directory: Some("D:/workspace/project".into()), + }, + }; + + let config = prepared_mcp_config(&candidate, prepared).expect("valid runtime config"); + + assert_eq!( + config.working_directory.as_deref(), + Some("D:/workspace/project") + ); + assert_eq!(config.inherit_parent_environment, Some(false)); + assert_eq!( + config.env.get("EXPLICIT_TOKEN").map(String::as_str), + Some("secret") + ); +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 3b2aa6d192..4a9b16fc3f 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -4,18 +4,25 @@ //! catalog and product surfaces remain provider- and ecosystem-neutral. pub use bitfun_product_domains::external_sources::{ - prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceAssetKind, - ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalToolActivationState, - ExternalToolApprovalRequest, ExternalToolCapability, ExternalToolCatalogEntry, - ExternalToolConflict, ExternalToolRuntimeKind, PromptCommandAvailability, - PromptCommandCatalogEntry, PromptCommandDefinition, SourceKey, + prompt_command_conflict_key, ExpandedPromptCommand, ExternalMcpActivationState, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry, + ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, + ExternalSourceLifecycleState, ExternalToolActivationState, ExternalToolApprovalRequest, + ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolConflict, + ExternalToolRuntimeKind, PromptCommandAvailability, PromptCommandCatalogEntry, + PromptCommandDefinition, SourceKey, }; pub use bitfun_product_domains::external_subagents::{ ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, ExternalSubagentConflictCandidate, ExternalSubagentSummary, }; +use crate::external_mcp::{ + reconcile_external_mcp_catalog, BitFunExternalMcpRuntime, ExternalMcpDecision, + ExternalMcpDecisions, ExternalMcpProductState, ExternalMcpRuntimePort, + ExternalMcpRuntimeStatus, NativeMcpCandidate, +}; use crate::external_subagents::{ reconcile_external_subagents, ExternalSubagentDecisions, ExternalSubagentProductState, DISABLED_SUBAGENT_CONFLICT_CHOICE, @@ -23,21 +30,22 @@ use crate::external_subagents::{ use crate::external_tools::{ begin_external_tool_workspace_recovery, external_tool_workspace_requires_recovery, merge_tool_state, reconcile_external_tools, release_external_tool_workspace, - reset_external_tool_workspace_recovery_budget, ExternalToolDecisions, ExternalToolProductState, - TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, + reset_external_tool_workspace_recovery_budget, workspace_route_key, ExternalToolDecisions, + ExternalToolProductState, TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, }; use crate::service::config::{subscribe_config_updates, ConfigUpdateEvent}; use bitfun_external_sources::{ + ExternalMcpCoordinator, ExternalMcpDiscoveryRequest, ExternalMcpDiscoveryResult, ExternalSourceCoordinator, ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, ExternalSubagentCoordinator, ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, ExternalToolCoordinator, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, }; use bitfun_opencode_adapter::{ - OpenCodeCommandProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, + OpenCodeCommandProvider, OpenCodeMcpProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, }; use bitfun_product_domains::external_sources::{ - ExecutionDomainId, ExternalSourceContext, ExternalSourceScope, ExternalToolSourceProvider, - PromptCommandSourceProvider, + ExecutionDomainId, ExternalMcpSourceProvider, ExternalSourceContext, ExternalSourceScope, + ExternalToolSourceProvider, PromptCommandSourceProvider, }; use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; use bitfun_services_core::json_store::JsonFileStore; @@ -46,6 +54,7 @@ use dashmap::{mapref::entry::Entry, DashMap}; use futures::future::{join_all, BoxFuture, Shared}; use futures::FutureExt; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::path::{Path, PathBuf}; @@ -84,6 +93,10 @@ struct ExternalSourcesConfig { subagent_conflict_choices: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] subagent_conflict_lineage_current_keys: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + mcp_server_decisions: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + mcp_conflict_choices: BTreeMap, } #[derive(Debug, Clone)] @@ -128,6 +141,7 @@ impl ExternalSourcePreferenceStore { type SharedDiscoveryTask = Shared>; type SharedToolDiscoveryTask = Shared>; type SharedSubagentDiscoveryTask = Shared>; +type SharedMcpDiscoveryTask = Shared>; struct InFlightDiscovery { task: SharedDiscoveryTask, @@ -144,6 +158,11 @@ struct InFlightSubagentDiscovery { wake_scheduled: bool, } +struct InFlightMcpDiscovery { + task: SharedMcpDiscoveryTask, + wake_scheduled: bool, +} + #[derive(Clone, Copy)] enum WorkerRecoveryPolicy { Preserve, @@ -160,6 +179,7 @@ struct WorkspaceExternalSourceService { coordinator: Arc>, tool_coordinator: Arc>, subagent_coordinator: Arc>, + mcp_coordinator: Arc>, snapshot: StdMutex, updates: broadcast::Sender, watch_states: tokio::sync::Mutex>, @@ -174,6 +194,11 @@ struct WorkspaceExternalSourceService { subagent_discovery_tasks: tokio::sync::Mutex< BTreeMap, >, + mcp_discovery_tasks: tokio::sync::Mutex< + BTreeMap, + >, + mcp_runtime: Arc, + active_mcp_runtime_ids: tokio::sync::Mutex>, initial_refresh_completed: AtomicBool, background_refresh_scheduled: AtomicBool, initial_refresh_gate: tokio::sync::Mutex<()>, @@ -203,7 +228,10 @@ impl WorkspaceExternalSourceService { let subagent_providers: Vec> = vec![Arc::new(OpenCodeSubagentProvider::default())]; let mut subagent_coordinator = - ExternalSubagentCoordinator::new(context, subagent_providers)?; + ExternalSubagentCoordinator::new(context.clone(), subagent_providers)?; + let mcp_providers: Vec> = + vec![Arc::new(OpenCodeMcpProvider::default())]; + let mut mcp_coordinator = ExternalMcpCoordinator::new(context, mcp_providers)?; let preferences = read_external_sources_config().await?; let suppressed_sources = preferences .suppressed_source_keys @@ -212,7 +240,8 @@ impl WorkspaceExternalSourceService { .collect::>(); coordinator.replace_suppressed_sources(suppressed_sources.clone()); tool_coordinator.replace_suppressed_sources(suppressed_sources.clone()); - subagent_coordinator.replace_suppressed_sources(suppressed_sources); + subagent_coordinator.replace_suppressed_sources(suppressed_sources.clone()); + mcp_coordinator.replace_suppressed_sources(suppressed_sources); coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); coordinator.replace_conflict_lineage_current_keys( preferences.conflict_lineage_current_keys.clone(), @@ -231,6 +260,7 @@ impl WorkspaceExternalSourceService { coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), + mcp_coordinator: Arc::new(StdMutex::new(mcp_coordinator)), snapshot: StdMutex::new(initial_snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), @@ -239,6 +269,9 @@ impl WorkspaceExternalSourceService { discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + mcp_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + mcp_runtime: Arc::new(BitFunExternalMcpRuntime), + active_mcp_runtime_ids: tokio::sync::Mutex::new(BTreeSet::new()), initial_refresh_completed: AtomicBool::new(false), background_refresh_scheduled: AtomicBool::new(false), initial_refresh_gate: tokio::sync::Mutex::new(()), @@ -305,18 +338,23 @@ impl WorkspaceExternalSourceService { let subagent_scheduled = self .prepare_subagent_discovery_tasks(subagent_requests) .await; - let (polled, tool_polled, subagent_polled) = tokio::join!( + let mcp_requests = lock_mcp_coordinator(&self.mcp_coordinator).discovery_requests(); + let mcp_scheduled = self.prepare_mcp_discovery_tasks(mcp_requests).await; + let (polled, tool_polled, subagent_polled, mcp_polled) = tokio::join!( poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT), poll_tool_discovery_tasks(tool_scheduled, PROVIDER_DISCOVERY_TIMEOUT), poll_subagent_discovery_tasks(subagent_scheduled, PROVIDER_DISCOVERY_TIMEOUT), + poll_mcp_discovery_tasks(mcp_scheduled, PROVIDER_DISCOVERY_TIMEOUT), ); let results = self.finish_discovery_poll(polled).await; let tool_results = self.finish_tool_discovery_poll(tool_polled).await; let subagent_results = self.finish_subagent_discovery_poll(subagent_polled).await; + let mcp_results = self.finish_mcp_discovery_poll(mcp_polled).await; let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); lock_tool_coordinator(&self.tool_coordinator).apply_discovery_results(tool_results); let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator) .apply_discovery_results(subagent_results); + lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_results(mcp_results); self.schedule_subagent_last_valid_expiry(&subagent_snapshot); self.ensure_watch_roots().await; let snapshot = self @@ -397,6 +435,42 @@ impl WorkspaceExternalSourceService { } let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); + let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); + let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); + let mut mcp_state = match load_native_mcp_candidates().await { + Ok(native_candidates) => reconcile_external_mcp_catalog( + "local-user", + &mcp_workspace_key, + &mcp_snapshot, + &native_candidates, + ExternalMcpDecisions { + server_decisions: &preferences.mcp_server_decisions, + conflict_choices: &preferences.mcp_conflict_choices, + }, + ), + Err(error) => { + let mut state = reconcile_external_mcp_catalog( + "local-user", + &mcp_workspace_key, + &mcp_snapshot, + &[], + ExternalMcpDecisions { + server_decisions: &preferences.mcp_server_decisions, + conflict_choices: &preferences.mcp_conflict_choices, + }, + ); + for entry in &mut state.entries { + entry.runtime_id = None; + entry.activation_state = ExternalMcpActivationState::RuntimeUnavailable { + reason: error.clone(), + }; + } + state.active.clear(); + state + } + }; + self.reconcile_mcp_runtime(&mut mcp_state).await; + merge_mcp_state(&mut snapshot, &mcp_snapshot, mcp_state); let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); let mut subagent_state = reconcile_external_subagents( self.workspace_root.as_deref(), @@ -468,6 +542,16 @@ impl WorkspaceExternalSourceService { } sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); let mut current = lock_snapshot(&self.snapshot); + let mcp_changed = snapshot.mcp_servers != current.mcp_servers + || snapshot.mcp_conflicts != current.mcp_conflicts + || snapshot.mcp_approval_requests != current.mcp_approval_requests; + snapshot.mcp_generation = if mcp_changed { + snapshot + .mcp_generation + .max(current.mcp_generation.saturating_add(1)) + } else { + snapshot.mcp_generation.max(current.mcp_generation) + }; let subagent_changed = snapshot.subagents != current.subagents || snapshot.subagent_conflicts != current.subagent_conflicts || snapshot.pending_subagent_approvals != current.pending_subagent_approvals @@ -488,6 +572,122 @@ impl WorkspaceExternalSourceService { Ok(snapshot) } + async fn reconcile_mcp_runtime(&self, state: &mut ExternalMcpProductState) { + let desired = state + .active + .iter() + .map(|candidate| (candidate.runtime_id.clone(), candidate.clone())) + .collect::>(); + let desired_ids = desired.keys().cloned().collect::>(); + let mut managed = self.active_mcp_runtime_ids.lock().await.clone(); + let workspace_key = workspace_route_key(self.workspace_root.as_deref()); + if let Err(reason) = self + .mcp_runtime + .replace_workspace_route( + &workspace_key, + desired_ids.clone(), + state.suppressed_native_server_ids.clone(), + ) + .await + { + state.diagnostics.push( + ExternalSourceDiagnostic::warning("external_mcp.route_update_failed", reason, None) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ); + } + let managed_statuses = join_all( + desired + .values() + .filter(|candidate| managed.contains(&candidate.runtime_id)) + .map(|candidate| async { + ( + candidate.runtime_id.clone(), + self.mcp_runtime.status(&candidate.runtime_id).await, + ) + }), + ) + .await + .into_iter() + .collect::>(); + + for runtime_id in managed + .difference(&desired_ids) + .cloned() + .collect::>() + { + match self.mcp_runtime.retire(&runtime_id).await { + Ok(()) => { + managed.remove(&runtime_id); + } + Err(reason) => state.diagnostics.push( + ExternalSourceDiagnostic::warning( + "external_mcp.retirement_failed", + reason, + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ), + } + } + + for candidate in desired.values() { + if managed.contains(&candidate.runtime_id) { + let status = managed_statuses + .get(&candidate.runtime_id) + .cloned() + .unwrap_or_else(|| { + Err("The external MCP server status is unavailable".to_string()) + }); + // Keep failed registrations managed until the user disables + // them. Re-installing from a status error would turn a + // persistent startup failure into an unbounded retry loop. + apply_external_mcp_runtime_status(state, candidate, status); + continue; + } + + let coordinator = Arc::clone(&self.mcp_coordinator); + let server_id = candidate.definition.id.clone(); + let behavior_version = candidate.definition.behavior_version.clone(); + let prepared = tokio::task::spawn_blocking(move || { + lock_mcp_coordinator(&coordinator) + .prepare_server_guarded(&server_id, &behavior_version) + }) + .await + .map_err(|_| "The external MCP configuration could not be prepared".to_string()) + .and_then(|result| result.map_err(|error| error.message)); + + let activation = match prepared { + Ok(prepared) => { + self.mcp_runtime + .install(candidate, prepared, &workspace_key) + .await + } + Err(reason) => Err(reason), + }; + match activation { + Ok(()) => { + managed.insert(candidate.runtime_id.clone()); + apply_external_mcp_runtime_status( + state, + candidate, + self.mcp_runtime.status(&candidate.runtime_id).await, + ); + } + Err(reason) => { + // A process may have reached a failed-but-registered state. + // Track it so later source changes retire it safely instead + // of attempting duplicate installs on every refresh. + if self.mcp_runtime.status(&candidate.runtime_id).await.is_ok() { + managed.insert(candidate.runtime_id.clone()); + } + mark_external_mcp_runtime_unavailable(state, candidate, reason); + } + } + } + + *self.active_mcp_runtime_ids.lock().await = managed; + } + async fn prepare_discovery_tasks( &self, requests: Vec, @@ -726,6 +926,86 @@ impl WorkspaceExternalSourceService { results } + async fn prepare_mcp_discovery_tasks( + &self, + requests: Vec, + ) -> Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedMcpDiscoveryTask, + bool, + )> { + let mut tasks = self.mcp_discovery_tasks.lock().await; + requests + .into_iter() + .map(|request| { + let provider_id = request.provider_id().clone(); + if let Some(in_flight) = tasks.get(&provider_id) { + return (provider_id, in_flight.task.clone(), false); + } + let task = spawn_mcp_discovery_task(request); + tasks.insert( + provider_id.clone(), + InFlightMcpDiscovery { + task: task.clone(), + wake_scheduled: false, + }, + ); + (provider_id, task, true) + }) + .collect() + } + + async fn finish_mcp_discovery_poll( + self: &Arc, + polled: Vec, + ) -> Vec { + let mut results = Vec::with_capacity(polled.len()); + let mut wake_tasks = Vec::new(); + let mut tasks = self.mcp_discovery_tasks.lock().await; + for poll in polled { + match poll { + McpDiscoveryPoll::Complete(result) => { + tasks.remove(&result.provider_id().clone()); + results.push(result); + } + McpDiscoveryPoll::InFlight(provider_id) => { + results.push(mcp_discovery_failure( + provider_id, + "external_mcp.discovery_in_progress", + "MCP provider discovery is still running; using its last valid version", + )); + } + McpDiscoveryPoll::TimedOut(provider_id) => { + if let Some(in_flight) = tasks.get_mut(&provider_id) { + if !in_flight.wake_scheduled { + in_flight.wake_scheduled = true; + wake_tasks.push((provider_id.clone(), in_flight.task.clone())); + } + } + results.push(mcp_discovery_failure( + provider_id, + "external_mcp.discovery_timeout", + "MCP provider discovery exceeded the 5 second deadline", + )); + } + } + } + drop(tasks); + for (provider_id, task) in wake_tasks { + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let result = task.await; + let Some(service) = weak.upgrade() else { + return; + }; + service + .complete_deferred_mcp_discovery(provider_id, result) + .await; + }); + } + results + } + async fn complete_deferred_discovery( &self, provider_id: bitfun_product_domains::external_sources::ProviderId, @@ -796,6 +1076,29 @@ impl WorkspaceExternalSourceService { } } + async fn complete_deferred_mcp_discovery( + &self, + provider_id: bitfun_product_domains::external_sources::ProviderId, + result: ExternalMcpDiscoveryResult, + ) { + let _refresh_guard = self.refresh_gate.lock().await; + if self + .mcp_discovery_tasks + .lock() + .await + .remove(&provider_id) + .is_none() + { + return; + } + lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_result(result); + self.ensure_watch_roots().await; + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { + let _ = self.updates.send(snapshot); + } + } + fn schedule_subagent_last_valid_expiry( self: &Arc, snapshot: &bitfun_external_sources::ExternalSubagentCoordinatorSnapshot, @@ -892,6 +1195,26 @@ impl WorkspaceExternalSourceService { .is_some_and(|cached| Arc::ptr_eq(&cached, &service)); drop(entry); if should_remove { + let runtime_ids = + std::mem::take(&mut *service.active_mcp_runtime_ids.lock().await); + let workspace_key = workspace_route_key(key.as_deref()); + let _ = service + .mcp_runtime + .replace_workspace_route( + &workspace_key, + BTreeSet::new(), + BTreeSet::new(), + ) + .await; + for runtime_id in runtime_ids { + if let Err(error) = service.mcp_runtime.retire(&runtime_id).await { + log::warn!( + "Could not retire idle external MCP runtime: id={} error={}", + runtime_id, + error + ); + } + } workspace_services().remove(&key); release_external_tool_workspace(key.as_deref()).await; if let Some(workspace_root) = key.as_deref() { @@ -910,7 +1233,7 @@ impl WorkspaceExternalSourceService { } async fn set_source_enabled( - &self, + self: &Arc, stable_key: &str, enabled: bool, ) -> Result { @@ -932,7 +1255,13 @@ impl WorkspaceExternalSourceService { let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; - if !command_known && !tool_known && !subagent_known { + let (previous_mcps, mcp_known) = { + let mut coordinator = lock_mcp_coordinator(&self.mcp_coordinator); + let previous = coordinator.suppressed_sources().clone(); + let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); + (previous, known) + }; + if !command_known && !tool_known && !subagent_known && !mcp_known { return Err(format!("unknown external source: {stable_key}")); } let authoritative = match persist_source_enabled_change(stable_key, enabled).await { @@ -943,6 +1272,8 @@ impl WorkspaceExternalSourceService { .replace_suppressed_sources(previous_tools); lock_subagent_coordinator(&self.subagent_coordinator) .replace_suppressed_sources(previous_subagents); + lock_mcp_coordinator(&self.mcp_coordinator) + .replace_suppressed_sources(previous_mcps); return Err(error); } }; @@ -951,9 +1282,10 @@ impl WorkspaceExternalSourceService { .replace_suppressed_sources(authoritative.clone()); lock_subagent_coordinator(&self.subagent_coordinator) .replace_suppressed_sources(authoritative.clone()); - propagate_suppressed_sources(&authoritative); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); - self.rebuild_product_snapshot(command_snapshot).await + lock_mcp_coordinator(&self.mcp_coordinator) + .replace_suppressed_sources(authoritative.clone()); + propagate_suppressed_sources(&authoritative, self); + self.refresh_preserving_worker_recovery().await } async fn set_conflict_choice( @@ -1072,6 +1404,113 @@ impl WorkspaceExternalSourceService { self.rebuild_product_snapshot(command_snapshot).await } + async fn set_mcp_server_decision( + &self, + candidate_id: &str, + decision_key: &str, + approved: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.mcp_generation != expected_mcp_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err( + "stale_action: external MCP catalog changed; refresh before retrying".to_string(), + ); + } + let entry = snapshot + .mcp_servers + .iter() + .find(|entry| entry.candidate_id == candidate_id && entry.decision_key == decision_key) + .ok_or_else(|| { + "candidate_unavailable: external MCP candidate is no longer available".to_string() + })?; + if !external_mcp_decision_allowed(&entry.activation_state, approved) { + return Err( + "unsupported_definition: external MCP candidate cannot be changed in its current state" + .to_string(), + ); + } + validate_mcp_decision_value(candidate_id, "candidate id")?; + validate_mcp_decision_value(decision_key, "decision key")?; + let preferences = persist_mcp_server_decision( + decision_key, + approved, + expected_preference_revision, + ) + .await?; + propagate_mcp_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn choose_mcp_conflict( + &self, + conflict_key: &str, + candidate_id: &str, + approve_external: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.mcp_generation != expected_mcp_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err( + "stale_action: external MCP catalog changed; refresh before retrying".to_string(), + ); + } + let conflict = snapshot + .mcp_conflicts + .iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .ok_or_else(|| { + "conflict_choice_required: external MCP conflict is stale or unknown".to_string() + })?; + let candidate = conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id && candidate.available) + .ok_or_else(|| { + "candidate_unavailable: external MCP conflict candidate is unavailable".to_string() + })?; + let external_decision = if candidate.external { + if !approve_external { + return Err( + "approval_required: selecting an external MCP server must atomically approve its current behavior" + .to_string(), + ); + } + let entry = snapshot + .mcp_servers + .iter() + .find(|entry| entry.candidate_id == candidate_id) + .ok_or_else(|| { + "candidate_unavailable: external MCP candidate is no longer available" + .to_string() + })?; + Some(entry.decision_key.as_str()) + } else { + None + }; + validate_mcp_decision_value(conflict_key, "conflict key")?; + validate_mcp_decision_value(candidate_id, "candidate id")?; + let preferences = persist_mcp_conflict_choice( + conflict_key, + candidate_id, + external_decision, + expected_preference_revision, + ) + .await?; + propagate_mcp_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + async fn set_subagent_activation( &self, candidate_id: &str, @@ -1344,6 +1783,7 @@ impl WorkspaceExternalSourceService { .into_iter() .chain(lock_tool_coordinator(&self.tool_coordinator).watch_roots()) .chain(lock_subagent_coordinator(&self.subagent_coordinator).watch_roots()) + .chain(lock_mcp_coordinator(&self.mcp_coordinator).watch_roots()) { roots .entry(root.path) @@ -1385,6 +1825,12 @@ enum SubagentDiscoveryPoll { TimedOut(bitfun_product_domains::external_sources::ProviderId), } +enum McpDiscoveryPoll { + Complete(ExternalMcpDiscoveryResult), + InFlight(bitfun_product_domains::external_sources::ProviderId), + TimedOut(bitfun_product_domains::external_sources::ProviderId), +} + async fn poll_discovery_tasks( scheduled: Vec<( bitfun_product_domains::external_sources::ProviderId, @@ -1466,6 +1912,33 @@ async fn poll_subagent_discovery_tasks( .await } +async fn poll_mcp_discovery_tasks( + scheduled: Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedMcpDiscoveryTask, + bool, + )>, + timeout: std::time::Duration, +) -> Vec { + join_all( + scheduled + .into_iter() + .map(|(provider_id, task, is_new)| async move { + if !is_new { + return match task.clone().now_or_never() { + Some(result) => McpDiscoveryPoll::Complete(result), + None => McpDiscoveryPoll::InFlight(provider_id), + }; + } + match tokio::time::timeout(timeout, task).await { + Ok(result) => McpDiscoveryPoll::Complete(result), + Err(_) => McpDiscoveryPoll::TimedOut(provider_id), + } + }), + ) + .await +} + fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscoveryTask { let provider_id = request.provider_id().clone(); async move { @@ -1516,6 +1989,22 @@ fn spawn_subagent_discovery_task( .shared() } +fn spawn_mcp_discovery_task(request: ExternalMcpDiscoveryRequest) -> SharedMcpDiscoveryTask { + let provider_id = request.provider_id().clone(); + async move { + match tokio::task::spawn_blocking(move || request.execute()).await { + Ok(result) => result, + Err(error) => mcp_discovery_failure( + provider_id, + "external_mcp.discovery_task_failed", + &format!("MCP provider discovery task failed: {error}"), + ), + } + } + .boxed() + .shared() +} + fn discovery_failure( provider_id: bitfun_product_domains::external_sources::ProviderId, code: &str, @@ -1555,6 +2044,19 @@ fn subagent_discovery_failure( ) } +fn mcp_discovery_failure( + provider_id: bitfun_product_domains::external_sources::ProviderId, + code: &str, + message: &str, +) -> ExternalMcpDiscoveryResult { + ExternalMcpDiscoveryResult::failed( + provider_id, + bitfun_product_domains::external_sources::ExternalSourceProviderError::new( + code, message, true, + ), + ) +} + fn lock_coordinator( coordinator: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCoordinator> { @@ -1585,6 +2087,15 @@ fn lock_subagent_coordinator( } } +fn lock_mcp_coordinator( + coordinator: &StdMutex, +) -> MutexGuard<'_, ExternalMcpCoordinator> { + match coordinator.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + fn lock_snapshot( snapshot: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { @@ -1745,6 +2256,26 @@ fn sanitize_external_snapshot_locations( remember_location(scope, location); } } + for server in &snapshot.mcp_servers { + let Some(directory) = server.definition.working_directory.as_deref() else { + continue; + }; + let scope = source_scopes + .get(&server.definition.id.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + remember_location(scope, directory); + } + for request in &snapshot.mcp_approval_requests { + let Some(directory) = request.definition.working_directory.as_deref() else { + continue; + }; + let scope = source_scopes + .get(&request.definition.id.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + remember_location(scope, directory); + } drop(remember_location); replacements.sort_by(|left, right| right.0.len().cmp(&left.0.len())); let sanitize_message = |message: &mut String| { @@ -1818,6 +2349,148 @@ fn sanitize_external_snapshot_locations( *location = safe_external_source_location(scope, location, workspace_root); } } + for server in &mut snapshot.mcp_servers { + let Some(directory) = server.definition.working_directory.as_mut() else { + continue; + }; + let scope = source_scopes + .get(&server.definition.id.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + *directory = safe_external_source_location(scope, directory, workspace_root); + } + for request in &mut snapshot.mcp_approval_requests { + let Some(directory) = request.definition.working_directory.as_mut() else { + continue; + }; + let scope = source_scopes + .get(&request.definition.id.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + *directory = safe_external_source_location(scope, directory, workspace_root); + } +} + +async fn load_native_mcp_candidates() -> Result, String> { + let service = crate::service::mcp::get_global_mcp_service() + .ok_or_else(|| "MCP service is not initialized".to_string())?; + let configs = service + .config_service() + .load_all_configs() + .await + .map_err(|error| format!("Could not read BitFun MCP configuration: {error}"))?; + let mut candidates = Vec::with_capacity(configs.len()); + for config in configs { + let encoded = serde_json::to_vec(&config) + .map_err(|error| format!("Could not fingerprint BitFun MCP configuration: {error}"))?; + let mut behavior_hasher = Sha256::new(); + behavior_hasher.update(&encoded); + let behavior_version = format!("sha256:{}", hex::encode(behavior_hasher.finalize())); + let candidate_id = native_mcp_candidate_id(&config.id); + candidates.push(NativeMcpCandidate { + candidate_id, + server_id: config.id, + display_name: format!("BitFun: {}", config.name), + name: config.name, + behavior_version, + enabled: config.enabled, + }); + } + candidates.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.server_id.cmp(&right.server_id)) + }); + Ok(candidates) +} + +/// Stable product identifier for a BitFun-owned MCP configuration. Surfaces +/// use this only to correlate native list rows with conflict candidates; the +/// underlying configuration id remains private to the MCP owner. +pub fn native_mcp_candidate_id(server_id: &str) -> String { + let mut id_hasher = Sha256::new(); + id_hasher.update(server_id.as_bytes()); + format!("native_mcp:{}", &hex::encode(id_hasher.finalize())[..24]) +} + +fn apply_external_mcp_runtime_status( + state: &mut ExternalMcpProductState, + candidate: &crate::external_mcp::ActiveExternalMcpCandidate, + status: Result, +) { + match status { + Ok(ExternalMcpRuntimeStatus::Active) => {} + Ok(ExternalMcpRuntimeStatus::Loading) => { + if let Some(entry) = state + .entries + .iter_mut() + .find(|entry| entry.candidate_id == candidate.definition.candidate_id()) + { + entry.activation_state = ExternalMcpActivationState::Starting; + } + } + Ok(ExternalMcpRuntimeStatus::Unavailable(reason)) | Err(reason) => { + mark_external_mcp_runtime_unavailable(state, candidate, reason); + } + } +} + +fn mark_external_mcp_runtime_unavailable( + state: &mut ExternalMcpProductState, + candidate: &crate::external_mcp::ActiveExternalMcpCandidate, + reason: String, +) { + if let Some(entry) = state + .entries + .iter_mut() + .find(|entry| entry.candidate_id == candidate.definition.candidate_id()) + { + entry.activation_state = ExternalMcpActivationState::RuntimeUnavailable { + reason: reason.clone(), + }; + } + state.diagnostics.push( + ExternalSourceDiagnostic::warning( + "external_mcp.runtime_unavailable", + reason, + Some(candidate.definition.id.source.clone()), + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ); +} + +fn merge_mcp_state( + snapshot: &mut ExternalSourceCatalogSnapshot, + coordinator_snapshot: &bitfun_external_sources::ExternalMcpCoordinatorSnapshot, + state: ExternalMcpProductState, +) { + let known_sources = snapshot + .sources + .iter() + .map(|source| source.stable_key.clone()) + .collect::>(); + snapshot.sources.extend( + coordinator_snapshot + .sources + .iter() + .filter(|source| !known_sources.contains(&source.stable_key)) + .cloned(), + ); + snapshot.sources.sort_by(|left, right| { + left.record + .ecosystem_id + .cmp(&right.record.ecosystem_id) + .then(left.stable_key.cmp(&right.stable_key)) + }); + snapshot + .diagnostics + .extend(coordinator_snapshot.diagnostics.clone()); + snapshot.diagnostics.extend(state.diagnostics.clone()); + snapshot.discovery_pending |= coordinator_snapshot.discovery_pending; + snapshot.mcp_generation = coordinator_snapshot.generation; + snapshot.mcp_servers = state.entries; + snapshot.mcp_approval_requests = state.approval_requests; + snapshot.mcp_conflicts = state.conflicts; } async fn service_for( @@ -2222,6 +2895,110 @@ async fn persist_subagent_conflict_choice_with_store( }) } +async fn persist_mcp_server_decision( + decision_key: &str, + approved: bool, + expected_preference_revision: u64, +) -> Result { + let store = ExternalSourcePreferenceStore::global()?; + let decision_key = decision_key.to_string(); + store + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + reconcile_versioned_mcp_server_decision( + &mut config.mcp_server_decisions, + decision_key, + approved, + ); + config.preference_revision = config.preference_revision.saturating_add(1); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + "stale_action: external MCP preferences changed; refresh before retrying" + .to_string() + }) + }) +} + +async fn persist_mcp_conflict_choice( + conflict_key: &str, + candidate_id: &str, + external_decision: Option<&str>, + expected_preference_revision: u64, +) -> Result { + let store = ExternalSourcePreferenceStore::global()?; + let conflict_key = conflict_key.to_string(); + let candidate_id = candidate_id.to_string(); + let external_decision = external_decision.map(str::to_string); + store + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + reconcile_versioned_mcp_conflict_choice( + &mut config.mcp_conflict_choices, + conflict_key, + candidate_id, + ); + if let Some(decision_key) = external_decision { + reconcile_versioned_mcp_server_decision( + &mut config.mcp_server_decisions, + decision_key, + true, + ); + } + config.preference_revision = config.preference_revision.saturating_add(1); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + "stale_action: external MCP preferences changed; refresh before retrying" + .to_string() + }) + }) +} + +fn reconcile_versioned_mcp_conflict_choice( + choices: &mut BTreeMap, + conflict_key: String, + candidate_id: String, +) { + if let Some((lineage, _)) = conflict_key.rsplit_once(':') { + choices.retain(|existing_key, _| { + existing_key + .rsplit_once(':') + .is_none_or(|(existing_lineage, _)| existing_lineage != lineage) + }); + } + choices.insert(conflict_key, candidate_id); +} + +fn reconcile_versioned_mcp_server_decision( + decisions: &mut BTreeMap, + decision_key: String, + approved: bool, +) { + if let Some((lineage, _)) = decision_key.rsplit_once(':') { + decisions.retain(|existing_key, _| { + existing_key + .rsplit_once(':') + .is_none_or(|(existing_lineage, _)| existing_lineage != lineage) + }); + } + decisions.insert( + decision_key.clone(), + ExternalMcpDecision { + decision_key, + approved, + }, + ); +} + fn reconcile_versioned_tool_conflict_choice( choices: &mut BTreeMap, conflict_key: String, @@ -2237,20 +3014,28 @@ fn reconcile_versioned_tool_conflict_choice( choices.insert(conflict_key, candidate_id); } -fn propagate_suppressed_sources(sources: &BTreeSet) { +fn propagate_suppressed_sources( + sources: &BTreeSet, + current: &Arc, +) { for service in workspace_services().iter() { let Some(service) = service.value().upgrade() else { continue; }; + if Arc::ptr_eq(&service, current) { + continue; + } lock_coordinator(&service.coordinator).replace_suppressed_sources(sources.clone()); lock_tool_coordinator(&service.tool_coordinator) .replace_suppressed_sources(sources.clone()); lock_subagent_coordinator(&service.subagent_coordinator) .replace_suppressed_sources(sources.clone()); + lock_mcp_coordinator(&service.mcp_coordinator).replace_suppressed_sources(sources.clone()); tokio::spawn(async move { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); - if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { - let _ = service.updates.send(snapshot); + if let Err(error) = service.refresh_preserving_worker_recovery().await { + log::warn!( + "Could not refresh external sources after source preference change: {error}" + ); } }); } @@ -2307,6 +3092,20 @@ fn propagate_subagent_preferences(_preferences: &ExternalSourcesConfig) { } } +fn propagate_mcp_preferences(_preferences: &ExternalSourcesConfig) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; + }; + tokio::spawn(async move { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); + } +} + pub(crate) fn notify_external_tool_registry_changed() { TOOL_REGISTRY_CHANGE_EPOCH.fetch_add(1, Ordering::AcqRel); if TOOL_REGISTRY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { @@ -2387,7 +3186,16 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R let subagent_changed = { let mut coordinator = lock_subagent_coordinator(&service.subagent_coordinator); if coordinator.suppressed_sources() != &suppressed_sources { - coordinator.replace_suppressed_sources(suppressed_sources); + coordinator.replace_suppressed_sources(suppressed_sources.clone()); + true + } else { + false + } + }; + let mcp_changed = { + let mut coordinator = lock_mcp_coordinator(&service.mcp_coordinator); + if coordinator.suppressed_sources() != &suppressed_sources { + coordinator.replace_suppressed_sources(suppressed_sources.clone()); true } else { false @@ -2395,7 +3203,12 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R }; let subagent_preferences_changed = service.snapshot().preference_revision != preferences.preference_revision; - if command_changed || tool_changed || subagent_changed || subagent_preferences_changed { + if command_changed + || tool_changed + || subagent_changed + || mcp_changed + || subagent_preferences_changed + { let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); let snapshot = service.rebuild_product_snapshot(command_snapshot).await?; let _ = service.updates.send(snapshot); @@ -2422,6 +3235,35 @@ fn validate_subagent_decision_value(value: &str, label: &str) -> Result<(), Stri Ok(()) } +fn validate_mcp_decision_value(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { + return Err(format!("invalid_request: external MCP {label} is invalid")); + } + Ok(()) +} + +pub(super) fn external_mcp_decision_allowed( + state: &ExternalMcpActivationState, + approved: bool, +) -> bool { + if matches!( + state, + ExternalMcpActivationState::Conflict + | ExternalMcpActivationState::Covered { .. } + | ExternalMcpActivationState::SourceDisabled + | ExternalMcpActivationState::Unsupported { .. } + | ExternalMcpActivationState::Removed + ) { + return false; + } + !approved + || !matches!( + state, + ExternalMcpActivationState::Starting + | ExternalMcpActivationState::RuntimeUnavailable { .. } + ) +} + pub async fn external_source_conflict_choices() -> Result< ( BTreeMap, @@ -2505,6 +3347,46 @@ pub async fn set_external_tool_conflict_choice( .await } +pub async fn set_external_mcp_server_decision( + workspace_root: Option<&Path>, + candidate_id: &str, + decision_key: &str, + approved: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .set_mcp_server_decision( + candidate_id, + decision_key, + approved, + expected_mcp_generation, + expected_preference_revision, + ) + .await +} + +pub async fn choose_external_mcp_conflict( + workspace_root: Option<&Path>, + conflict_key: &str, + candidate_id: &str, + approve_external: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .choose_mcp_conflict( + conflict_key, + candidate_id, + approve_external, + expected_mcp_generation, + expected_preference_revision, + ) + .await +} + pub async fn set_external_subagent_activation( workspace_root: Option<&Path>, candidate_id: &str, @@ -2692,6 +3574,10 @@ mod tests { tools: Vec::new(), tool_approval_requests: Vec::new(), tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), @@ -2820,7 +3706,9 @@ mod tests { let (updates, _) = broadcast::channel(8); let coordinator = ExternalSourceCoordinator::new(context.clone(), providers).unwrap(); let tool_coordinator = ExternalToolCoordinator::new(context.clone(), Vec::new()).unwrap(); - let subagent_coordinator = ExternalSubagentCoordinator::new(context, Vec::new()).unwrap(); + let subagent_coordinator = + ExternalSubagentCoordinator::new(context.clone(), Vec::new()).unwrap(); + let mcp_coordinator = ExternalMcpCoordinator::new(context, Vec::new()).unwrap(); let snapshot = merge_tool_state( coordinator.snapshot(), &tool_coordinator.snapshot(), @@ -2831,6 +3719,7 @@ mod tests { coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), + mcp_coordinator: Arc::new(StdMutex::new(mcp_coordinator)), snapshot: StdMutex::new(snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), @@ -2839,6 +3728,9 @@ mod tests { discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + mcp_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + mcp_runtime: Arc::new(BitFunExternalMcpRuntime), + active_mcp_runtime_ids: tokio::sync::Mutex::new(BTreeSet::new()), initial_refresh_completed: AtomicBool::new(false), background_refresh_scheduled: AtomicBool::new(false), initial_refresh_gate: tokio::sync::Mutex::new(()), @@ -3197,6 +4089,41 @@ mod tests { assert_eq!(choices.len(), 2); } + #[test] + fn mcp_server_decisions_keep_one_version_per_workspace_lineage() { + let mut decisions = BTreeMap::from([ + ( + "external_mcp_approval:local-user:workspace-a:server:old".to_string(), + ExternalMcpDecision { + decision_key: + "external_mcp_approval:local-user:workspace-a:server:old".to_string(), + approved: true, + }, + ), + ( + "external_mcp_approval:local-user:workspace-b:server:current".to_string(), + ExternalMcpDecision { + decision_key: + "external_mcp_approval:local-user:workspace-b:server:current".to_string(), + approved: true, + }, + ), + ]); + + reconcile_versioned_mcp_server_decision( + &mut decisions, + "external_mcp_approval:local-user:workspace-a:server:new".to_string(), + false, + ); + + assert!(!decisions + .contains_key("external_mcp_approval:local-user:workspace-a:server:old")); + assert!(!decisions["external_mcp_approval:local-user:workspace-a:server:new"].approved); + assert!(decisions + .contains_key("external_mcp_approval:local-user:workspace-b:server:current")); + assert_eq!(decisions.len(), 2); + } + #[tokio::test] async fn slow_provider_is_not_respawned_while_healthy_sibling_updates() { let slow_calls = Arc::new(AtomicUsize::new(0)); diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index aead0104b6..b901fc5f4d 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -1886,7 +1886,7 @@ async fn local_candidate(tool: &Arc) -> ExternalToolConflictCandidate } } -fn workspace_route_key(workspace_root: Option<&Path>) -> String { +pub(crate) fn workspace_route_key(workspace_root: Option<&Path>) -> String { workspace_root .map(|path| { dunce::canonicalize(path) diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 64d0d2daca..94cc7e666a 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -8,6 +8,10 @@ #[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration #[cfg(feature = "product-full")] +mod external_mcp; +#[cfg(all(test, feature = "product-full"))] +mod external_mcp_tests; +#[cfg(feature = "product-full")] pub mod external_sources; mod external_subagents; #[cfg(feature = "product-full")] diff --git a/src/crates/assembly/core/src/service/mcp/adapter/mod.rs b/src/crates/assembly/core/src/service/mcp/adapter/mod.rs index 71ffd62c17..f912169ee1 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/mod.rs @@ -12,3 +12,4 @@ pub use context::MCPContextProvider; pub use prompt::PromptAdapter; pub use resource::ResourceAdapter; pub use tool::MCPToolAdapter; +pub(crate) use tool::{MCPToolContextPolicy, MCPWorkspaceToolRoute}; diff --git a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs index d480926606..23b1bab1e7 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs @@ -21,12 +21,71 @@ use bitfun_services_integrations::mcp::adapter::{ }; use log::{debug, error, info, warn}; use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; +use std::sync::RwLock; const MCP_TOOL_DEFAULT_EXPOSURE: ToolExposure = ToolExposure::Deferred; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MCPWorkspaceToolRoute { + pub active_external_server_ids: BTreeSet, + pub suppressed_native_server_ids: BTreeSet, +} + +#[derive(Debug, Default)] +pub(crate) struct MCPToolContextPolicy { + routes: RwLock>, +} + +impl MCPToolContextPolicy { + pub(crate) fn replace_route(&self, workspace_key: String, route: MCPWorkspaceToolRoute) { + let mut routes = self.routes.write().expect("MCP route lock poisoned"); + if route == MCPWorkspaceToolRoute::default() { + routes.remove(&workspace_key); + } else { + routes.insert(workspace_key, route); + } + } + + pub(crate) fn server_available_for_route( + &self, + server_id: &str, + external_workspace_scope: Option<&str>, + workspace_key: Option<&str>, + remote: bool, + ) -> bool { + if let Some(expected_workspace) = external_workspace_scope { + if remote || workspace_key != Some(expected_workspace) { + return false; + } + return self + .routes + .read() + .expect("MCP route lock poisoned") + .get(expected_workspace) + .is_some_and(|route| route.active_external_server_ids.contains(server_id)); + } + if remote { + return true; + } + let Some(workspace_key) = workspace_key else { + return true; + }; + !self + .routes + .read() + .expect("MCP route lock poisoned") + .get(workspace_key) + .is_some_and(|route| route.suppressed_native_server_ids.contains(server_id)) + } +} + /// MCP tool wrapper that adapts an MCP tool to BitFun's `Tool`. struct MCPToolWrapper { + server_id: String, + external_workspace_scope: Option, + context_policy: Arc, mcp_tool: MCPTool, connection: Arc, descriptor: McpDynamicToolDescriptor, @@ -34,11 +93,17 @@ struct MCPToolWrapper { impl MCPToolWrapper { fn from_descriptor( + server_id: String, + external_workspace_scope: Option, + context_policy: Arc, mcp_tool: MCPTool, connection: Arc, descriptor: McpDynamicToolDescriptor, ) -> Self { Self { + server_id, + external_workspace_scope, + context_policy, mcp_tool, connection, descriptor, @@ -49,8 +114,16 @@ impl MCPToolWrapper { self.descriptor.title.clone() } - fn is_blocked_in_context(&self, _context: Option<&ToolUseContext>) -> bool { - false + fn is_blocked_in_context(&self, context: Option<&ToolUseContext>) -> bool { + let workspace_key = crate::external_tools::workspace_route_key( + context.and_then(ToolUseContext::workspace_root), + ); + !self.context_policy.server_available_for_route( + &self.server_id, + self.external_workspace_scope.as_deref(), + Some(&workspace_key), + context.is_some_and(ToolUseContext::is_remote), + ) } // Do not pre-truncate MCP output here. The shared tool-result storage policy @@ -174,7 +247,12 @@ impl Tool for MCPToolWrapper { input: &Value, context: &ToolUseContext, ) -> BitFunResult> { - let _ = context; + if self.is_blocked_in_context(Some(context)) { + return Err(crate::util::errors::BitFunError::tool(format!( + "MCP server '{}' is unavailable in the current workspace", + self.descriptor.tool_info.server_name + ))); + } info!( "Calling MCP tool: {} from server: {}", @@ -218,11 +296,13 @@ impl MCPToolAdapter { } /// Loads tools from an MCP server. - pub async fn load_tools_from_server( + pub(crate) async fn load_tools_from_server( &mut self, server_id: &str, server_name: &str, connection: Arc, + external_workspace_scope: Option, + context_policy: Arc, ) -> BitFunResult<()> { info!( "Loading tools from MCP server: {} (id={})", @@ -251,6 +331,9 @@ impl MCPToolAdapter { for definition in definitions.into_iter() { let wrapper = Arc::new(MCPToolWrapper::from_descriptor( + server_id.to_string(), + external_workspace_scope.clone(), + Arc::clone(&context_policy), definition.mcp_tool, connection.clone(), definition.descriptor, @@ -284,7 +367,44 @@ impl Default for MCPToolAdapter { #[cfg(test)] mod tests { - use super::{ToolExposure, MCP_TOOL_DEFAULT_EXPOSURE}; + use super::{ + MCPToolContextPolicy, MCPWorkspaceToolRoute, ToolExposure, MCP_TOOL_DEFAULT_EXPOSURE, + }; + + #[test] + fn external_mcp_routes_are_workspace_scoped_and_remote_fail_closed() { + let policy = MCPToolContextPolicy::default(); + policy.replace_route( + "workspace-a".to_string(), + MCPWorkspaceToolRoute { + active_external_server_ids: ["external-a".to_string()].into_iter().collect(), + suppressed_native_server_ids: ["native".to_string()].into_iter().collect(), + }, + ); + + assert!(policy.server_available_for_route( + "external-a", + Some("workspace-a"), + Some("workspace-a"), + false, + )); + assert!(!policy.server_available_for_route( + "external-a", + Some("workspace-a"), + Some("workspace-b"), + false, + )); + assert!(!policy.server_available_for_route("external-a", Some("workspace-a"), None, false,)); + assert!(!policy.server_available_for_route( + "external-a", + Some("workspace-a"), + Some("workspace-a"), + true, + )); + assert!(!policy.server_available_for_route("native", None, Some("workspace-a"), false,)); + assert!(policy.server_available_for_route("native", None, Some("workspace-b"), false,)); + assert!(policy.server_available_for_route("native", None, Some("workspace-a"), true,)); + } #[test] fn mcp_tool_wrapper_defaults_to_deferred_exposure() { diff --git a/src/crates/assembly/core/src/service/mcp/config/service.rs b/src/crates/assembly/core/src/service/mcp/config/service.rs index 536c32f630..7d907aa4d1 100644 --- a/src/crates/assembly/core/src/service/mcp/config/service.rs +++ b/src/crates/assembly/core/src/service/mcp/config/service.rs @@ -141,6 +141,8 @@ mod tests { command: command.map(str::to_string), args: Vec::new(), env: HashMap::new(), + working_directory: None, + inherit_parent_environment: None, headers: HashMap::new(), url: url.map(str::to_string), auto_start: true, @@ -149,6 +151,7 @@ mod tests { capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, } } diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs b/src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs index 3f9d4a6c79..2b6d02ed0d 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs @@ -3,6 +3,10 @@ use bitfun_services_integrations::mcp::server::{detect_mcp_list_changed_kind, MC use std::collections::HashSet; impl MCPServerManager { + fn external_server_request_allowed(method: &str) -> bool { + method == "ping" + } + fn path_to_file_uri(path: &Path) -> Option { reqwest::Url::from_directory_path(path) .ok() @@ -51,6 +55,27 @@ impl MCPServerManager { method: String, params: Option, ) { + if self + .ephemeral_workspace_scopes + .read() + .await + .contains_key(server_id) + && !Self::external_server_request_allowed(&method) + { + let error = MCPError::method_not_found(method.clone()); + if let Err(error) = connection.send_error(request_id, error).await { + warn!( + "Failed to reject unsupported external MCP request: server_name={} server_id={} method={} error={}", + server_name, server_id, method, error + ); + } else { + warn!( + "Rejected unsupported external MCP request: server_name={} server_id={} method={}", + server_name, server_id, method + ); + } + return; + } match method.as_str() { "ping" => { if let Err(e) = connection.send_response(request_id, json!({})).await { @@ -382,4 +407,18 @@ mod tests { "MCP roots/list must not expose the process current directory when no workspace is active" ); } + + #[test] + fn external_runtime_allows_ping_but_not_host_capability_requests() { + assert!(MCPServerManager::external_server_request_allowed("ping")); + assert!(!MCPServerManager::external_server_request_allowed( + "roots/list" + )); + assert!(!MCPServerManager::external_server_request_allowed( + "elicitation/create" + )); + assert!(!MCPServerManager::external_server_request_allowed( + "sampling/createMessage" + )); + } } diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs index a88a3c300a..dca8dfc773 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs @@ -26,14 +26,27 @@ impl MCPServerManager { /// Initializes all servers. pub async fn initialize_all(&self) -> BitFunResult<()> { info!("Initializing all MCP servers"); + let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; let existing_server_ids = self.runtime.get_all_server_ids().await; if !existing_server_ids.is_empty() { + let external_ids = self.ephemeral_workspace_scopes.read().await; + let refresh_ids = existing_server_ids + .iter() + .filter(|server_id| !external_ids.contains_key(*server_id)) + .cloned() + .collect::>(); + drop(external_ids); info!( - "Refreshing MCP servers: shutting down existing servers before applying config: count={}", - existing_server_ids.len() + "Refreshing persisted MCP servers while preserving external workspace runtimes: count={}", + refresh_ids.len() ); - self.shutdown().await?; + for server_id in refresh_ids { + let _ = self.stop_server(&server_id).await; + let _ = self.runtime.unregister(&server_id).await; + self.runtime.remove_catalog(&server_id).await; + self.clear_reconnect_state(&server_id).await; + } } let configs = self.config_service.load_all_configs().await?; @@ -166,6 +179,14 @@ impl MCPServerManager { /// Starts a server. pub async fn start_server(&self, server_id: &str) -> BitFunResult<()> { + self.start_server_with_external_token(server_id, None).await + } + + async fn start_server_with_external_token( + &self, + server_id: &str, + expected_external_start_token: Option>, + ) -> BitFunResult<()> { self.start_reconnect_monitor_if_needed(); info!("Starting MCP server: id={}", server_id); @@ -213,7 +234,13 @@ impl MCPServerManager { resolved_command, source_label, server_id ); - proc.start(&resolved_command, &config.args, &config.env) + proc.start_with_environment_policy( + &resolved_command, + &config.args, + &config.env, + config.working_directory.as_deref().map(Path::new), + config.inherits_parent_environment(), + ) .await .map_err(|e| { error!( @@ -237,35 +264,72 @@ impl MCPServerManager { ))); } - let url = config.url.as_ref().ok_or_else(|| { + config.url.as_ref().ok_or_else(|| { error!("Missing URL for remote MCP server: id={}", server_id); BitFunError::Configuration("Missing URL for remote MCP server".to_string()) })?; info!( - "Connecting to remote MCP server: transport={} url={} id={}", + "Connecting to remote MCP server: transport={} id={}", transport.as_str(), - url, server_id ); let data_dir = crate::infrastructure::try_get_path_manager_arc()?.user_data_dir(); proc.start_remote(data_dir, &config).await.map_err(|e| { error!( - "Failed to connect to remote MCP server: url={} id={} error={}", - url, server_id, e + "Failed to connect to remote MCP server: id={} error={}", + server_id, e ); e })?; } } - if let Some(connection) = proc.connection() { + let connection = proc.connection(); + drop(proc); + let external_workspace_scope = self + .ephemeral_workspace_scopes + .read() + .await + .get(server_id) + .cloned(); + let _external_publication_guard = if external_workspace_scope.is_some() { + Some(self.ephemeral_lifecycle.lock().await) + } else { + None + }; + if !external_start_publication_allowed( + external_workspace_scope.is_some(), + self.ephemeral_retirements + .read() + .await + .contains_key(server_id), + ) { + return Err(BitFunError::Configuration(format!( + "External MCP server was retired during startup: {}", + server_id + ))); + } + if let Some(expected_token) = expected_external_start_token.as_ref() { + let start_tokens = self.ephemeral_start_tokens.read().await; + if !external_start_token_is_current(start_tokens.get(server_id), expected_token) { + return Err(BitFunError::Configuration(format!( + "External MCP server startup was superseded: {}", + server_id + ))); + } + } + + if let Some(connection) = connection { self.runtime .add_connection(server_id.to_string(), connection.clone()) .await; - match Self::register_mcp_tools(server_id, &config.name, connection.clone()).await { + match self + .register_mcp_tools(server_id, &config.name, connection.clone()) + .await + { Ok(count) => { info!( "Registered {} MCP tools: server_name={} server_id={}", @@ -277,17 +341,32 @@ impl MCPServerManager { "Failed to register MCP tools: server_name={} server_id={} error={}", config.name, server_id, e ); + if external_workspace_scope.is_some() { + self.runtime.remove_connection(server_id).await; + return Err(e); + } } } self.start_connection_event_listener(server_id, &config.name, connection.clone()) .await; self.warm_catalog_caches(server_id, connection).await; + if external_workspace_scope.is_some() { + self.ephemeral_ready_servers + .write() + .await + .insert(server_id.to_string()); + } } else { warn!( "Connection not available, server may not have started correctly: id={}", server_id ); + if external_workspace_scope.is_some() { + return Err(BitFunError::MCPError( + "External MCP server did not establish a connection".to_string(), + )); + } } info!("MCP server started successfully: id={}", server_id); @@ -336,7 +415,14 @@ impl MCPServerManager { .command .as_ref() .ok_or_else(|| BitFunError::Configuration("Missing command".to_string()))?; - proc.restart(command, &config.args, &config.env).await?; + proc.restart_with_environment_policy( + command, + &config.args, + &config.env, + config.working_directory.as_deref().map(Path::new), + config.inherits_parent_environment(), + ) + .await?; } super::super::MCPServerType::Remote => { self.ensure_registered(server_id).await?; @@ -447,6 +533,302 @@ impl MCPServerManager { Ok(()) } + async fn external_start_token_matches(&self, server_id: &str, expected: &Arc<()>) -> bool { + let start_tokens = self.ephemeral_start_tokens.read().await; + external_start_token_is_current(start_tokens.get(server_id), expected) + } + + async fn remove_ephemeral_server_for_start(&self, server_id: &str, expected: &Arc<()>) -> bool { + let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; + if !self.external_start_token_matches(server_id, expected).await { + return false; + } + if let Err(error) = self.remove_ephemeral_server(server_id).await { + warn!( + "Could not clean up failed external MCP startup: id={} error={}", + server_id, error + ); + } + true + } + + /// Installs a product-approved runtime-only server. A matching retirement + /// can be cancelled without restarting the process, which keeps rapid + /// disable/enable actions from interrupting unrelated session work. + pub async fn install_external_ephemeral_server( + &self, + config: MCPServerConfig, + workspace_key: String, + ) -> BitFunResult<()> { + config.validate()?; + let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; + let server_id = config.id.clone(); + let start_token = Arc::new(()); + self.ephemeral_start_tokens + .write() + .await + .insert(server_id.clone(), Arc::clone(&start_token)); + self.ephemeral_workspace_scopes + .write() + .await + .insert(server_id.clone(), workspace_key); + self.ephemeral_ready_servers + .write() + .await + .remove(&server_id); + let cancelled_retirement = self + .ephemeral_retirements + .write() + .await + .remove(&server_id) + .map(|cancelled| { + cancelled.store(true, Ordering::Release); + true + }) + .unwrap_or(false); + + if cancelled_retirement && self.runtime.contains(&server_id).await { + if let Err(error) = self.runtime.insert_runtime_config(config.clone()).await { + let _ = self.remove_ephemeral_server(&server_id).await; + return Err(error.into()); + } + let connection = if let Some(process) = self.runtime.get_process(&server_id).await { + process.read().await.connection() + } else { + None + }; + if let Some(connection) = connection { + self.runtime + .add_connection(server_id.clone(), connection.clone()) + .await; + if let Err(error) = self + .refresh_mcp_tools(&server_id, &config.name, connection.clone()) + .await + { + let _ = self.remove_ephemeral_server(&server_id).await; + return Err(error); + } + self.start_connection_event_listener(&server_id, &config.name, connection.clone()) + .await; + self.warm_catalog_caches(&server_id, connection).await; + self.ephemeral_ready_servers + .write() + .await + .insert(server_id.clone()); + } else { + let _ = self.remove_ephemeral_server(&server_id).await; + return Err(BitFunError::MCPError( + "External MCP server did not retain its connection".to_string(), + )); + } + return Ok(()); + } + if self.runtime.contains(&server_id).await { + self.ephemeral_workspace_scopes + .write() + .await + .remove(&server_id); + self.ephemeral_start_tokens.write().await.remove(&server_id); + return Err(BitFunError::Configuration(format!( + "MCP server already exists: {}", + server_id + ))); + } + + if let Err(error) = self.runtime.insert_runtime_config(config.clone()).await { + self.ephemeral_workspace_scopes + .write() + .await + .remove(&server_id); + self.ephemeral_start_tokens.write().await.remove(&server_id); + return Err(error.into()); + } + if let Err(error) = self.runtime.register(&config).await { + self.runtime.remove_runtime_config(&server_id).await; + self.ephemeral_workspace_scopes + .write() + .await + .remove(&server_id); + self.ephemeral_start_tokens.write().await.remove(&server_id); + return Err(error.into()); + } + if config.enabled && config.auto_start { + // External source refresh and product-surface reads must not wait + // for a third-party process or network handshake. Registration is + // synchronous so status reads immediately see Loading; startup is + // bounded in the background and cleans up only this runtime item. + const EXTERNAL_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + let manager = self.clone(); + tokio::spawn(async move { + let startup = tokio::time::timeout( + EXTERNAL_START_TIMEOUT, + manager.start_server_with_external_token( + &server_id, + Some(Arc::clone(&start_token)), + ), + ) + .await; + match startup { + Ok(Ok(())) => { + if manager + .external_start_token_matches(&server_id, &start_token) + .await + { + crate::external_sources::notify_external_tool_registry_changed(); + } + } + Ok(Err(error)) => { + warn!( + "External ephemeral MCP server failed to start: id={} error={}", + server_id, error + ); + if manager + .remove_ephemeral_server_for_start(&server_id, &start_token) + .await + { + crate::external_sources::notify_external_tool_registry_changed(); + } + } + Err(_) => { + warn!( + "External ephemeral MCP server startup timed out: id={}", + server_id + ); + if manager + .remove_ephemeral_server_for_start(&server_id, &start_token) + .await + { + crate::external_sources::notify_external_tool_registry_changed(); + } + } + } + }); + } + Ok(()) + } + + /// Withdraws new tool/resource access immediately, then lets already-held + /// connection users finish before the process is reclaimed. The grace is + /// bounded so a deleted or malicious server cannot remain indefinitely. + pub async fn retire_external_ephemeral_server(&self, server_id: &str) -> BitFunResult<()> { + const RETIREMENT_GRACE: std::time::Duration = std::time::Duration::from_secs(30); + const RETIREMENT_RECLAIM_ATTEMPTS: usize = 3; + const RETIREMENT_RETRY_DELAY: std::time::Duration = + std::time::Duration::from_millis(250); + let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; + self.ephemeral_start_tokens.write().await.remove(server_id); + if !self.runtime.contains(server_id).await { + self.runtime.remove_runtime_config(server_id).await; + self.ephemeral_ready_servers.write().await.remove(server_id); + self.ephemeral_workspace_scopes + .write() + .await + .remove(server_id); + return Ok(()); + } + + if let Some(previous) = self + .ephemeral_retirements + .write() + .await + .insert(server_id.to_string(), Arc::new(AtomicBool::new(false))) + { + previous.store(true, Ordering::Release); + } + let cancelled = self + .ephemeral_retirements + .read() + .await + .get(server_id) + .cloned() + .expect("retirement marker was just inserted"); + let connection = self.runtime.get_connection(server_id).await; + + self.ephemeral_ready_servers.write().await.remove(server_id); + Self::unregister_mcp_tools(server_id).await; + self.stop_connection_event_listener(server_id).await; + self.runtime.remove_connection(server_id).await; + self.runtime.remove_catalog(server_id).await; + self.runtime.remove_runtime_config(server_id).await; + self.clear_reconnect_state(server_id).await; + + let manager = self.clone(); + let server_id = server_id.to_string(); + tokio::spawn(async move { + let started = std::time::Instant::now(); + loop { + if cancelled.load(Ordering::Acquire) { + return; + } + let references = connection.as_ref().map_or(0, Arc::strong_count); + if should_finish_ephemeral_retirement( + references, + started.elapsed(), + RETIREMENT_GRACE, + ) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + for attempt in 1..=RETIREMENT_RECLAIM_ATTEMPTS { + let lifecycle_guard = manager.ephemeral_lifecycle.lock().await; + if cancelled.load(Ordering::Acquire) { + return; + } + let should_remove = manager + .ephemeral_retirements + .read() + .await + .get(&server_id) + .is_some_and(|current| Arc::ptr_eq(current, &cancelled)); + if !should_remove { + return; + } + match manager.runtime.unregister(&server_id).await { + Ok(()) => { + manager + .ephemeral_retirements + .write() + .await + .remove(&server_id); + Self::unregister_mcp_tools(&server_id).await; + manager.stop_connection_event_listener(&server_id).await; + manager.runtime.remove_connection(&server_id).await; + manager.runtime.remove_catalog(&server_id).await; + manager + .ephemeral_ready_servers + .write() + .await + .remove(&server_id); + manager + .ephemeral_workspace_scopes + .write() + .await + .remove(&server_id); + return; + } + Err(error) if attempt < RETIREMENT_RECLAIM_ATTEMPTS => { + warn!( + "Could not reclaim retired ephemeral MCP server; retrying: id={} attempt={} error={}", + server_id, attempt, error + ); + } + Err(error) => { + warn!( + "Could not reclaim retired ephemeral MCP server; retaining ownership for a later retry: id={} attempts={} error={}", + server_id, RETIREMENT_RECLAIM_ATTEMPTS, error + ); + return; + } + } + drop(lifecycle_guard); + tokio::time::sleep(RETIREMENT_RETRY_DELAY).await; + } + }); + Ok(()) + } + /// Removes a runtime-only MCP server and its registered tools without touching persisted config. pub async fn remove_ephemeral_server(&self, server_id: &str) -> BitFunResult<()> { info!("Removing ephemeral MCP server: id={}", server_id); @@ -463,6 +845,12 @@ impl MCPServerManager { self.stop_connection_event_listener(server_id).await; self.clear_reconnect_state(server_id).await; self.runtime.remove_catalog(server_id).await; + self.ephemeral_ready_servers.write().await.remove(server_id); + self.ephemeral_start_tokens.write().await.remove(server_id); + self.ephemeral_workspace_scopes + .write() + .await + .remove(server_id); if let Err(error) = stop_result { warn!( @@ -566,6 +954,12 @@ impl MCPServerManager { pub async fn shutdown(&self) -> BitFunResult<()> { info!("Shutting down all MCP servers"); + for (_, cancelled) in self.ephemeral_retirements.write().await.drain() { + cancelled.store(true, Ordering::Release); + } + self.ephemeral_ready_servers.write().await.clear(); + self.ephemeral_start_tokens.write().await.clear(); + let server_ids = self.runtime.get_all_server_ids().await; for server_id in server_ids { if let Err(e) = self.stop_server(&server_id).await { diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs index 4ced06605d..6bf428c5f5 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs @@ -15,7 +15,7 @@ mod tools; use super::connection::MCPConnection; use super::{MCPServerConfig, MCPServerStatus}; use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; -use crate::service::mcp::adapter::MCPToolAdapter; +use crate::service::mcp::adapter::{MCPToolAdapter, MCPToolContextPolicy, MCPWorkspaceToolRoute}; use crate::service::mcp::auth::MCPRemoteOAuthSessionSnapshot; use crate::service::mcp::config::MCPConfigService; use crate::service::mcp::protocol::{MCPError, MCPPrompt, MCPResource}; @@ -25,10 +25,11 @@ use bitfun_services_integrations::mcp::server::MCPConnectionEvent; use bitfun_services_integrations::mcp::server::MCPServerRuntimeState; use log::{debug, error, info, warn}; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::{oneshot, Mutex}; use tokio::task::JoinHandle; @@ -57,6 +58,12 @@ pub struct MCPServerManager { connection_event_tasks: Arc>>>, pending_interactions: Arc>>, oauth_sessions: Arc>>>, + ephemeral_retirements: Arc>>>, + ephemeral_workspace_scopes: Arc>>, + ephemeral_ready_servers: Arc>>, + ephemeral_start_tokens: Arc>>>, + tool_context_policy: Arc, + ephemeral_lifecycle: Arc>, } impl MCPServerManager { @@ -69,6 +76,89 @@ impl MCPServerManager { connection_event_tasks: Arc::new(tokio::sync::RwLock::new(HashMap::new())), pending_interactions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), oauth_sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + ephemeral_retirements: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + ephemeral_workspace_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + ephemeral_ready_servers: Arc::new(tokio::sync::RwLock::new(HashSet::new())), + ephemeral_start_tokens: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + tool_context_policy: Arc::new(MCPToolContextPolicy::default()), + ephemeral_lifecycle: Arc::new(Mutex::new(())), } } + + pub async fn replace_external_workspace_tool_route( + &self, + workspace_key: String, + active_external_server_ids: std::collections::BTreeSet, + suppressed_native_server_ids: std::collections::BTreeSet, + ) { + self.tool_context_policy.replace_route( + workspace_key, + MCPWorkspaceToolRoute { + active_external_server_ids, + suppressed_native_server_ids, + }, + ); + } + + /// Authorizes access to a server from a concrete execution context. + /// Runtime-only external servers are workspace-scoped and never available + /// to remote or unscoped host callers. Native servers remain available + /// unless the current local workspace explicitly selected an external + /// conflict candidate for the same server. + pub async fn server_available_for_context( + &self, + server_id: &str, + workspace_root: Option<&Path>, + remote: bool, + ) -> bool { + let external_workspace_scope = self + .ephemeral_workspace_scopes + .read() + .await + .get(server_id) + .cloned(); + let workspace_key = crate::external_tools::workspace_route_key(workspace_root); + self.tool_context_policy.server_available_for_route( + server_id, + external_workspace_scope.as_deref(), + workspace_root.map(|_| workspace_key.as_str()), + remote, + ) + } + + /// Returns `Some(true)` only after an external runtime has connected and + /// completed its initial tool catalog publication. `None` means the server + /// is not an external runtime. + pub async fn external_server_readiness(&self, server_id: &str) -> Option { + if !self + .ephemeral_workspace_scopes + .read() + .await + .contains_key(server_id) + { + return None; + } + Some( + self.ephemeral_ready_servers + .read() + .await + .contains(server_id), + ) + } +} + +fn should_finish_ephemeral_retirement( + connection_references: usize, + elapsed: Duration, + grace: Duration, +) -> bool { + connection_references <= 2 || elapsed >= grace +} + +fn external_start_publication_allowed(external: bool, retiring: bool) -> bool { + !external || !retiring +} + +fn external_start_token_is_current(current: Option<&Arc<()>>, expected: &Arc<()>) -> bool { + current.is_some_and(|current| Arc::ptr_eq(current, expected)) } diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs b/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs index 78752e6f4f..35797fb258 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs @@ -42,3 +42,39 @@ fn detect_list_changed_kind_supports_three_catalogs() { ); assert_eq!(detect_mcp_list_changed_kind("notifications/unknown"), None); } + +#[test] +fn ephemeral_retirement_waits_for_in_flight_connection_users_but_is_bounded() { + let grace = Duration::from_secs(30); + assert!(super::should_finish_ephemeral_retirement( + 2, + Duration::ZERO, + grace + )); + assert!(!super::should_finish_ephemeral_retirement( + 3, + Duration::from_secs(10), + grace + )); + assert!(super::should_finish_ephemeral_retirement(3, grace, grace)); +} + +#[test] +fn retired_external_start_cannot_publish_after_handshake() { + assert!(super::external_start_publication_allowed(false, true)); + assert!(super::external_start_publication_allowed(true, false)); + assert!(!super::external_start_publication_allowed(true, true)); +} + +#[test] +fn superseded_external_start_token_cannot_clean_up_current_instance() { + let first = std::sync::Arc::new(()); + let current = std::sync::Arc::new(()); + + assert!(super::external_start_token_is_current(Some(&first), &first)); + assert!(!super::external_start_token_is_current( + Some(¤t), + &first + )); + assert!(!super::external_start_token_is_current(None, &first)); +} diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/tools.rs b/src/crates/assembly/core/src/service/mcp/server/manager/tools.rs index b0ebc0d070..8112f39441 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/tools.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/tools.rs @@ -8,11 +8,13 @@ impl MCPServerManager { connection: Arc, ) -> BitFunResult { Self::unregister_mcp_tools(server_id).await; - Self::register_mcp_tools(server_id, server_name, connection).await + self.register_mcp_tools(server_id, server_name, connection) + .await } /// Registers MCP tools into the global tool registry. pub(super) async fn register_mcp_tools( + &self, server_id: &str, server_name: &str, connection: Arc, @@ -23,9 +25,21 @@ impl MCPServerManager { ); let mut adapter = MCPToolAdapter::new(); + let external_workspace_scope = self + .ephemeral_workspace_scopes + .read() + .await + .get(server_id) + .cloned(); adapter - .load_tools_from_server(server_id, server_name, connection) + .load_tools_from_server( + server_id, + server_name, + connection, + external_workspace_scope, + Arc::clone(&self.tool_context_policy), + ) .await .map_err(|e| { error!( diff --git a/src/crates/assembly/core/src/service/mcp/server/process.rs b/src/crates/assembly/core/src/service/mcp/server/process.rs index 89bdef7c35..eb26289f6e 100644 --- a/src/crates/assembly/core/src/service/mcp/server/process.rs +++ b/src/crates/assembly/core/src/service/mcp/server/process.rs @@ -33,6 +33,39 @@ impl MCPServerProcess { Ok(()) } + pub async fn start_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> BitFunResult<()> { + self.inner + .start_in_directory(command, args, env, working_directory) + .await?; + Ok(()) + } + + pub async fn start_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, + ) -> BitFunResult<()> { + self.inner + .start_with_environment_policy( + command, + args, + env, + working_directory, + inherit_parent_environment, + ) + .await?; + Ok(()) + } + pub async fn start_remote(&mut self, config: &MCPServerConfig) -> BitFunResult<()> { let data_dir = try_get_path_manager_arc()?.user_data_dir(); self.inner.start_remote(data_dir, config).await?; @@ -54,6 +87,39 @@ impl MCPServerProcess { Ok(()) } + pub async fn restart_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> BitFunResult<()> { + self.inner + .restart_in_directory(command, args, env, working_directory) + .await?; + Ok(()) + } + + pub async fn restart_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, + ) -> BitFunResult<()> { + self.inner + .restart_with_environment_policy( + command, + args, + env, + working_directory, + inherit_parent_environment, + ) + .await?; + Ok(()) + } + pub async fn status(&self) -> MCPServerStatus { self.inner.status().await } diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 8c3bb46f7a..2297b8df83 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -4,9 +4,14 @@ //! branches on ecosystem identity. Concrete provider selection remains in the //! product composition root. +mod mcp; mod subagent; mod tool; +pub use mcp::{ + ExternalMcpCoordinator, ExternalMcpCoordinatorSnapshot, ExternalMcpDiscoveryRequest, + ExternalMcpDiscoveryResult, +}; pub use subagent::{ ExternalSubagentCoordinator, ExternalSubagentCoordinatorSnapshot, ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, @@ -157,6 +162,10 @@ impl ExternalSourceCoordinator { tools: Vec::new(), tool_approval_requests: Vec::new(), tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), @@ -651,6 +660,10 @@ impl ExternalSourceCoordinator { tools: Vec::new(), tool_approval_requests: Vec::new(), tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), diff --git a/src/crates/assembly/external-sources/src/mcp.rs b/src/crates/assembly/external-sources/src/mcp.rs new file mode 100644 index 0000000000..081f32d835 --- /dev/null +++ b/src/crates/assembly/external-sources/src/mcp.rs @@ -0,0 +1,449 @@ +use bitfun_product_domains::external_sources::{ + ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, + ExternalMcpServerDefinition, ExternalMcpSourceProvider, ExternalMcpStaticStatus, + ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceLifecycleState, + ExternalSourceProviderError, ExternalWatchRoot, PreparedExternalMcpServer, ProviderId, + SourceKey, SourceQualifiedMcpServerId, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +struct McpProviderGeneration { + provider: Arc, + identity: ExternalMcpProviderIdentity, + initial_result_received: bool, + last_success: Option, + last_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalMcpCoordinatorSnapshot { + pub generation: u64, + pub discovery_pending: bool, + pub sources: Vec, + pub servers: Vec, + pub diagnostics: Vec, +} + +/// One provider-neutral discovery unit that product assembly may schedule in +/// parallel without teaching the coordinator about a concrete ecosystem. +pub struct ExternalMcpDiscoveryRequest { + provider_id: ProviderId, + provider: Arc, + input: ExternalMcpDiscoveryInput, +} + +impl ExternalMcpDiscoveryRequest { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn execute(self) -> ExternalMcpDiscoveryResult { + ExternalMcpDiscoveryResult { + provider_id: self.provider_id, + candidate: self.provider.discover(&self.input), + } + } +} + +#[derive(Clone)] +pub struct ExternalMcpDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalMcpDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +/// Coordinates lifecycle and stale-revision guards for executable MCP +/// configuration while remaining independent of the source ecosystem. +pub struct ExternalMcpCoordinator { + context: ExternalSourceContext, + providers: Vec, + suppressed_sources: BTreeSet, + generation: u64, + snapshot: ExternalMcpCoordinatorSnapshot, +} + +impl fmt::Debug for ExternalMcpCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalMcpCoordinator") + .field("context", &self.context) + .field("providers", &self.providers.len()) + .field("suppressed_sources", &self.suppressed_sources) + .field("generation", &self.generation) + .finish() + } +} + +impl ExternalMcpCoordinator { + pub fn new( + context: ExternalSourceContext, + providers: Vec>, + ) -> Result { + let mut provider_ids = BTreeSet::new(); + let mut generations = Vec::with_capacity(providers.len()); + for provider in providers { + let identity = provider.identity(); + if !provider_ids.insert(identity.provider_id.clone()) { + return Err(format!( + "duplicate external MCP provider id: {}", + identity.provider_id + )); + } + generations.push(McpProviderGeneration { + provider, + identity, + initial_result_received: false, + last_success: None, + last_error: None, + }); + } + let discovery_pending = !generations.is_empty(); + Ok(Self { + context, + providers: generations, + suppressed_sources: BTreeSet::new(), + generation: 0, + snapshot: ExternalMcpCoordinatorSnapshot { + generation: 0, + discovery_pending, + sources: Vec::new(), + servers: Vec::new(), + diagnostics: Vec::new(), + }, + }) + } + + pub fn refresh(&mut self) -> ExternalMcpCoordinatorSnapshot { + let results = self + .discovery_requests() + .into_iter() + .map(ExternalMcpDiscoveryRequest::execute) + .collect(); + self.apply_discovery_results(results) + } + + pub fn discovery_requests(&self) -> Vec { + let suppressed_sources = self.suppressed_source_keys(); + self.providers + .iter() + .map(|generation| ExternalMcpDiscoveryRequest { + provider_id: generation.identity.provider_id.clone(), + provider: Arc::clone(&generation.provider), + input: ExternalMcpDiscoveryInput { + context: self.context.clone(), + suppressed_sources: suppressed_sources.clone(), + }, + }) + .collect() + } + + pub fn apply_discovery_results( + &mut self, + results: Vec, + ) -> ExternalMcpCoordinatorSnapshot { + let mut results = results + .into_iter() + .map(|result| (result.provider_id, result.candidate)) + .collect::>(); + for generation in &mut self.providers { + let candidate = results + .remove(&generation.identity.provider_id) + .unwrap_or_else(|| { + Err(ExternalSourceProviderError::new( + "external_mcp.discovery_result_missing", + "MCP provider discovery did not return a result", + true, + )) + }); + apply_provider_candidate(generation, candidate); + } + self.rebuild_snapshot() + } + + pub fn apply_discovery_result( + &mut self, + result: ExternalMcpDiscoveryResult, + ) -> ExternalMcpCoordinatorSnapshot { + if let Some(generation) = self + .providers + .iter_mut() + .find(|generation| generation.identity.provider_id == result.provider_id) + { + apply_provider_candidate(generation, result.candidate); + } + self.rebuild_snapshot() + } + + pub fn snapshot(&self) -> ExternalMcpCoordinatorSnapshot { + self.snapshot.clone() + } + + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { + let known = self.providers.iter().any(|provider| { + provider.last_success.as_ref().is_some_and(|snapshot| { + snapshot + .sources + .iter() + .any(|source| source.preference_key() == stable_key) + }) + }); + if !known { + return Err(format!("unknown external MCP source: {stable_key}")); + } + if enabled { + self.suppressed_sources.remove(stable_key); + } else { + self.suppressed_sources.insert(stable_key.to_string()); + } + self.rebuild_snapshot(); + Ok(()) + } + + pub fn replace_suppressed_sources(&mut self, sources: BTreeSet) { + self.suppressed_sources = sources; + self.rebuild_snapshot(); + } + + pub fn suppressed_sources(&self) -> &BTreeSet { + &self.suppressed_sources + } + + pub fn prepare_server_guarded( + &self, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + let current = self.snapshot.servers.iter().find(|server| { + &server.id == server_id && server.behavior_version == expected_behavior_version + }); + let Some(server) = current else { + return Err(ExternalSourceProviderError::new( + "external_mcp.stale_revision", + "MCP server is not available at the requested revision", + true, + )); + }; + if !server.source_enabled || !matches!(server.static_status, ExternalMcpStaticStatus::Ready) + { + return Err(ExternalSourceProviderError::new( + "external_mcp.server_unavailable", + "MCP server configuration is not available for activation", + false, + )); + } + let provider = self + .providers + .iter() + .find(|provider| provider.identity.provider_id == server_id.source.provider_id) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_mcp.provider_missing", + "MCP provider is not registered", + false, + ) + })?; + let prepared = provider.provider.prepare_server( + &ExternalMcpDiscoveryInput { + context: self.context.clone(), + suppressed_sources: self.suppressed_source_keys(), + }, + server_id, + expected_behavior_version, + )?; + if prepared.id != *server_id || prepared.behavior_version != expected_behavior_version { + return Err(ExternalSourceProviderError::new( + "external_mcp.prepared_server_mismatch", + "MCP provider prepared a different server revision", + false, + )); + } + Ok(prepared) + } + + pub fn watch_roots(&self) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + + fn suppressed_source_keys(&self) -> BTreeSet { + self.providers + .iter() + .filter_map(|provider| provider.last_success.as_ref()) + .flat_map(|snapshot| snapshot.sources.iter()) + .filter(|source| self.suppressed_sources.contains(&source.preference_key())) + .map(|source| source.key.clone()) + .collect() + } + + fn rebuild_snapshot(&mut self) -> ExternalMcpCoordinatorSnapshot { + self.generation = self.generation.saturating_add(1); + let mut sources = Vec::new(); + let mut servers = Vec::new(); + let mut diagnostics = Vec::new(); + let suppressed_source_keys = self.suppressed_source_keys(); + for provider in &self.providers { + let failed = provider.last_error.is_some(); + if let Some(snapshot) = &provider.last_success { + for source in &snapshot.sources { + let suppressed = self.suppressed_sources.contains(&source.preference_key()); + sources.push(ExternalSourceCatalogEntry { + stable_key: source.preference_key(), + record: source.clone(), + lifecycle: source_lifecycle(source.health, suppressed, failed), + }); + if !suppressed { + servers.extend( + snapshot + .servers + .iter() + .filter(|server| { + server.id.source == source.key + && server.provenance.iter().all(|source_key| { + !suppressed_source_keys.contains(source_key) + }) + }) + .cloned(), + ); + } + } + diagnostics.extend(snapshot.diagnostics.clone()); + } + if let Some(error) = &provider.last_error { + let diagnostic = if error.transient { + ExternalSourceDiagnostic::warning( + error.code.clone(), + error.message.clone(), + None, + ) + } else { + ExternalSourceDiagnostic::error(error.code.clone(), error.message.clone(), None) + }; + diagnostics.push(diagnostic.with_asset_kind(ExternalSourceAssetKind::Mcp)); + } + } + sources.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + servers.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + self.snapshot = ExternalMcpCoordinatorSnapshot { + generation: self.generation, + discovery_pending: self + .providers + .iter() + .any(|provider| !provider.initial_result_received), + sources, + servers, + diagnostics, + }; + self.snapshot.clone() + } +} + +fn source_lifecycle( + health: ExternalSourceHealth, + suppressed: bool, + using_last_valid: bool, +) -> ExternalSourceLifecycleState { + if suppressed { + return ExternalSourceLifecycleState::Suppressed; + } + if using_last_valid { + return ExternalSourceLifecycleState::UsingLastValidVersion; + } + match health { + ExternalSourceHealth::Available => ExternalSourceLifecycleState::Available, + ExternalSourceHealth::Partial => ExternalSourceLifecycleState::Restricted, + ExternalSourceHealth::Degraded => ExternalSourceLifecycleState::Degraded, + ExternalSourceHealth::Unavailable => ExternalSourceLifecycleState::Unavailable, + _ => ExternalSourceLifecycleState::Unavailable, + } +} + +fn apply_provider_candidate( + generation: &mut McpProviderGeneration, + candidate: Result, +) { + generation.initial_result_received = true; + match candidate { + Ok(snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + let previously_known_sources = generation + .last_success + .as_ref() + .map(|previous| { + previous + .sources + .iter() + .map(|source| source.key.clone()) + .collect::>() + }) + .unwrap_or_default(); + let previously_known_locations = generation + .last_success + .as_ref() + .map(|previous| { + previous + .sources + .iter() + .map(|source| source.location.clone()) + .collect::>() + }) + .unwrap_or_default(); + let known_source_refresh_failed = snapshot.sources.iter().any(|source| { + source.health == ExternalSourceHealth::Unavailable + && (previously_known_sources.contains(&source.key) + || previously_known_locations.contains(&source.location)) + }); + if known_source_refresh_failed { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_mcp.source_refresh_failed", + "An MCP source could not be refreshed; using its last valid version", + true, + )); + } else { + generation.last_success = Some(snapshot); + generation.last_error = None; + } + } + Ok(()) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_mcp.provider_identity_mismatch", + "MCP provider returned a mismatched identity", + false, + )); + } + Err(error) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_mcp.snapshot_invalid", + error.to_string(), + false, + )); + } + }, + Err(error) => generation.last_error = Some(error), + } +} diff --git a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs new file mode 100644 index 0000000000..2198f53c84 --- /dev/null +++ b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs @@ -0,0 +1,305 @@ +use bitfun_external_sources::ExternalMcpCoordinator; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, + ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, + ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceContext, ExternalSourceHealth, + ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalSourceRecord, + ExternalSourceScope, ExternalWatchRoot, PreparedExternalMcpServer, + PreparedExternalMcpTransport, ProviderId, SourceKey, SourceQualifiedMcpServerId, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +fn source(source_id: &str) -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new("fake.mcp", source_id).unwrap(), + ecosystem_id: EcosystemId::new("fake").unwrap(), + display_name: format!("Fake {source_id}"), + source_kind: "fake_mcp_config".to_string(), + scope: ExternalSourceScope::Project, + location: format!("/workspace/{source_id}.json"), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + } +} + +fn snapshot(source_id: &str, version: &str) -> ExternalMcpProviderSnapshot { + let source = source(source_id); + ExternalMcpProviderSnapshot { + provider: ExternalMcpProviderIdentity::new("fake.mcp", "fake", "Fake MCP").unwrap(), + sources: vec![source.clone()], + servers: vec![ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key], + name: "github".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://example.test/mcp".to_string()), + header_names: Vec::new(), + source_enabled: true, + behavior_version: version.to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }], + diagnostics: Vec::new(), + } +} + +fn merged_snapshot() -> ExternalMcpProviderSnapshot { + let global = source("global"); + let project = source("project"); + ExternalMcpProviderSnapshot { + provider: ExternalMcpProviderIdentity::new("fake.mcp", "fake", "Fake MCP").unwrap(), + sources: vec![global.clone(), project.clone()], + servers: vec![ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(project.key.clone(), "github").unwrap(), + provenance: vec![global.key, project.key], + name: "github".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://example.test/mcp".to_string()), + header_names: Vec::new(), + source_enabled: true, + behavior_version: "behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }], + diagnostics: Vec::new(), + } +} + +struct FakeProvider { + result: Mutex>, + observed_suppression: Mutex>>, +} + +impl FakeProvider { + fn new(snapshot: ExternalMcpProviderSnapshot) -> Self { + Self { + result: Mutex::new(Ok(snapshot)), + observed_suppression: Mutex::new(Vec::new()), + } + } + + fn replace(&self, result: Result) { + *self.result.lock().unwrap() = result; + } +} + +impl ExternalMcpSourceProvider for FakeProvider { + fn identity(&self) -> ExternalMcpProviderIdentity { + ExternalMcpProviderIdentity::new("fake.mcp", "fake", "Fake MCP").unwrap() + } + + fn discover( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + self.observed_suppression + .lock() + .unwrap() + .push(input.suppressed_sources.clone()); + self.result.lock().unwrap().clone() + } + + fn prepare_server( + &self, + _input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + Ok(PreparedExternalMcpServer { + id: server_id.clone(), + behavior_version: expected_behavior_version.to_string(), + transport: PreparedExternalMcpTransport::Remote { + url: "https://example.test/mcp".to_string(), + headers: BTreeMap::new(), + oauth_enabled: true, + }, + }) + } + + fn watch_roots(&self, _context: &ExternalSourceContext) -> Vec { + vec![ + ExternalWatchRoot { + path: PathBuf::from("/workspace/.opencode"), + recursive: false, + }, + ExternalWatchRoot { + path: PathBuf::from("/workspace/.opencode"), + recursive: true, + }, + ] + } +} + +#[test] +fn coordinator_isolates_failures_keeps_last_success_and_withdraws_stable_deletions() { + let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); + let provider_trait: Arc = provider.clone(); + let mut coordinator = ExternalMcpCoordinator::new(context(), vec![provider_trait]).unwrap(); + + let first = coordinator.refresh(); + assert!(!first.discovery_pending); + assert_eq!(first.servers.len(), 1); + assert_eq!( + first.sources[0].lifecycle, + ExternalSourceLifecycleState::Available + ); + + provider.replace(Err(ExternalSourceProviderError::new( + "fake.mcp.temporarily_unreadable", + "temporary failure", + true, + ))); + let degraded = coordinator.refresh(); + assert_eq!(degraded.servers.len(), 1); + assert_eq!( + degraded.sources[0].lifecycle, + ExternalSourceLifecycleState::UsingLastValidVersion + ); + assert!(degraded + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "fake.mcp.temporarily_unreadable")); + + provider.replace(Ok(ExternalMcpProviderSnapshot { + provider: provider.identity(), + sources: Vec::new(), + servers: Vec::new(), + diagnostics: Vec::new(), + })); + let removed = coordinator.refresh(); + assert!(removed.sources.is_empty()); + assert!(removed.servers.is_empty()); +} + +#[test] +fn coordinator_passes_suppression_to_the_provider_and_guards_preparation_revision() { + let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); + let provider_trait: Arc = provider.clone(); + let mut coordinator = ExternalMcpCoordinator::new(context(), vec![provider_trait]).unwrap(); + let first = coordinator.refresh(); + let server = first.servers[0].clone(); + let source_key = first.sources[0].stable_key.clone(); + + coordinator.set_source_enabled(&source_key, false).unwrap(); + coordinator.refresh(); + assert!(provider + .observed_suppression + .lock() + .unwrap() + .last() + .unwrap() + .contains(&server.id.source)); + + assert!(coordinator + .prepare_server_guarded(&server.id, "behavior-v0") + .is_err()); + coordinator.set_source_enabled(&source_key, true).unwrap(); + coordinator.refresh(); + coordinator + .prepare_server_guarded(&server.id, "behavior-v1") + .expect("current approved revision can be prepared"); +} + +#[test] +fn coordinator_keeps_last_success_for_a_known_unavailable_source() { + let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); + let provider_trait: Arc = provider.clone(); + let mut coordinator = ExternalMcpCoordinator::new(context(), vec![provider_trait]).unwrap(); + let first = coordinator.refresh(); + assert_eq!(first.servers.len(), 1); + + let mut unavailable = source("project"); + unavailable.health = ExternalSourceHealth::Unavailable; + provider.replace(Ok(ExternalMcpProviderSnapshot { + provider: provider.identity(), + sources: vec![unavailable], + servers: Vec::new(), + diagnostics: Vec::new(), + })); + + let degraded = coordinator.refresh(); + assert_eq!(degraded.servers.len(), 1); + assert_eq!( + degraded.sources[0].lifecycle, + ExternalSourceLifecycleState::UsingLastValidVersion + ); + assert!(degraded + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "external_mcp.source_refresh_failed")); +} + +#[test] +fn coordinator_withdraws_merged_server_when_any_provenance_source_is_suppressed() { + let provider = Arc::new(FakeProvider::new(merged_snapshot())); + let provider_trait: Arc = provider.clone(); + let mut coordinator = ExternalMcpCoordinator::new(context(), vec![provider_trait]).unwrap(); + let first = coordinator.refresh(); + assert_eq!(first.servers.len(), 1); + + let global_source = first + .sources + .iter() + .find(|source| source.record.key.source_id.as_str() == "global") + .unwrap(); + coordinator + .set_source_enabled(&global_source.stable_key, false) + .unwrap(); + provider.replace(Err(ExternalSourceProviderError::new( + "fake.mcp.temporarily_unreadable", + "temporary failure", + true, + ))); + + let degraded = coordinator.refresh(); + assert!(degraded.servers.is_empty()); + assert_eq!( + degraded + .sources + .iter() + .find(|source| source.record.key.source_id.as_str() == "global") + .unwrap() + .lifecycle, + ExternalSourceLifecycleState::Suppressed + ); +} + +#[test] +fn coordinator_deduplicates_watch_roots_and_rejects_duplicate_providers() { + let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); + let provider_trait: Arc = provider.clone(); + let coordinator = ExternalMcpCoordinator::new(context(), vec![provider_trait.clone()]).unwrap(); + assert_eq!( + coordinator.watch_roots(), + vec![ExternalWatchRoot { + path: PathBuf::from("/workspace/.opencode"), + recursive: true, + }] + ); + let duplicate = + ExternalMcpCoordinator::new(context(), vec![provider_trait.clone(), provider_trait]); + assert!(duplicate.is_err()); + assert_eq!( + provider.identity().provider_id, + ProviderId::new("fake.mcp").unwrap() + ); +} diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index cff4a472a3..bb7fa207d3 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -5,7 +5,7 @@ //! a concrete ecosystem or carrying arbitrary extension payloads. use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; use std::path::PathBuf; @@ -82,6 +82,7 @@ open_id!(SourceId, "source"); open_id!(CommandLocalId, "command"); open_id!(ToolTargetLocalId, "tool target"); open_id!(ToolExportLocalId, "tool export"); +open_id!(McpServerLocalId, "MCP server"); #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -225,6 +226,480 @@ impl SourceQualifiedToolId { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceQualifiedMcpServerId { + pub source: SourceKey, + pub local_id: McpServerLocalId, +} + +impl SourceQualifiedMcpServerId { + pub fn new( + source: SourceKey, + local_id: impl Into, + ) -> Result { + Ok(Self { + source, + local_id: McpServerLocalId::new(local_id)?, + }) + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.source.stable_key(), + self.local_id.as_str().len(), + self.local_id + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalMcpTransportKind { + LocalStdio, + StreamableHttp, +} + +impl ExternalMcpTransportKind { + pub fn as_str(self) -> &'static str { + match self { + Self::LocalStdio => "local_stdio", + Self::StreamableHttp => "streamable_http", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalMcpStaticStatus { + Ready, + DisabledBySource, + Unsupported { reason: String }, + Invalid { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpServerDefinition { + pub id: SourceQualifiedMcpServerId, + /// Ordered low-to-high source contributions used to materialize this server. + pub provenance: Vec, + /// Logical MCP name used for conflict detection and the public tool namespace. + pub name: String, + pub transport: ExternalMcpTransportKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_preview: Option, + pub argument_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environment_keys: Vec, + /// Names read from BitFun's parent environment while resolving `{env:NAME}` + /// references. Values never enter the static catalog. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environment_reference_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_url_preview: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub header_names: Vec, + pub source_enabled: bool, + pub behavior_version: String, + pub static_status: ExternalMcpStaticStatus, +} + +impl ExternalMcpServerDefinition { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.name, "MCP server name")?; + validate_id(&self.behavior_version, "MCP behavior version")?; + if self.provenance.is_empty() + || self.provenance.len() > 256 + || self.provenance.last() != Some(&self.id.source) + || self.argument_count > 256 + || self.environment_keys.len() > 128 + || self.environment_reference_names.len() > 128 + || self.header_names.len() > 128 + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP configuration size", + )); + } + let mut provenance = BTreeSet::new(); + if self + .provenance + .iter() + .any(|source| !provenance.insert(source)) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP provenance", + )); + } + if let Some(command) = &self.command_preview { + validate_text(command, "MCP command preview")?; + } + if let Some(directory) = &self.working_directory { + validate_text(directory, "MCP working directory")?; + } + if let Some(url) = &self.remote_url_preview { + validate_text(url, "MCP remote URL preview")?; + } + let mut environment_keys = BTreeSet::new(); + for key in &self.environment_keys { + validate_id(key, "MCP environment key")?; + if !environment_keys.insert(key) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP environment key", + )); + } + } + let mut environment_reference_names = BTreeSet::new(); + for name in &self.environment_reference_names { + validate_id(name, "MCP environment reference")?; + if !environment_reference_names.insert(name) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP environment reference", + )); + } + } + let mut header_names = BTreeSet::new(); + for name in &self.header_names { + validate_id(name, "MCP header name")?; + if !header_names.insert(name.to_ascii_lowercase()) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP header name", + )); + } + } + match self.transport { + ExternalMcpTransportKind::LocalStdio + if self.command_preview.is_none() || self.remote_url_preview.is_some() => + { + Err(ExternalSourceContractError::InvalidIdentifier( + "local MCP transport", + )) + } + ExternalMcpTransportKind::StreamableHttp + if self.remote_url_preview.is_none() || self.command_preview.is_some() => + { + Err(ExternalSourceContractError::InvalidIdentifier( + "remote MCP transport", + )) + } + _ => match &self.static_status { + ExternalMcpStaticStatus::Unsupported { reason } + | ExternalMcpStaticStatus::Invalid { reason } => { + validate_text(reason, "MCP static status reason") + } + ExternalMcpStaticStatus::Ready | ExternalMcpStaticStatus::DisabledBySource => { + Ok(()) + } + }, + } + } + + pub fn candidate_id(&self) -> String { + format!("external_mcp:{}", self.id.stable_key()) + } +} + +/// Product-owned state for one discovered external MCP candidate. Ecosystem +/// adapters never set this value; they only provide static definitions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalMcpActivationState { + ApprovalRequired, + Starting, + Active, + Declined, + Conflict, + Covered { selected_candidate_id: String }, + SourceDisabled, + ConfigurationChanged, + Unsupported { reason: String }, + RuntimeUnavailable { reason: String }, + Removed, +} + +/// Sanitized product view for one external MCP server. Runtime credentials are +/// intentionally absent and remain available only through `prepare_server`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpCatalogEntry { + pub candidate_id: String, + pub definition: ExternalMcpServerDefinition, + pub approval_key: String, + pub decision_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + pub activation_state: ExternalMcpActivationState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpApprovalRequest { + pub candidate_id: String, + pub approval_key: String, + pub decision_key: String, + pub definition: ExternalMcpServerDefinition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpConflictCandidate { + pub candidate_id: String, + pub display_name: String, + pub external: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + pub behavior_version: String, + pub available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpConflict { + pub conflict_key: String, + pub server_name: String, + pub candidates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_candidate_id: Option, +} + +/// Secret runtime value whose Debug representation never includes the value. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretValue(String); + +impl SecretValue { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretValue([REDACTED])") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum PreparedExternalMcpTransport { + Local { + command: String, + args: Vec, + environment: BTreeMap, + working_directory: Option, + }, + Remote { + url: String, + headers: BTreeMap, + oauth_enabled: bool, + }, +} + +impl PreparedExternalMcpTransport { + pub fn remote_headers(&self) -> Option<&BTreeMap> { + match self { + Self::Remote { headers, .. } => Some(headers), + Self::Local { .. } => None, + } + } +} + +impl fmt::Debug for PreparedExternalMcpTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local { + command, + args, + environment, + working_directory, + } => formatter + .debug_struct("Local") + .field("command", command) + .field("args", args) + .field("environment_keys", &environment.keys().collect::>()) + .field("working_directory", working_directory) + .finish(), + Self::Remote { + url: _, + headers, + oauth_enabled, + } => formatter + .debug_struct("Remote") + // Query strings and path segments can carry credentials. The + // sanitized product snapshot already owns the safe preview. + .field("url", &"[REDACTED]") + .field("header_names", &headers.keys().collect::>()) + .field("oauth_enabled", oauth_enabled) + .finish(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedExternalMcpServer { + pub id: SourceQualifiedMcpServerId, + pub behavior_version: String, + pub transport: PreparedExternalMcpTransport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl ExternalMcpProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let display_name = display_name.into(); + validate_text(&display_name, "MCP provider display name")?; + Ok(Self { + provider_id: ProviderId::new(provider_id)?, + ecosystem_id: EcosystemId::new(ecosystem_id)?, + display_name, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpProviderSnapshot { + pub provider: ExternalMcpProviderIdentity, + pub sources: Vec, + pub servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalMcpProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + if self.sources.len() > 1024 || self.servers.len() > 1024 { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP provider snapshot size", + )); + } + let mut source_keys = BTreeSet::new(); + for source in &self.sources { + source.validate()?; + if source.key.provider_id != self.provider.provider_id + || source.ecosystem_id != self.provider.ecosystem_id + || !source_keys.insert(source.key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP provider-qualified source", + )); + } + } + let mut server_ids = BTreeSet::new(); + for server in &self.servers { + server.validate()?; + if server.id.source.provider_id != self.provider.provider_id + || !source_keys.contains(&server.id.source) + || server + .provenance + .iter() + .any(|source| !source_keys.contains(source)) + || !server_ids.insert(server.id.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified MCP server", + )); + } + } + Ok(()) + } +} + +/// Capability-specific MCP provider. Discovery is static; preparation may read +/// runtime values only after the product owner has approved a behavior version. +pub trait ExternalMcpSourceProvider: Send + Sync { + fn identity(&self) -> ExternalMcpProviderIdentity; + + fn discover( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result; + + fn prepare_server( + &self, + input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result; + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; +} + +pub fn external_mcp_approval_key( + execution_domain_id: &str, + workspace_key: &str, + server_id: &SourceQualifiedMcpServerId, + behavior_version: &str, +) -> String { + let stable_id = server_id.stable_key(); + let workspace_fingerprint = stable_fingerprint([workspace_key.as_bytes()]); + let server_fingerprint = stable_fingerprint([stable_id.as_bytes()]); + format!( + "external_mcp_approval:{}:{}:{}:{}", + execution_domain_id, + &workspace_fingerprint[..24], + &server_fingerprint[..24], + stable_fingerprint([behavior_version.as_bytes()]) + ) +} + +pub fn external_mcp_conflict_key<'a>( + execution_domain_id: &str, + workspace_key: &str, + server_name: &str, + candidates: impl IntoIterator, +) -> String { + let normalized_name = server_name.to_ascii_lowercase(); + let lineage = external_mcp_conflict_lineage(execution_domain_id, workspace_key, server_name); + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + let encoded = candidates + .into_iter() + .map(|(id, version)| format!("{}:{id}{}:{version}", id.len(), version.len())) + .collect::>(); + let mut parts = vec![ + execution_domain_id.as_bytes(), + workspace_key.as_bytes(), + normalized_name.as_bytes(), + ]; + parts.extend(encoded.iter().map(|value| value.as_bytes())); + format!("{}:{}", lineage, stable_fingerprint(parts)) +} + +fn external_mcp_conflict_lineage( + execution_domain_id: &str, + workspace_key: &str, + server_name: &str, +) -> String { + let workspace_fingerprint = stable_fingerprint([workspace_key.as_bytes()]); + format!( + "external_mcp:{}:{}:{}", + execution_domain_id, + &workspace_fingerprint[..24], + server_name.to_ascii_lowercase() + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -430,6 +905,7 @@ pub enum ExternalSourceAssetKind { Command, Tool, Subagent, + Mcp, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -652,6 +1128,12 @@ pub struct ExternalSourceContext { pub execution_domain_id: ExecutionDomainId, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalMcpDiscoveryInput { + pub context: ExternalSourceContext, + pub suppressed_sources: BTreeSet, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalWatchRoot { pub path: PathBuf, @@ -1003,6 +1485,15 @@ pub struct ExternalSourceCatalogSnapshot { pub tool_approval_requests: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_conflicts: Vec, + /// Independent catalog generation for MCP row/action stability. + #[serde(default)] + pub mcp_generation: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_conflicts: Vec, /// Independent catalog generation for subagent row/action stability. #[serde(default)] pub subagent_generation: u64, diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index f9d34ef0ee..cbc0c993bf 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,12 +1,17 @@ use bitfun_product_domains::external_sources::{ - external_tool_approval_key, external_tool_conflict_key, prompt_command_conflict_key, - EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalSourceAssetKind, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, + external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, + ExpandedPromptCommand, ExternalMcpActivationState, ExternalMcpApprovalRequest, + ExternalMcpCatalogEntry, ExternalMcpConflict, ExternalMcpConflictCandidate, + ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, + ExternalMcpServerDefinition, ExternalMcpStaticStatus, ExternalMcpTransportKind, + ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, - PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, - PromptCommandProviderSnapshot, PromptCommandSourceProvider, SourceKey, - SourceQualifiedCommandId, SourceQualifiedToolId, SourceQualifiedToolTargetId, + PreparedExternalMcpServer, PreparedExternalMcpTransport, PromptCommandAvailability, + PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, + PromptCommandSourceProvider, SecretValue, SourceKey, SourceQualifiedCommandId, + SourceQualifiedMcpServerId, SourceQualifiedToolId, SourceQualifiedToolTargetId, }; use bitfun_product_domains::external_subagents::{ external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, @@ -618,3 +623,226 @@ fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { assert_ne!(first, reordered); assert_ne!(first, updated); } + +#[test] +fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), + header_names: vec!["Authorization".to_string()], + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP servers") + .unwrap(); + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone()], + diagnostics: Vec::new(), + }; + + snapshot.validate().expect("valid MCP provider snapshot"); + let encoded = serde_json::to_string(&snapshot).expect("serialize MCP snapshot"); + assert!(encoded.contains("Authorization")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("mcp.example.com")); + + let prepared = PreparedExternalMcpServer { + id: definition.id, + behavior_version: definition.behavior_version, + transport: PreparedExternalMcpTransport::Remote { + url: "https://mcp.example.com/mcp?token=url-secret".to_string(), + headers: [( + "Authorization".to_string(), + SecretValue::new("Bearer secret"), + )] + .into_iter() + .collect(), + oauth_enabled: true, + }, + }; + assert_eq!( + prepared.transport.remote_headers().unwrap()["Authorization"].expose(), + "Bearer secret" + ); + assert!(!format!("{prepared:?}").contains("Bearer secret")); + assert!(!format!("{prepared:?}").contains("url-secret")); +} + +#[test] +fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP").unwrap(); + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("/workspace".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone(), definition], + diagnostics: Vec::new(), + }; + + assert!(snapshot.validate().is_err()); + + let input = ExternalMcpDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("opencode.mcp", "suppressed").unwrap()] + .into_iter() + .collect(), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_mcp_decisions_change_only_with_behavior_domain_or_conflict_participants() { + let id = SourceQualifiedMcpServerId::new( + SourceKey::new("opencode.mcp", "project-config").unwrap(), + "github", + ) + .unwrap(); + let first = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let same = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let updated = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v2"); + let other_workspace = + external_mcp_approval_key("local-user", "/workspace-b", &id, "behavior-v1"); + let remote = external_mcp_approval_key("remote-user", "/workspace-a", &id, "behavior-v1"); + assert_eq!(first, same); + assert_ne!(first, updated); + assert_ne!(first, other_workspace); + assert_ne!(first, remote); + + let stable_id = id.stable_key(); + let conflict = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ); + let reordered = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "GITHUB", + [ + (stable_id.as_str(), "behavior-v1"), + ("bitfun:github", "native-v1"), + ], + ); + let participant_updated = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v2"), + ], + ); + assert_eq!(conflict, reordered); + assert_ne!(conflict, participant_updated); + assert_ne!( + conflict, + external_mcp_conflict_key( + "local-user", + "/workspace-b", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ) + ); +} + +#[test] +fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fields() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let entry = ExternalMcpCatalogEntry { + candidate_id: definition.candidate_id(), + definition: definition.clone(), + approval_key: "external_mcp_approval:local-user:v1".to_string(), + decision_key: "external_mcp_approval:local-user:v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::ApprovalRequired, + }; + let request = ExternalMcpApprovalRequest { + candidate_id: entry.candidate_id.clone(), + approval_key: entry.approval_key.clone(), + decision_key: entry.decision_key.clone(), + definition, + }; + let conflict = ExternalMcpConflict { + conflict_key: "external_mcp:local-user:github:v1".to_string(), + server_name: "github".to_string(), + candidates: vec![ + ExternalMcpConflictCandidate { + candidate_id: "native_mcp:github".to_string(), + display_name: "BitFun: github".to_string(), + external: false, + source: None, + behavior_version: "native-v1".to_string(), + available: true, + unavailable_reason: None, + }, + ExternalMcpConflictCandidate { + candidate_id: entry.candidate_id.clone(), + display_name: "OpenCode: github".to_string(), + external: true, + source: Some(entry.definition.id.source.clone()), + behavior_version: entry.definition.behavior_version.clone(), + available: true, + unavailable_reason: None, + }, + ], + selected_candidate_id: None, + }; + + let encoded = serde_json::to_string(&(entry, request, conflict)).unwrap(); + assert!(encoded.contains("GITHUB_TOKEN")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("approval_required")); +} diff --git a/src/crates/interfaces/acp/src/runtime/mcp.rs b/src/crates/interfaces/acp/src/runtime/mcp.rs index 1d9df56bf4..f864a719c9 100644 --- a/src/crates/interfaces/acp/src/runtime/mcp.rs +++ b/src/crates/interfaces/acp/src/runtime/mcp.rs @@ -151,6 +151,8 @@ fn stdio_server_config(acp_session_id: &str, server: McpServerStdio) -> Result Result Vec { command, args, env, + working_directory: None, + inherit_parent_environment: None, headers, url, auto_start, @@ -231,6 +233,7 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { .get("oauth") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + oauth_enabled: None, xaa: obj .get("xaa") .cloned() diff --git a/src/crates/services/services-integrations/src/mcp/server/mod.rs b/src/crates/services/services-integrations/src/mcp/server/mod.rs index 7a2471da95..d3c5bc5cc7 100644 --- a/src/crates/services/services-integrations/src/mcp/server/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/server/mod.rs @@ -118,6 +118,13 @@ pub struct MCPServerConfig { pub args: Vec, #[serde(default)] pub env: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + /// Whether a local process inherits every environment variable from the + /// BitFun process. `None` preserves the legacy behavior for native MCP + /// configurations; imported executable configuration can opt out. + #[serde(skip_serializing_if = "Option::is_none")] + pub inherit_parent_environment: Option, /// Additional HTTP headers for remote MCP servers (Cursor-style `headers`). #[serde(default)] pub headers: HashMap, @@ -134,6 +141,10 @@ pub struct MCPServerConfig { pub settings: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub oauth: Option, + /// Explicit OAuth discovery policy. `None` preserves the legacy behavior + /// where remote servers may negotiate OAuth after an authentication error. + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub xaa: Option, } @@ -164,6 +175,14 @@ impl fmt::Display for MCPServerConfigValidationError { impl std::error::Error for MCPServerConfigValidationError {} impl MCPServerConfig { + pub fn remote_oauth_enabled(&self) -> bool { + self.oauth_enabled.unwrap_or(true) + } + + pub fn inherits_parent_environment(&self) -> bool { + self.inherit_parent_environment.unwrap_or(true) + } + pub fn resolved_transport(&self) -> MCPServerTransport { self.transport.unwrap_or(match self.server_type { MCPServerType::Local => MCPServerTransport::Stdio, @@ -187,6 +206,12 @@ impl MCPServerConfig { let transport = self.resolved_transport(); match self.server_type { MCPServerType::Local => { + if self.oauth_enabled.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Local MCP server '{}' cannot configure OAuth", + self.id + ))); + } if self.command.is_none() { return Err(MCPServerConfigValidationError::new(format!( "Local MCP server '{}' must have a command", @@ -201,8 +226,28 @@ impl MCPServerConfig { transport.as_str() ))); } + if self.working_directory.as_deref().is_some_and(|directory| { + directory.trim().is_empty() || directory.chars().any(char::is_control) + }) { + return Err(MCPServerConfigValidationError::new(format!( + "Local MCP server '{}' has an invalid working directory", + self.id + ))); + } } MCPServerType::Remote => { + if self.inherit_parent_environment.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Remote MCP server '{}' cannot configure process environment inheritance", + self.id + ))); + } + if self.working_directory.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Remote MCP server '{}' cannot set a working directory", + self.id + ))); + } if self.url.is_none() { return Err(MCPServerConfigValidationError::new(format!( "Remote MCP server '{}' must have a URL", diff --git a/src/crates/services/services-integrations/src/mcp/server/process.rs b/src/crates/services/services-integrations/src/mcp/server/process.rs index 7ca867c913..b13153ac37 100644 --- a/src/crates/services/services-integrations/src/mcp/server/process.rs +++ b/src/crates/services/services-integrations/src/mcp/server/process.rs @@ -31,6 +31,7 @@ pub struct MCPServerProcess { last_ping_time: Arc>>, last_error_message: Arc>>, message_rx: Option>, + remote_url: Option, #[cfg(test)] fail_next_stop: bool, } @@ -53,6 +54,7 @@ impl MCPServerProcess { last_ping_time: Arc::new(RwLock::new(None)), last_error_message: Arc::new(RwLock::new(None)), message_rx: None, + remote_url: None, #[cfg(test)] fail_next_stop: false, } @@ -69,6 +71,29 @@ impl MCPServerProcess { command: &str, args: &[String], env: &std::collections::HashMap, + ) -> MCPRuntimeResult<()> { + self.start_in_directory(command, args, env, None).await + } + + /// Starts the server process in an explicitly selected working directory. + pub async fn start_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> MCPRuntimeResult<()> { + self.start_with_environment_policy(command, args, env, working_directory, true) + .await + } + + pub async fn start_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, ) -> MCPRuntimeResult<()> { info!("Starting MCP server: name={} id={}", self.name, self.id); self.set_status(MCPServerStatus::Starting).await; @@ -95,7 +120,18 @@ impl MCPServerProcess { let mut cmd = process_manager::create_tokio_command(&final_command); cmd.args(&final_args); + if !inherit_parent_environment { + cmd.env_clear(); + for key in safe_process_environment_keys() { + if let Some(value) = std::env::var_os(key) { + cmd.env(key, value); + } + } + } cmd.envs(env); + if let Some(working_directory) = working_directory { + cmd.current_dir(working_directory); + } cmd.stdin(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -183,13 +219,13 @@ impl MCPServerProcess { ))); } info!( - "Starting remote MCP server: name={} id={} transport={} url={}", + "Starting remote MCP server: name={} id={} transport={}", self.name, self.id, - transport.as_str(), - url + transport.as_str() ); self.set_status(MCPServerStatus::Starting).await; + self.remote_url = Some(url.to_string()); let merged_headers = merge_mcp_remote_headers(&config.headers, &config.env); @@ -199,30 +235,37 @@ impl MCPServerProcess { &self.id, url.to_string(), merged_headers, - true, + config.remote_oauth_enabled(), ) - .await?, + .await + .map_err(|error| { + MCPRuntimeError::mcp(redact_sensitive_value(&error.to_string(), Some(url))) + })?, ); self.connection = Some(connection.clone()); self.start_time = Some(Instant::now()); if let Err(e) = self.handshake().await { + let redacted_error = redact_sensitive_value(&e.to_string(), Some(url)); error!( - "Remote MCP server handshake failed: name={} id={} url={} error={}", - self.name, self.id, url, e + "Remote MCP server handshake failed: name={} id={} error={}", + self.name, self.id, redacted_error ); self.connection = None; self.message_rx = None; self.child = None; self.server_info = None; - if is_mcp_auth_error_message(&e.to_string()) { - self.set_status_with_error(MCPServerStatus::NeedsAuth, Some(e.to_string())) - .await; + if is_mcp_auth_error_message(&redacted_error) { + self.set_status_with_error( + MCPServerStatus::NeedsAuth, + Some(redacted_error.clone()), + ) + .await; } else { - self.set_status_with_error(MCPServerStatus::Failed, Some(e.to_string())) + self.set_status_with_error(MCPServerStatus::Failed, Some(redacted_error.clone())) .await; } - return Err(e); + return Err(MCPRuntimeError::mcp(redacted_error)); } self.set_status_with_error(MCPServerStatus::Connected, None) @@ -306,6 +349,28 @@ impl MCPServerProcess { command: &str, args: &[String], env: &std::collections::HashMap, + ) -> MCPRuntimeResult<()> { + self.restart_in_directory(command, args, env, None).await + } + + pub async fn restart_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> MCPRuntimeResult<()> { + self.restart_with_environment_policy(command, args, env, working_directory, true) + .await + } + + pub async fn restart_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, ) -> MCPRuntimeResult<()> { if self.restart_count >= self.max_restarts { error!( @@ -334,7 +399,14 @@ impl MCPServerProcess { self.stop().await?; tokio::time::sleep(Duration::from_secs(1)).await; - self.start(command, args, env).await + self.start_with_environment_policy( + command, + args, + env, + working_directory, + inherit_parent_environment, + ) + .await } /// Sets status. @@ -377,6 +449,7 @@ impl MCPServerProcess { let connection = self.connection.clone(); let interval = self.health_check_interval; let server_name = self.name.clone(); + let remote_url = self.remote_url.clone(); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -404,16 +477,18 @@ impl MCPServerProcess { *last_error_message.write().await = None; } Err(e) => { + let redacted_error = + redact_sensitive_value(&e.to_string(), remote_url.as_deref()); warn!( "Health check failed: server_name={} error={}", - server_name, e + server_name, redacted_error ); - if is_mcp_auth_error_message(&e.to_string()) { + if is_mcp_auth_error_message(&redacted_error) { *status.write().await = MCPServerStatus::NeedsAuth; } else { *status.write().await = MCPServerStatus::Reconnecting; } - *last_error_message.write().await = Some(e.to_string()); + *last_error_message.write().await = Some(redacted_error); } } } else { @@ -444,6 +519,59 @@ impl MCPServerProcess { } } +#[cfg(windows)] +fn safe_process_environment_keys() -> &'static [&'static str] { + &[ + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "PATH", + "TEMP", + "TMP", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + ] +} + +fn redact_sensitive_value(message: &str, sensitive_value: Option<&str>) -> String { + sensitive_value + .filter(|value| !value.is_empty()) + .map(|value| message.replace(value, "")) + .unwrap_or_else(|| message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{redact_sensitive_value, safe_process_environment_keys}; + + #[test] + fn isolated_environment_excludes_common_secret_variables() { + let keys = safe_process_environment_keys(); + assert!(!keys.contains(&"OPENAI_API_KEY")); + assert!(!keys.contains(&"ANTHROPIC_API_KEY")); + assert!(keys.contains(&"PATH")); + } + + #[test] + fn remote_errors_do_not_expose_the_configured_url() { + let url = "https://mcp.example.test/path?token=secret"; + let error = format!("request to {url} failed"); + let redacted = redact_sensitive_value(&error, Some(url)); + + assert!(!redacted.contains("secret")); + assert!(redacted.contains("")); + } +} + +#[cfg(not(windows))] +fn safe_process_environment_keys() -> &'static [&'static str] { + &[ + "PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL", + ] +} + impl Drop for MCPServerProcess { fn drop(&mut self) { if let Some(mut child) = self.child.take() { diff --git a/src/crates/services/services-integrations/src/mcp/server/registry.rs b/src/crates/services/services-integrations/src/mcp/server/registry.rs index 51a57995c8..6aa8476ec0 100644 --- a/src/crates/services/services-integrations/src/mcp/server/registry.rs +++ b/src/crates/services/services-integrations/src/mcp/server/registry.rs @@ -206,6 +206,8 @@ mod tests { command: Some("node".to_string()), args: Vec::new(), env: HashMap::new(), + working_directory: None, + inherit_parent_environment: None, headers: HashMap::new(), url: None, auto_start: false, @@ -214,6 +216,7 @@ mod tests { capabilities: Vec::new(), settings: HashMap::new(), oauth: None, + oauth_enabled: None, xaa: None, } } diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index 87b9b17af6..74b00b0570 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -55,6 +55,8 @@ fn make_mcp_config( command: command.map(str::to_string), args: Vec::new(), env: HashMap::new(), + working_directory: None, + inherit_parent_environment: None, headers: HashMap::new(), url: url.map(str::to_string), auto_start: true, @@ -63,6 +65,7 @@ fn make_mcp_config( capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, } } @@ -1571,6 +1574,8 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { command: Some("npx".to_string()), args: vec!["server".to_string()], env: Default::default(), + working_directory: None, + inherit_parent_environment: None, headers: Default::default(), url: None, auto_start: true, @@ -1579,6 +1584,7 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, }; assert_eq!(local.resolved_transport(), MCPServerTransport::Stdio); @@ -1605,6 +1611,86 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { .expect("remote streamable-http config is valid"); } +#[test] +fn mcp_server_config_preserves_an_optional_local_working_directory() { + let config: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "external-local", + "name": "External local", + "type": "local", + "command": "node", + "args": ["server.js"], + "workingDirectory": "C:/workspace/project", + "autoStart": true, + "enabled": true, + "location": "built-in", + "capabilities": [], + "settings": {} + })) + .unwrap(); + + assert_eq!( + config.working_directory.as_deref(), + Some("C:/workspace/project") + ); + assert_eq!( + serde_json::to_value(config).unwrap()["workingDirectory"], + "C:/workspace/project" + ); +} + +#[test] +fn remote_mcp_oauth_can_be_explicitly_disabled_without_changing_legacy_default() { + let disabled: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "remote-static-auth", + "name": "Remote static auth", + "type": "remote", + "transport": "streamable-http", + "url": "https://example.test/mcp", + "oauthEnabled": false, + "location": "built-in" + })) + .unwrap(); + assert!(!disabled.remote_oauth_enabled()); + + let legacy: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "remote-legacy", + "name": "Remote legacy", + "type": "remote", + "transport": "streamable-http", + "url": "https://example.test/mcp", + "location": "user" + })) + .unwrap(); + assert!(legacy.remote_oauth_enabled()); +} + +#[test] +fn local_mcp_can_disable_parent_environment_inheritance_without_changing_legacy_default() { + let restricted: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "external-local", + "name": "External local", + "type": "local", + "command": "node", + "args": [], + "env": {"ALLOWED_TOKEN": "explicit"}, + "inheritParentEnvironment": false, + "autoStart": true, + "enabled": true, + "location": "built-in" + })) + .unwrap(); + assert!(!restricted.inherits_parent_environment()); + + let legacy = make_mcp_config( + "legacy-local", + ConfigLocation::User, + MCPServerType::Local, + Some("node"), + None, + ); + assert!(legacy.inherits_parent_environment()); +} + #[test] fn mcp_oauth_session_snapshot_preserves_camel_case_status_contract() { let snapshot = MCPRemoteOAuthSessionSnapshot::new( @@ -1677,6 +1763,8 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { command: None, args: Vec::new(), env: Default::default(), + working_directory: None, + inherit_parent_environment: None, headers: std::collections::HashMap::from([( "Authorization".to_string(), "Bearer token".to_string(), @@ -1688,6 +1776,7 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, }; diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 7e69214218..d22b587097 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -33,7 +33,7 @@ export interface ExternalSourceRecord { contentVersion: string; diagnostics?: Array<{ severity: string; - assetKind?: 'source' | 'command' | 'tool' | 'subagent'; + assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp'; code: string; message: string; }>; @@ -78,6 +78,10 @@ export interface ExternalSourceCatalogSnapshot { tools?: ExternalToolCatalogEntry[]; toolApprovalRequests?: ExternalToolApprovalRequest[]; toolConflicts?: ExternalToolConflict[]; + mcpGeneration?: number; + mcpServers?: ExternalMcpCatalogEntry[]; + mcpApprovalRequests?: ExternalMcpApprovalRequest[]; + mcpConflicts?: ExternalMcpConflict[]; subagentGeneration?: number; preferenceRevision?: number; subagents?: ExternalSubagentSummary[]; @@ -85,7 +89,7 @@ export interface ExternalSourceCatalogSnapshot { pendingSubagentApprovals?: string[]; diagnostics?: Array<{ severity: string; - assetKind?: 'source' | 'command' | 'tool' | 'subagent'; + assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp'; code: string; message: string; }>; @@ -201,6 +205,74 @@ export interface ExternalToolConflict { }>; } +export type ExternalMcpActivation = + | { state: 'approval_required' } + | { state: 'starting' } + | { state: 'active' } + | { state: 'declined' } + | { state: 'conflict' } + | { state: 'covered'; selected_candidate_id: string } + | { state: 'source_disabled' } + | { state: 'configuration_changed' } + | { state: 'unsupported'; reason: string } + | { state: 'runtime_unavailable'; reason: string } + | { state: 'removed' }; + +export interface ExternalMcpDefinition { + id: { + source: { providerId: string; sourceId: string }; + localId: string; + }; + provenance: Array<{ providerId: string; sourceId: string }>; + name: string; + transport: 'local_stdio' | 'streamable_http'; + commandPreview?: string; + argumentCount: number; + workingDirectory?: string; + environmentKeys: string[]; + environmentReferenceNames?: string[]; + remoteUrlPreview?: string; + headerNames: string[]; + sourceEnabled: boolean; + behaviorVersion: string; + staticStatus: + | { state: 'ready' } + | { state: 'disabled_by_source' } + | { state: 'unsupported'; reason: string } + | { state: 'invalid'; reason: string }; +} + +export interface ExternalMcpCatalogEntry { + candidateId: string; + definition: ExternalMcpDefinition; + approvalKey: string; + decisionKey: string; + runtimeId?: string; + activationState: ExternalMcpActivation; +} + +export interface ExternalMcpApprovalRequest { + candidateId: string; + approvalKey: string; + decisionKey: string; + definition: ExternalMcpDefinition; +} + +export interface ExternalMcpConflict { + conflictKey: string; + serverName: string; + selectedCandidateId?: string; + candidates: Array<{ + candidateId: string; + displayName: string; + external: boolean; + source?: { providerId: string; sourceId: string }; + behaviorVersion: string; + available: boolean; + unavailableReason?: string; + }>; +} + export const externalSourcesAPI = { getSnapshot(workspacePath?: string, forceRefresh = false) { return api.invoke('get_external_source_snapshot', { @@ -280,4 +352,44 @@ export const externalSourcesAPI = { }, }); }, + + setMcpServerDecision( + workspacePath: string | undefined, + candidateId: string, + decisionKey: string, + approved: boolean, + expectedMcpGeneration: number, + expectedPreferenceRevision: number, + ) { + return api.invoke('set_external_mcp_server_decision_command', { + request: { + workspacePath, + candidateId, + decisionKey, + approved, + expectedMcpGeneration, + expectedPreferenceRevision, + }, + }); + }, + + chooseMcpConflict( + workspacePath: string | undefined, + conflictKey: string, + candidateId: string, + approveExternal: boolean, + expectedMcpGeneration: number, + expectedPreferenceRevision: number, + ) { + return api.invoke('choose_external_mcp_conflict_command', { + request: { + workspacePath, + conflictKey, + candidateId, + approveExternal, + expectedMcpGeneration, + expectedPreferenceRevision, + }, + }); + }, }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 813fe114a0..a5ddb5155a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -12,6 +12,8 @@ const setToolTargetDecisionMock = vi.hoisted(() => vi.fn()); const setToolConflictChoiceMock = vi.hoisted(() => vi.fn()); const setSubagentActivationMock = vi.hoisted(() => vi.fn()); const chooseSubagentConflictMock = vi.hoisted(() => vi.fn()); +const setMcpServerDecisionMock = vi.hoisted(() => vi.fn()); +const chooseMcpConflictMock = vi.hoisted(() => vi.fn()); const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project' })); vi.mock('react-i18next', () => ({ @@ -43,6 +45,8 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ setToolConflictChoice: setToolConflictChoiceMock, setSubagentActivation: setSubagentActivationMock, chooseSubagentConflict: chooseSubagentConflictMock, + setMcpServerDecision: setMcpServerDecisionMock, + chooseMcpConflict: chooseMcpConflictMock, }, })); @@ -119,6 +123,8 @@ describe('ExternalSourcesConfig', () => { setToolConflictChoiceMock.mockResolvedValue(snapshot); setSubagentActivationMock.mockResolvedValue(snapshot); chooseSubagentConflictMock.mockResolvedValue(snapshot); + setMcpServerDecisionMock.mockResolvedValue(snapshot); + chooseMcpConflictMock.mockResolvedValue(snapshot); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -165,6 +171,164 @@ describe('ExternalSourcesConfig', () => { ); }); + it('reviews MCP risk and binds approval and conflict choices to the visible versions', async () => { + const mcpSnapshot = { + ...snapshot, + commandConflicts: [], + preferenceRevision: 9, + mcpGeneration: 5, + sources: [{ + ...snapshot.sources[0], + stableKey: 'opencode-mcp-project', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.mcp', sourceId: 'project' }, + displayName: 'OpenCode project MCP', + sourceKind: 'opencode_mcp_config', + location: '/opencode.json', + }, + }], + mcpServers: [{ + candidateId: 'external-mcp-github', + decisionKey: 'mcp-decision-v1', + definition: { + id: { + source: { providerId: 'opencode.mcp', sourceId: 'project' }, + localId: 'github', + }, + provenance: [{ providerId: 'opencode.mcp', sourceId: 'project' }], + name: 'github', + transport: 'local_stdio', + commandPreview: 'npx', + argumentCount: 2, + workingDirectory: '', + environmentKeys: ['GITHUB_TOKEN'], + environmentReferenceNames: ['OPENCODE_TOKEN'], + headerNames: [], + sourceEnabled: true, + behaviorVersion: 'behavior-v1', + staticStatus: { state: 'ready' }, + }, + activationState: { state: 'approval_required' }, + }], + mcpApprovalRequests: [{ + candidateId: 'external-mcp-github', + approvalKey: 'mcp-approval-v1', + decisionKey: 'mcp-decision-v1', + definition: { + id: { + source: { providerId: 'opencode.mcp', sourceId: 'project' }, + localId: 'github', + }, + provenance: [{ providerId: 'opencode.mcp', sourceId: 'project' }], + name: 'github', + transport: 'local_stdio', + commandPreview: 'npx', + argumentCount: 2, + workingDirectory: '', + environmentKeys: ['GITHUB_TOKEN'], + environmentReferenceNames: ['OPENCODE_TOKEN'], + headerNames: [], + sourceEnabled: true, + behaviorVersion: 'behavior-v1', + staticStatus: { state: 'ready' }, + }, + }], + mcpConflicts: [{ + conflictKey: 'mcp-conflict-v1', + serverName: 'github', + candidates: [{ + candidateId: 'native-mcp-github', + displayName: 'BitFun: github', + external: false, + behaviorVersion: 'native-v1', + available: true, + }, { + candidateId: 'external-mcp-github', + displayName: 'OpenCode: github', + external: true, + behaviorVersion: 'behavior-v1', + available: true, + }], + }], + }; + getSnapshotMock.mockResolvedValue(mcpSnapshot); + setMcpServerDecisionMock.mockResolvedValue({ + ...mcpSnapshot, + mcpApprovalRequests: [], + }); + chooseMcpConflictMock.mockResolvedValue({ + ...mcpSnapshot, + mcpConflicts: [{ + ...mcpSnapshot.mcpConflicts[0], + selectedCandidateId: 'native-mcp-github', + }], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('mcpApprovals.warning'); + expect(container.textContent).toContain('OpenCode project MCP'); + expect(container.textContent).not.toContain('D:/workspace/project/opencode.json'); + expect(container.textContent).toContain('mcp.command:{"command":"npx"}'); + expect(container.textContent).toContain('mcp.workingDirectory:{"location":""}'); + expect(container.textContent).toContain('GITHUB_TOKEN'); + expect(container.textContent).toContain('OPENCODE_TOKEN'); + + const externalConflictCandidate = Array.from( + container.querySelectorAll('.bitfun-external-sources-config__candidate'), + ).find((candidate) => candidate.textContent?.includes('OpenCode: github')); + expect(externalConflictCandidate?.textContent).toContain('mcpConflicts.review'); + expect(externalConflictCandidate?.textContent).not.toContain('mcp.argumentCount'); + + const reviewExternalCandidate = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('mcpConflicts.review')); + await act(async () => reviewExternalCandidate?.click()); + expect(chooseMcpConflictMock).not.toHaveBeenCalled(); + expect(externalConflictCandidate?.textContent).toContain('mcp.argumentCount:{"count":2}'); + expect(externalConflictCandidate?.textContent).toContain('mcp.scope'); + expect(externalConflictCandidate?.textContent).toContain('mcpApprovals.warning'); + + const approveExternalCandidate = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('mcpConflicts.approveAndUse')); + await act(async () => approveExternalCandidate?.click()); + expect(chooseMcpConflictMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'mcp-conflict-v1', + 'external-mcp-github', + true, + 5, + 9, + ); + + const enable = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('mcpApprovals.enable')); + await act(async () => enable?.click()); + expect(setMcpServerDecisionMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'external-mcp-github', + 'mcp-decision-v1', + true, + 5, + 9, + ); + + const nativeCandidate = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('BitFun: github')); + await act(async () => nativeCandidate?.click()); + expect(chooseMcpConflictMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'mcp-conflict-v1', + 'native-mcp-github', + false, + 5, + 9, + ); + }); + it('keeps remembered command, tool, and agent choices visible and changeable', async () => { const resolvedSnapshot = { ...snapshot, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 70abf73f85..e7889b672b 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -133,6 +133,8 @@ const ExternalSourcesConfig: React.FC = () => { const [busyKey, setBusyKey] = useState(null); const [reviewingToolKey, setReviewingToolKey] = useState(null); const [reviewingAgentKey, setReviewingAgentKey] = useState(null); + const [reviewingMcpKey, setReviewingMcpKey] = useState(null); + const [reviewingMcpConflictKey, setReviewingMcpConflictKey] = useState(null); const [error, setError] = useState(null); const [operationStatus, setOperationStatus] = useState(null); const [agentChangeNotice, setAgentChangeNotice] = useState(null); @@ -285,6 +287,8 @@ const ExternalSourcesConfig: React.FC = () => { setBusyKey(null); setReviewingToolKey(null); setReviewingAgentKey(null); + setReviewingMcpKey(null); + setReviewingMcpConflictKey(null); setLoading(desktopRuntime && !remoteWorkspace); void loadSnapshot(false, false); if (!desktopRuntime || remoteWorkspace) return undefined; @@ -348,6 +352,16 @@ const ExternalSourcesConfig: React.FC = () => { return counts; }, [snapshot]); + const mcpCounts = useMemo(() => { + const counts = new Map(); + for (const server of snapshot?.mcpServers ?? []) { + const source = server.definition.id.source; + const key = `${source.providerId}\u0000${source.sourceId}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }, [snapshot?.mcpServers]); + const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), [snapshot?.commandConflicts], @@ -363,6 +377,11 @@ const ExternalSourcesConfig: React.FC = () => { [snapshot?.subagentConflicts], ); + const mcpConflicts = useMemo( + () => unresolvedFirst(snapshot?.mcpConflicts ?? []), + [snapshot?.mcpConflicts], + ); + const runMutation = useCallback(async ( mutationKey: string, request: () => Promise, @@ -496,6 +515,54 @@ const ExternalSourcesConfig: React.FC = () => { if (accepted) await loadSnapshot(true, false); }, [loadSnapshot, runMutation, snapshot, t, workspacePath]); + const decideMcpServer = useCallback(async ( + candidateId: string, + decisionKey: string, + approved: boolean, + ) => { + if (!snapshot) return false; + const accepted = await runMutation( + decisionKey, + () => externalSourcesAPI.setMcpServerDecision( + workspacePath, + candidateId, + decisionKey, + approved, + snapshot.mcpGeneration ?? 0, + snapshot.preferenceRevision ?? 0, + ), + true, + 'all', + t('actions.mcpUpdated'), + ); + if (accepted) await loadSnapshot(true, false); + return accepted; + }, [loadSnapshot, runMutation, snapshot, t, workspacePath]); + + const chooseMcpConflict = useCallback(async ( + conflictKey: string, + candidateId: string, + approveExternal: boolean, + ) => { + if (!snapshot) return false; + const accepted = await runMutation( + conflictKey, + () => externalSourcesAPI.chooseMcpConflict( + workspacePath, + conflictKey, + candidateId, + approveExternal, + snapshot.mcpGeneration ?? 0, + snapshot.preferenceRevision ?? 0, + ), + true, + 'all', + t('actions.mcpUpdated'), + ); + if (accepted) await loadSnapshot(true, false); + return accepted; + }, [loadSnapshot, runMutation, snapshot, t, workspacePath]); + if (loading && !snapshot) { return ; } @@ -594,6 +661,378 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} + {(snapshot?.mcpApprovalRequests?.length ?? 0) > 0 ? ( + + {snapshot?.mcpApprovalRequests?.map((request) => { + const source = snapshot.sources.find((candidate) => ( + candidate.record.key.providerId === request.definition.id.source.providerId + && candidate.record.key.sourceId === request.definition.id.source.sourceId + )); + return ( +
+
+ {request.definition.name} +
+
+ {t('mcp.source', { + source: source?.record.displayName ?? t('mcp.externalSource'), + })} + {source ? ( + {t('mcp.sourceLocation', { + location: source.record.location, + })} + ) : null} + {source ? ( + {t('mcp.scope', { + scope: source.record.scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${source.record.scope}`), + })} + ) : null} + {t(`mcp.transport.${request.definition.transport}`)} + {request.definition.commandPreview ? ( + {t('mcp.command', { command: request.definition.commandPreview })} + ) : null} + {request.definition.remoteUrlPreview ? ( + {t('mcp.url', { url: request.definition.remoteUrlPreview })} + ) : null} + {request.definition.workingDirectory ? ( + {t('mcp.workingDirectory', { + location: request.definition.workingDirectory, + })} + ) : null} + {t('mcp.argumentCount', { + count: request.definition.argumentCount, + })} + {t('mcp.environmentCount', { + count: request.definition.environmentKeys.length, + })} + {request.definition.environmentKeys.length > 0 ? ( + {t('mcp.environmentNames', { + names: request.definition.environmentKeys.join(', '), + })} + ) : null} + {(request.definition.environmentReferenceNames?.length ?? 0) > 0 ? ( + {t('mcp.environmentReads', { + names: (request.definition.environmentReferenceNames ?? []).join(', '), + })} + ) : null} + {t('mcp.headerCount', { + count: request.definition.headerNames.length, + })} + {request.definition.headerNames.length > 0 ? ( + {t('mcp.headerNames', { + names: request.definition.headerNames.join(', '), + })} + ) : null} +
+
+ {t('mcpApprovals.warning')} +
+
+ + +
+
+ ); + })} +
+ ) : null} + + {(snapshot?.mcpServers?.length ?? 0) > 0 ? ( + + {snapshot?.mcpServers?.map((server) => { + const state = server.activationState.state; + const reviewing = reviewingMcpKey === server.candidateId; + const canEnable = state === 'declined' || state === 'configuration_changed'; + const canDisable = ['starting', 'active', 'runtime_unavailable'].includes(state); + return ( + + +
+ + {t(`mcpState.${state}`)} + + + {canDisable ? ( + + ) : null} +
+
+ {reviewing ? ( +
+
+ {server.definition.commandPreview ? ( + {t('mcp.command', { command: server.definition.commandPreview })} + ) : null} + {server.definition.remoteUrlPreview ? ( + {t('mcp.url', { url: server.definition.remoteUrlPreview })} + ) : null} + {server.definition.workingDirectory ? ( + {t('mcp.workingDirectory', { + location: server.definition.workingDirectory, + })} + ) : null} + {t('mcp.argumentCount', { + count: server.definition.argumentCount, + })} + {(server.definition.environmentReferenceNames?.length ?? 0) > 0 ? ( + {t('mcp.environmentReads', { + names: (server.definition.environmentReferenceNames ?? []).join(', '), + })} + ) : null} + {'reason' in server.activationState ? ( + <> + {t(server.activationState.state === 'runtime_unavailable' + ? 'mcp.runtimeUnavailableGuidance' + : 'mcp.unsupportedGuidance')} +
+ {t('common.technicalDetails')} + {server.activationState.reason} +
+ + ) : null} + {t('mcp.changePolicy')} +
+ {canEnable ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); + })} +
+ ) : null} + + {mcpConflicts.length > 0 ? ( + + {mcpConflicts.map((conflict) => ( +
+
+ {t('mcpConflicts.serverName', { name: conflict.serverName })} +
+
+ {conflict.candidates.map((candidate) => { + const selected = conflict.selectedCandidateId === candidate.candidateId; + const externalServer = candidate.external + ? snapshot?.mcpServers?.find((server) => ( + server.candidateId === candidate.candidateId + )) + : undefined; + const externalSource = externalServer + ? snapshot?.sources?.find((source) => ( + source.record.key.providerId + === externalServer.definition.id.source.providerId + && source.record.key.sourceId + === externalServer.definition.id.source.sourceId + )) + : undefined; + const conflictReviewKey = `${conflict.conflictKey}:${candidate.candidateId}`; + const reviewingExternal = reviewingMcpConflictKey === conflictReviewKey; + const detailId = `mcp-conflict-detail-${candidate.candidateId.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + return ( +
+ + + {!candidate.available + ? t(candidate.external + ? 'mcpConflicts.unavailable' + : 'mcpConflicts.nativeDisabled') + : selected + ? t('common.selected') + : t('common.availableChoice')} + + {!candidate.available && candidate.unavailableReason ? ( + + {candidate.unavailableReason} + + ) : null} + {externalServer && (reviewingExternal || selected) ? ( +
+ {t('mcp.source', { + source: externalSource?.record.displayName + ?? t('mcp.externalSource'), + })} + {externalSource ? ( + <> + {t('mcp.sourceLocation', { + location: externalSource.record.location, + })} + {t('mcp.scope', { + scope: t(`scope.${externalSource.record.scope}`), + })} + + ) : null} + {t(`mcp.transport.${externalServer.definition.transport}`)} + {externalServer.definition.commandPreview ? ( + {t('mcp.command', { + command: externalServer.definition.commandPreview, + })} + ) : null} + {externalServer.definition.remoteUrlPreview ? ( + {t('mcp.url', { + url: externalServer.definition.remoteUrlPreview, + })} + ) : null} + {t('mcp.argumentCount', { + count: externalServer.definition.argumentCount, + })} + {externalServer.definition.workingDirectory ? ( + {t('mcp.workingDirectory', { + location: externalServer.definition.workingDirectory, + })} + ) : null} + {(externalServer.definition.environmentKeys?.length ?? 0) > 0 ? ( + {t('mcp.environmentNames', { + names: externalServer.definition.environmentKeys.join(', '), + })} + ) : null} + {(externalServer.definition.environmentReferenceNames?.length ?? 0) > 0 ? ( + {t('mcp.environmentReads', { + names: (externalServer.definition.environmentReferenceNames ?? []).join(', '), + })} + ) : null} + {(externalServer.definition.headerNames?.length ?? 0) > 0 ? ( + {t('mcp.headerNames', { + names: externalServer.definition.headerNames.join(', '), + })} + ) : null} + + {t('mcpApprovals.warning')} + + {reviewingExternal && !selected && candidate.available ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); + })} +
+
+ {conflict.selectedCandidateId + ? t('mcpConflicts.currentSelection') + : t('mcpConflicts.pending')} +
+
+ ))} +
+ ) : null} + {(snapshot?.subagents?.length ?? 0) > 0 ? ( {snapshot?.subagents?.map((agent) => { @@ -954,6 +1393,8 @@ const ExternalSourcesConfig: React.FC = () => { {t('sources.toolCount', { count: toolCounts.get(sourcePair) ?? 0 })} {' · '} {t('sources.agentCount', { count: agentCounts.get(sourcePair) ?? 0 })} + {' · '} + {t('sources.mcpCount', { count: mcpCounts.get(sourcePair) ?? 0 })} )} align="center" diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index f368b7badb..4c2a965434 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -1,6 +1,6 @@ { "title": "External AI applications", - "subtitle": "Use supported commands, tools, and agents from other AI applications without copying their files into BitFun.", + "subtitle": "Use supported commands, tools, agents, and MCP servers from other AI applications without copying their files into BitFun.", "loading": "Checking external sources…", "checkingNonBlocking": "Still checking for changes. You can continue using settings while this finishes.", "common": { @@ -19,7 +19,8 @@ "refreshing": "Refreshing…", "updated": "Saved. The current status is shown below.", "waitForUpdate": "Another change is still being saved. Wait for it to finish before making another choice.", - "agentUpdated": "Saved changes to {{name}} and refreshed its status." + "agentUpdated": "Saved changes to {{name}} and refreshed its status.", + "mcpUpdated": "Saved the MCP server choice and refreshed its status." }, "unavailable": { "title": "External settings unavailable", @@ -55,6 +56,7 @@ "commandCount": "{{count}} commands", "toolCount": "{{count}} tools", "agentCount": "{{count}} agents", + "mcpCount": "{{count}} MCP servers", "toggleLabel": "Enable {{name}}" }, "agents": { @@ -139,6 +141,63 @@ "keptUnavailable": "This agent name is kept unavailable. Choose an option above when you want to use one.", "selectionApproves": "This choice confirms the model, tools, run location, and configuration sources shown below." }, + "mcp": { + "title": "Found MCP servers", + "description": "MCP settings stay in the source application. External servers are read-only here; disable one or change it in its original application.", + "externalSource": "External AI application", + "source": "Source: {{source}}", + "sourceLocation": "Configuration: {{location}}", + "scope": "Applies to: {{scope}}", + "command": "Command: {{command}}", + "url": "Address: {{url}}", + "workingDirectory": "Starts in: {{location}}", + "environmentCount": "{{count}} environment variables configured", + "environmentNames": "Environment variables: {{names}}", + "environmentReads": "Reads from the BitFun environment: {{names}}", + "argumentCount": "Command arguments: {{count}}", + "headerCount": "{{count}} HTTP headers configured", + "headerNames": "HTTP headers: {{names}}", + "changePolicy": "BitFun asks again before loading a changed server configuration.", + "runtimeUnavailableGuidance": "Disable this server, fix the source configuration or authentication, then enable it again. Restarting BitFun is usually not required.", + "unsupportedGuidance": "Change this setting in the source application, then refresh.", + "enable": "Enable server", + "disable": "Disable", + "transport": { + "local_stdio": "Local process", + "streamable_http": "Remote connection" + } + }, + "mcpApprovals": { + "title": "MCP servers to review", + "description": "BitFun found executable or network configuration from another AI application. It is not started or contacted until you confirm it.", + "warning": "Enabling can start a local process with your user permissions or connect to the shown remote service. The server can provide tools to the agent and can receive arguments sent to those tools.", + "keepDisabled": "Keep disabled", + "enable": "Enable server" + }, + "mcpState": { + "approval_required": "Confirmation required", + "starting": "Starting", + "active": "Enabled", + "declined": "Kept disabled", + "conflict": "Choice required", + "covered": "Not selected", + "source_disabled": "Source disabled", + "configuration_changed": "Changed; confirm again", + "unsupported": "Not supported", + "runtime_unavailable": "Temporarily unavailable", + "removed": "Removed" + }, + "mcpConflicts": { + "title": "MCP servers with the same name", + "description": "BitFun does not choose silently. Choose the BitFun or external server to use; the choice is remembered until one of these configurations changes.", + "serverName": "Multiple MCP servers are named {{name}}", + "pending": "Choose one server. Choosing an external server also confirms that configuration.", + "currentSelection": "The current choice is used. Choose another available option to change it.", + "unavailable": "Currently unavailable", + "nativeDisabled": "Enable this BitFun server before selecting it", + "review": "Review {{name}}", + "approveAndUse": "Approve and use {{name}}" + }, "tools": { "title": "Found tools", "description": "Tool code stays in its original location. BitFun does not run it while checking the source.", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index cb4d8623f5..b0185fc6a8 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -1,6 +1,6 @@ { "title": "外部 AI 应用", - "subtitle": "无需复制文件,即可使用其他 AI 应用中受支持的命令、工具和 Agent。", + "subtitle": "无需复制文件,即可使用其他 AI 应用中受支持的命令、工具、Agent 和 MCP 服务器。", "loading": "正在检查外部来源…", "checkingNonBlocking": "仍在检查变更;检查完成前可以继续使用当前设置。", "common": { @@ -19,7 +19,8 @@ "refreshing": "正在刷新…", "updated": "已保存,当前状态如下。", "waitForUpdate": "另一项更改仍在保存,请等待完成后再做选择。", - "agentUpdated": "已保存“{{name}}”的更改并刷新状态。" + "agentUpdated": "已保存“{{name}}”的更改并刷新状态。", + "mcpUpdated": "已保存 MCP 服务器选择并刷新状态。" }, "unavailable": { "title": "外部配置暂不可用", @@ -55,6 +56,7 @@ "commandCount": "{{count}} 个命令", "toolCount": "{{count}} 个工具", "agentCount": "{{count}} 个 Agent", + "mcpCount": "{{count}} 个 MCP 服务器", "toggleLabel": "启用 {{name}}" }, "agents": { @@ -139,6 +141,63 @@ "keptUnavailable": "当前保持此 Agent 名称不可用。需要使用时,可在上方选择一个 Agent。", "selectionApproves": "选择后,也表示你确认了下方显示的模型、工具、运行位置和配置来源。" }, + "mcp": { + "title": "发现的 MCP 服务器", + "description": "MCP 配置仍保存在原应用中。外部服务器在此处为只读;你可以将其停用,或回到原应用修改配置。", + "externalSource": "外部 AI 应用", + "source": "来源:{{source}}", + "sourceLocation": "配置位置:{{location}}", + "scope": "作用范围:{{scope}}", + "command": "命令:{{command}}", + "url": "地址:{{url}}", + "workingDirectory": "启动目录:{{location}}", + "environmentCount": "已配置 {{count}} 个环境变量", + "environmentNames": "环境变量:{{names}}", + "environmentReads": "将读取 BitFun 环境变量:{{names}}", + "argumentCount": "命令参数数量:{{count}}", + "headerCount": "已配置 {{count}} 个 HTTP 请求头", + "headerNames": "HTTP 请求头:{{names}}", + "changePolicy": "服务器配置发生变化后,BitFun 会在再次加载前请你确认。", + "runtimeUnavailableGuidance": "请先停用此服务器,修复来源配置或认证后再启用,通常无需重启 BitFun。", + "unsupportedGuidance": "请在来源应用中修改此配置,然后刷新。", + "enable": "启用服务器", + "disable": "停用", + "transport": { + "local_stdio": "本地进程", + "streamable_http": "远程连接" + } + }, + "mcpApprovals": { + "title": "待确认的 MCP 服务器", + "description": "BitFun 从其他 AI 应用中发现了可执行命令或网络连接配置;确认前不会启动进程或访问远程地址。", + "warning": "启用后,BitFun 可能以你的用户权限启动本地进程,或连接到上方显示的远程服务。服务器可以向 Agent 提供工具,并接收调用这些工具时提交的参数。", + "keepDisabled": "保持停用", + "enable": "启用服务器" + }, + "mcpState": { + "approval_required": "待确认", + "starting": "正在启动", + "active": "已启用", + "declined": "保持停用", + "conflict": "需要选择", + "covered": "未选用", + "source_disabled": "来源已停用", + "configuration_changed": "配置已变化,需再次确认", + "unsupported": "暂不支持", + "runtime_unavailable": "暂时不可用", + "removed": "已删除" + }, + "mcpConflicts": { + "title": "同名 MCP 服务器", + "description": "BitFun 不会自行选择。请选择使用 BitFun 或外部服务器;其中任一配置变化前,BitFun 会记住本次选择。", + "serverName": "有多个 MCP 服务器名为 {{name}}", + "pending": "请选择一个服务器。选择外部服务器时,也会确认其当前配置。", + "currentSelection": "当前使用已选择的服务器;可选择其他可用项进行更改。", + "unavailable": "当前不可用", + "nativeDisabled": "请先启用此 BitFun 服务器,再选择它", + "review": "查看 {{name}}", + "approveAndUse": "批准并使用 {{name}}" + }, "tools": { "title": "发现的工具", "description": "工具代码仍保留在原位置;BitFun 检查来源时不会运行这些代码。", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index ae4f072e2d..4604319598 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -1,6 +1,6 @@ { "title": "外部 AI 應用", - "subtitle": "無需複製檔案,即可使用其他 AI 應用中受支援的命令、工具與 Agent。", + "subtitle": "無需複製檔案,即可使用其他 AI 應用中受支援的命令、工具、Agent 與 MCP 伺服器。", "loading": "正在檢查外部來源…", "checkingNonBlocking": "仍在檢查變更;檢查完成前可以繼續使用目前設定。", "common": { @@ -19,7 +19,8 @@ "refreshing": "正在重新整理…", "updated": "已儲存,目前狀態如下。", "waitForUpdate": "另一項變更仍在儲存,請等待完成後再做選擇。", - "agentUpdated": "已儲存「{{name}}」的變更並重新整理狀態。" + "agentUpdated": "已儲存「{{name}}」的變更並重新整理狀態。", + "mcpUpdated": "已儲存 MCP 伺服器選擇並重新整理狀態。" }, "unavailable": { "title": "外部設定暫不可用", @@ -55,6 +56,7 @@ "commandCount": "{{count}} 個命令", "toolCount": "{{count}} 個工具", "agentCount": "{{count}} 個 Agent", + "mcpCount": "{{count}} 個 MCP 伺服器", "toggleLabel": "啟用 {{name}}" }, "agents": { @@ -139,6 +141,63 @@ "keptUnavailable": "目前保持此 Agent 名稱不可用。需要使用時,可在上方選擇一個 Agent。", "selectionApproves": "選擇後,也表示你確認了下方顯示的模型、工具、執行位置與設定來源。" }, + "mcp": { + "title": "找到的 MCP 伺服器", + "description": "MCP 設定仍保存在原應用程式中。外部伺服器在此處為唯讀;你可以將其停用,或回到原應用程式修改設定。", + "externalSource": "外部 AI 應用程式", + "source": "來源:{{source}}", + "sourceLocation": "設定位置:{{location}}", + "scope": "適用範圍:{{scope}}", + "command": "命令:{{command}}", + "url": "位址:{{url}}", + "workingDirectory": "啟動目錄:{{location}}", + "environmentCount": "已設定 {{count}} 個環境變數", + "environmentNames": "環境變數:{{names}}", + "environmentReads": "將讀取 BitFun 環境變數:{{names}}", + "argumentCount": "命令參數數量:{{count}}", + "headerCount": "已設定 {{count}} 個 HTTP 標頭", + "headerNames": "HTTP 標頭:{{names}}", + "changePolicy": "伺服器設定變更後,BitFun 會在再次載入前請你確認。", + "runtimeUnavailableGuidance": "請先停用此伺服器,修正來源設定或驗證後再啟用,通常不需要重新啟動 BitFun。", + "unsupportedGuidance": "請在來源應用程式中修改此設定,然後重新整理。", + "enable": "啟用伺服器", + "disable": "停用", + "transport": { + "local_stdio": "本機程序", + "streamable_http": "遠端連線" + } + }, + "mcpApprovals": { + "title": "待確認的 MCP 伺服器", + "description": "BitFun 從其他 AI 應用程式找到可執行命令或網路連線設定;確認前不會啟動程序或存取遠端位址。", + "warning": "啟用後,BitFun 可能以你的使用者權限啟動本機程序,或連線到上方顯示的遠端服務。伺服器可以向 Agent 提供工具,並接收呼叫這些工具時提交的參數。", + "keepDisabled": "保持停用", + "enable": "啟用伺服器" + }, + "mcpState": { + "approval_required": "待確認", + "starting": "正在啟動", + "active": "已啟用", + "declined": "保持停用", + "conflict": "需要選擇", + "covered": "未選用", + "source_disabled": "來源已停用", + "configuration_changed": "設定已變更,需再次確認", + "unsupported": "暫不支援", + "runtime_unavailable": "暫時無法使用", + "removed": "已刪除" + }, + "mcpConflicts": { + "title": "同名 MCP 伺服器", + "description": "BitFun 不會自行選擇。請選擇使用 BitFun 或外部伺服器;其中任一設定變更前,BitFun 會記住本次選擇。", + "serverName": "有多個 MCP 伺服器名為 {{name}}", + "pending": "請選擇一個伺服器。選擇外部伺服器時,也會確認其目前設定。", + "currentSelection": "目前使用已選擇的伺服器;可選擇其他可用項目進行變更。", + "unavailable": "目前無法使用", + "nativeDisabled": "請先啟用此 BitFun 伺服器,再選擇它", + "review": "查看 {{name}}", + "approveAndUse": "核准並使用 {{name}}" + }, "tools": { "title": "發現的工具", "description": "工具程式碼仍保留在原位置;BitFun 檢查來源時不會執行這些程式碼。",