diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 2e977ddd8d..0b895d4484 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -64,11 +64,13 @@ pnpm run type-check:web pnpm --dir src/mobile-web run type-check pnpm run i18n:contract:test # 仅 i18n contract / resources pnpm run i18n:audit # 仅 i18n contract / resources +pnpm run product:check # 默认产品定义 pnpm run check:repo-hygiene pnpm run check:github-config cargo check --workspace # 测试(本地优先用精确测试路径;大范围测试由 CI 兜底) +pnpm run product:test pnpm --dir src/web-ui run test:run # 大范围测试;本地优先用精确测试路径 cargo test --workspace # 大范围测试;CI 兜底 @@ -250,6 +252,7 @@ OpenCode 兼容或目标项目治理的变更,先阅读 | Locale contract 或 shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime、namespace loading 或直接 `i18nService.t(...)` 调用 | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI、状态、配对、断开或重连行为 | `pnpm --dir src/mobile-web run type-check`;行为变化还需要在 PR 中说明手动配对 / 重连验证 | +| 产品定义、schema、resolver 或 Desktop/CLI 产品构建 adapter | `pnpm run product:test`,并对默认定义运行 `pnpm run product:check` | | `core`、`transport`、adapter 或共享服务中的 Rust 逻辑 | `cargo check --workspace`;行为变化时再加最近的 focused `cargo test` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop`;行为变化时再加 focused desktop tests | | 被桌面端 smoke/functional 流覆盖的行为 | 优先运行最近的 focused E2E/smoke check;除非改动影响构建,否则 broad build/test 交给 CI | diff --git a/AGENTS.md b/AGENTS.md index a782c2f0f9..a5d9245946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,11 +67,13 @@ pnpm run type-check:web pnpm --dir src/mobile-web run type-check pnpm run i18n:contract:test # i18n contract / resources only pnpm run i18n:audit # i18n contract / resources only +pnpm run product:check # default product definition pnpm run check:repo-hygiene pnpm run check:github-config cargo check --workspace # Test (prefer focused paths locally; broad suites are CI-backed) +pnpm run product:test pnpm --dir src/web-ui run test:run # broad suite; prefer focused paths locally cargo test --workspace # broad suite; CI-backed @@ -281,6 +283,7 @@ change directly affects build, packaging, or CI cannot protect the path. | Locale contract or shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime, namespace loading, or direct `i18nService.t(...)` usage | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI, state, pairing, disconnect, or reconnect behavior | `pnpm --dir src/mobile-web run type-check`; include manual pairing / reconnect notes when behavior changes | +| Product definition, schema, resolver, or Desktop/CLI product build adapter | `pnpm run product:test`, plus `pnpm run product:check` for the default definition | | Shared Rust logic in `core`, `transport`, adapters, or services | `cargo check --workspace`, plus the nearest focused `cargo test` when behavior changed | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop`, plus focused desktop tests when behavior changed | | Behavior covered by desktop smoke/functional flows | Prefer the nearest focused E2E/smoke check; rely on CI for broad build/test coverage unless build behavior changed | diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 737d0a335e..e4f14b574d 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -159,7 +159,7 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop |---|---|---| | CLI 主会话客户端已仅消费 Rust Runtime SDK;本地工作区快照的准备、文件清单、统计和文件回滚已有 Desktop/Peer Host 共用的窄 owner port,但快照记录/持久化/事件、账号同步、富历史及 Peer Host/ACP 的其余维护仍由现有 Core owner 提供 | 窄端口只消除重复宿主转发,不代表完整快照系统、远程快照或公开 SDK 能力已迁移;过早删除其余兼容路径会改变行为 | 保持快照实现和工具拦截在 Core,远程与历史维护留在宿主;仅在新的真实调用方、独立语义和行为等价测试齐备后继续迁移。 | | TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上逐步拆成事件、状态处理、副作用和渲染四个边界,不重写全部 TUI。 | -| CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全复用其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、持续来源视图和可选的显式导入报告。 | +| CLI 配置只覆盖入口本地选项,除 C0a 外部 MCP 快照导入外仍缺少统一层级、来源解释和通用兼容导入 | 用户无法安全复用其他 CLI 资产,也难以解释最终配置来源 | 保留现有 MCP 窄入口,后续按真实资产建立 BitFun Canonical Config、持续来源视图和可选的显式导入报告。 | | OpenCode 来源发现与真实执行尚未形成完整流程 | “来源可识别”容易被误解为“插件可执行” | 先完成一个无外部依赖的样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | @@ -184,7 +184,7 @@ PTY/ConPTY 生命周期、Chat 活动 turn 的 resize/取消和发布归档冒 |---|---|---| | 调用级审批 | TUI、`exec` 与 ACP 已使用各自调用级策略且不写全局配置 | Runtime-context `Allow always`、审批规划、`exec` 安全默认值和显式 `--auto` 有 focused test;真实模型/PTY 审批流与 ACP 仍需另行验收 | | 输出协议 | 保留 `text/json/stream-json`,复用现有 Agent 事件 | 单一最终状态、失败优先级和 `success=false` 规则见下文;真实供应商审批仍需验收 | -| 配置解释 | Canonical Config 层级、全局/项目持续来源、加载状态和兼容导入 dry-run | 不自动写入;冲突、未知字段、待确认能力和凭据引用可解释 | +| 配置解释 | Canonical Config 层级、全局/项目持续来源、加载状态和非 MCP 兼容导入 dry-run | 除已单独评审的 MCP C0a 快照导入外不自动写入;冲突、未知字段、待确认能力和凭据引用可解释 | | 产品定制 | 消费最小产品定义、组装结果和已注册 TUI layout/theme ID | 第二个真实 CLI 产品复用后再提升公共字段 | | TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计;Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消、恢复可编辑状态和正常退出清理可单独验证,macOS 活动 turn 与 OS 级初始化失败注入另行补齐 | @@ -414,7 +414,11 @@ HarmonyOS PC GUI 与移动端均另立专题。 产品定义、品牌资源、TUI 布局选择、产品组装结果和内置扩展的通用边界由 [`product-customization-blueprint.md`](product-customization-blueprint.md) 定义。本节只约束 CLI/TUI 消费。 -CLI 入口只接收已校验的产品组装结果和当前 Delivery Profile 对应的 TUI 布局字段,不读取原始品牌资源, +当前 C0a 只消费已校验解析结果中的 localized 产品名和 binary name,并由 `cli:dev`、`cli:build` +的同一 wrapper 通过显式 `--product-config` 选择非默认定义。内部 Cargo target 仍为 `bitfun`;build 产物按解析后的 +名称暂存。安装、更新、用户数据隔离、完整运行时品牌替换以及下表中的布局、命令组、状态、键位或主题选择均未实现。 + +目标 CLI 入口只接收已校验的产品组装结果和当前 Delivery Profile 对应的 TUI 布局字段,不读取原始品牌资源, 也不运行构建脚本。首期 TUI 布局只允许引用宿主已注册的稳定 ID: | 定制面 | CLI/TUI 消费 | 宿主保留决定权 | @@ -465,30 +469,37 @@ Configuration 只能覆盖产品定义明确允许的默认值;用户插件只 外部进程、不 import 第三方 module、不读取凭据且不主动联网的 L1 字段可以按用户偏好自动应用或先询问。 Plugin/Tool、可执行 Skill/Command、MCP/LSP/Formatter、远程 Reference 等 L2/L3 内容在 OC-R2 完成归属模块保护 前只发现和展示;完成后仍须在首次启用或能力扩大时确认。它们无需先迁移; -显式导入用于用户希望把资产写入 BitFun 原生配置的场景。CLI-P0 截止到 Dry-run,CLI-P1 才允许 -对受支持的非执行型配置执行 apply: +显式导入用于用户希望把资产写入 BitFun 原生配置的场景。当前已落地的窄切片只有外部 MCP 快照:Desktop 与 +`bitfun mcp import` 可以预览 OpenCode / Claude Code 中语义等价的安全声明,只有显式 `--apply` 才原子写入现有 +BitFun MCP 配置;新条目保持 disabled,之后仍由既有 MCP 管理入口复核和启用。Codex 投影、凭据、header、env、cwd、 +通用导入记录和 undo 均未实现。该切片不表示通用 Canonical Config 导入已进入 CLI-P1;其他资产在 CLI-P0 仍截止到 +Dry-run,只有各自经过评审的 apply 切片才能写入: ```text 持续兼容:后台发现 -> 解析 -> 风险分级 -> L1 自动应用/先询问 | L2/L3 待确认 -> 同一次状态提交切换 -显式导入:选择来源 -> 归一化 -> 冲突分析 -> Dry-run | CLI-P1: 用户选择 -> 原子写入 BitFun 层 -> 复核/回滚 +MCP C0a:发现 -> 安全投影 -> 预览 | 显式 apply -> 原子写入 disabled 原生条目 -> 既有 MCP 管理 +其他显式导入:选择来源 -> 归一化 -> 冲突分析 -> Dry-run | 后续评审切片:用户选择 -> 原子写入 BitFun 层 ``` 交互式 CLI/TUI 以一条非阻塞摘要说明来源产品、全局/项目使用范围、资产数量、自动应用项和待确认项;详细内容进入 -统一来源与插件状态入口,具体命令名在有真实调用方时再固定。非交互命令只有在当前操作实际依赖待确认资产时才 +统一来源与插件状态入口。MCP 快照入口固定为 `bitfun mcp import`,其他资产的命令名在有真实调用方时再固定。非交互命令只有在当前操作实际依赖待确认资产时才 返回类型化 `action-required`;无关待办只进入结构化状态或 `stderr` 摘要,不等待不可见输入,也不自动批准。 当前只能静态预览的 custom tool 名称只显示“已发现,未执行”。 导入预览只使用四种用户可读结论:可直接使用、需要转换、会发生功能降级、输入无效。每项同时说明是原地 引用、写入 BitFun 配置、继续保持外部来源还是不支持;不得用“已映射”推导为已写入、已信任或已启用。 -兼容来源不写入 BitFun 层,也不双向修改原文件。显式导入时,项目级来源默认写入 BitFun 项目层,用户级 -来源默认写入用户层;用户可以在确认时选择更窄的目标层,但不能写入组织强制策略。导入记录保留来源产品、 +兼容来源不写入 BitFun 层,也不双向修改原文件。以下分层导入记录与撤销语义是后续通用目标,不是 MCP C0a +已实现能力:项目级来源默认写入 BitFun 项目层,用户级来源默认写入用户层;用户可以在确认时选择更窄的目标层, +但不能写入组织强制策略。导入记录保留来源产品、 来源范围、内容摘要和导入时间,并按字段保存目标层、导入前值及其版本/摘要和导入值。已导入字段以 BitFun 原生 配置为准,不再重复应用外部值;外部来源变化时提示重新导入并展示差异,不做双向写回。撤销只自动恢复当前值 仍等于导入值的字段;用户后续修改、来源变化或部分重新导入造成冲突时,逐字段选择“保留 BitFun / 重新导入 外部 / 手工处理”,不得整批覆盖。 -| 来源 | 首期可导入 | 首期不导入 | +下表描述目标覆盖范围;当前 MCP C0a 仅支持上文列出的 OpenCode / Claude Code 安全投影,不能由本表推导为已实现。 + +| 来源 | 目标可导入 | 目标不导入 | |---|---|---| | OpenCode | 规则/instructions、Agent、Mode、Skill、References、Command、MCP、LSP、Formatter、模型、Theme、Keybind 和稳定配置进入兼容来源图;非执行资产可显式导入 | 凭据值双向复制、把 OpenCode 原始类型变成 BitFun 内部类型;Plugin/Tool 经来源确认后由独立 Runtime 加载,不通过配置导入执行 | | Codex | `AGENTS.md` 原地引用;受支持的 MCP、稳定配置和 Skill 可选择原地引用或导入 | `auth.json` 等凭据、私有/未文档化字段、Codex App Server 状态 | diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 57f3a5945e..595da002a9 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -22,6 +22,41 @@ MCP 安全子集,以及 Codex Subagent、MCP 安全子集;三种生态使用 但各自在 sibling adapter 内保留原生来源与覆盖语义。完整 TypeScript/Bun、包依赖、package plugin 执行、 Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent 续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 静态目录 已作为独立只读切片接入;它只证明来源和声明能够安全展示,不证明 handler 已加载、获得权限或可以执行。 +独立的 MCP C0a 快照导入复用上述来源与现有 MCP 配置 owner:Desktop 和根 CLI 可预览 OpenCode / Claude Code +中语义等价的安全声明,并在用户显式确认后原子写入 disabled 原生条目。Codex 导入投影、凭据/header/env/cwd +迁移、通用导入记录、undo、Peer/Remote 写入均未实现;这不改变外部 MCP 持续兼容来源的运行路径。 + +## 0. 当前 MCP 快照导入契约(C0a) + +快照导入是显式复制,不是持续同步,也不改变现有外部 MCP 兼容来源。Desktop 与根 CLI 只负责展示脱敏预览并发送 +typed intent;OpenCode / Claude Code sibling adapter 复用各自已合并的解析结果生成私有安全投影,外部来源协调器固定 +当前 candidate 与行为版本,core 负责重新规划,最终仍由唯一 MCP 配置 service 校验并写入 `mcp_servers`。 +Codex 继续参与现有只读发现,但当前没有导入投影。 + +公开的 versioned plan/apply DTO 只包含 schema version、plan fingerprint、candidate ID、display name、transport、建议 +native ID、disposition 和稳定 reason code,不包含 command arguments、URL、原始 JSON、凭据、environment/header 值或 +`MCPServerConfig`。provider 私有投影不可序列化且使用 redacted `Debug`;plan/request 最多包含 256 个 candidate,未知请求 +字段与重复选择直接拒绝。 + +当前只复制能够与原生配置保持等价语义的声明: + +- 无显式 environment/cwd 的 local stdio command 与 adapter 已解析 arguments; +- 无 userinfo、query、fragment、header 或 provider OAuth 变化的 HTTPS streamable HTTP URL。 + +environment 值或引用、header/authorization、cwd、未知字段和其他 transport 不猜测、不复制、不记录。导入条目始终为 +`enabled: false` 与 `autoStart: false`;local 条目不继承完整父进程环境,只保留 MCP runtime owner 提供的安全环境。 + +native ID 优先使用外部 logical name,再使用稳定生态后缀和最小可用数字后缀;超长名称使用 bounded digest,已有条目 +永不覆盖。plan fingerprint 同时绑定脱敏 plan、私有投影和当前原生 MCP 配置摘要。apply 会重新发现并重建 plan;来源或 +目标内容变化时返回刷新后的脱敏 plan,且不写入;fingerprint 不绑定 coordinator refresh generation,因此内容未变的刷新 +不会让 plan stale。配置 service 通过同一 JSON key 的 compare-and-set mutation lane +一次提交全部选中条目或全部不提交,并在 `_bitfunImport` 中只保留 source-qualified candidate ID 与 behavior version。 +普通 MCP 编辑保留这段 provenance,删除条目时随条目一并移除。 + +根 CLI 的 `bitfun mcp import` 默认只预览,`--apply` 导入全部 eligible 项;重复 `--candidate` 可缩小集合,单一选择可用 +`--native-id` 指定目标 ID,`--format json` 输出 versioned plan/result。当前没有 TUI/Mobile/Server/Peer/Remote/ACP/SDK +写入口、导入 journal、tombstone、undo、外部应用回写或插件安装/激活策略;导入后仍由既有 MCP manager 完成复核、编辑、 +启用和删除。 ## 1. 产品判断与竞品启示 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 1d1ee1278a..e2ed8c5b3e 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -40,7 +40,12 @@ JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也 | 方式 | 写入位置 | 是否立即生效 / 是否执行代码 | 停用或撤销 | 来源变化后 | |---|---|---|---|---| | 兼容来源 | 不写 BitFun 配置,也不写回源文件 | OC-R1 的 L1 内容按用户偏好自动应用或先询问;L2/L3 内容只发现,首次启用、更新策略要求或能力扩大时确认 | 按当前项目或执行域抑制来源/资产,或分别停用 server/tui 入口;watcher 更新不得绕过该偏好重新应用 | 重新解析候选;低风险变化自动切换,能力/权限扩大等待确认,失败时保留仍合规的上一结果 | -| 显式导入 | 用户选择的 BitFun 用户层、项目层或更窄工作区层 | 写入成功后由目标层正常生效;首期只导入非执行配置,Plugin/Tool 不经导入执行 | 按字段撤销;冲突字段先预览,不自动覆盖后续修改 | 只提示重新导入,不双向写回,也不自动覆盖 BitFun 值 | +| 显式导入(目标) | 用户选择的 BitFun 用户层、项目层或更窄工作区层 | 写入成功后由目标层正常生效;Plugin/Tool 不经导入执行 | 按字段撤销;冲突字段先预览,不自动覆盖后续修改 | 只提示重新导入,不双向写回,也不自动覆盖 BitFun 值 | + +当前显式导入仅落地 MCP C0a 窄切片:复用现有来源解析,在 Desktop 与 `bitfun mcp import` 中预览 +OpenCode / Claude Code 的等价安全声明;显式 apply 只向现有用户级 MCP 配置原子写入 disabled 条目。它不复制 +header、env、cwd 或凭据,不支持 Codex 投影、通用目标层选择、逐字段导入记录或 undo。下述通用导入与撤销语义 +仍是目标设计,不能由 MCP C0a 推导为已实现。 “只读”只表示源文件不被 BitFun 改写,不表示结果仅供预览。兼容来源不是 BitFun 内部权威模型,但它是合法 运行输入;达到对应阶段后,适配器把有效值映射到归属模块,归属模块仍负责最终持久化、运行时状态和错误语义。 @@ -189,7 +194,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)只消费中立定义与展开结果 | 当前支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | -| 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。 | +| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 当前支持 local stdio 和 HTTPS remote 的静态发现、首次/行为变化审批、冲突选择与 workspace 隔离的运行期接纳;C0a 快照导入只复制无 env/cwd 的 local command/args 或无 header/query/fragment 的 HTTPS remote,并保持 disabled | `{env:NAME}` 当前只允许用于运行期兼容来源的 environment/Header 值,不进入 C0a 快照;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 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 983cf1ed6f..73b7cf829d 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -5,8 +5,62 @@ [OpenCode 扩展兼容总览](extensions/opencode-extension-compatibility.md)和 [插件运行时与 Plugin Host](extensions/plugin-runtime-design.md)。 -本文是目标设计,不表示相关构建任务或配置格式已实现。设计只保留已有或近期有明确消费方的概念,不建立通用 -白标平台、构建脚本运行时或跨 GUI/TUI 的组件协议。 +本文同时记录长期目标边界与最小架构切片。设计只保留已有或近期有明确消费方的概念,不建立通用 +白标平台、构建脚本运行时或跨 GUI/TUI 的组件协议;未在“当前实现”中列出的对象只是后续边界,不应被视为已支持能力。 + +## 0. 当前实现(C0a) + +C0a 实现一个构建期 JSONC 产品定义、严格解析器和确定性解析摘要,入口位于 `products/` 与 +`scripts/product-customization/`。默认命令不需要参数;只有多产品仓库、CI 矩阵或外部定义文件需要显式传入 +`--product-config `。不存在产品 ID 注册表、运行时主选择器或用于选源的环境变量。 + +当前真实消费者只有: + +- Desktop build adapter:从解析结果覆盖 Tauri `productName`、`mainBinaryName` 与 bundle identifier; +- CLI dev/build wrapper:从同一解析结果设置命令名、隔离定制构建缓存,并按成员 `binaryName` 暂存构建产物; + +`product:check` / `product:explain` 只是构建作者的校验与解释工具,不计作产品字段的生产消费者。C0a 不生成无人读取的 +通用产品 manifest 或 locale projection;Desktop 与 CLI build adapter 直接消费同一次内存解析结果。 + +产品定义 v1 仅包含已被这些消费者读取的字段,未知字段一律拒绝。localized 名称独立于技术 ID,并按共享 locale contract +校验后交给各自 build adapter。 + +C0a 不声称生成可独立发行的完整品牌产品。品牌资产、GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、 +用户数据隔离、更新与签名、运行时全量品牌替换均延期;每一项必须在出现真实 owner 和消费者后独立扩展 schema 与组装结果。 + +### 0.1 当前定义与解析契约 + +产品定义描述一个 family,其中 Desktop 与 CLI 是分别命名、分别消费的成员;Installer 与 Store 是可能的 Desktop +交付目标,不是独立成员,当前也没有对应实现。schema v1 只接受以下已消费字段: + +```jsonc +{ + "$schema": "../schemas/product-definition.schema.json", + "schemaVersion": 1, + "localeRoot": "./locales", + "members": { + "desktop": { + "displayNameKey": "product.desktop.name", + "binaryName": "acme-desktop", + "bundleId": "com.acme.desktop" + }, + "cli": { + "displayNameKey": "product.cli.name", + "binaryName": "acme" + } + } +} +``` + +解析器先校验完整 family、双方 locale key、owned path 与技术 ID,再选择命令对应成员;digest-bearing `assembly` +只携带 schema/source digest、成员、display-name key、binary/bundle identity、locale contract facts 与 assembly digest。 +构建 adapter 所需的源路径、localized 名称、输出目录和 default-product 标记保留在外围 build context,不扩展成通用 +manifest。相同输入必须产生相同摘要;非默认产品使用 digest-scoped Cargo target 目录,避免复用其他产品的编译期身份。 + +默认构建不需要选择参数。构建作者与 CI 只通过 +`--product-config ` 指向定义;`product:check` 校验完整 family 并报告选定成员 assembly,`product:explain` +解释来源、localized 名称、技术 identity 与摘要。binary/bundle identity 不作为可见名称,产品 locale 必须符合共享 +i18n locale 集合和 key parity。 ## 1. 设计结论 diff --git a/package.json b/package.json index 4f1e96cd03..d8bdbf27f9 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "desktop:preview:debug": "node scripts/dev.cjs desktop-preview", "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", "target:gc": "node scripts/cargo-target-gc.mjs", + "product:check": "node scripts/product-customization/cli.mjs check", + "product:explain": "node scripts/product-customization/cli.mjs explain", + "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", @@ -72,8 +75,8 @@ "installer:build:only": "pnpm --dir BitFun-Installer run installer:build:only", "installer:build:only:fast": "pnpm --dir BitFun-Installer run installer:build:only:fast", "installer:dev": "pnpm --dir BitFun-Installer run installer:dev", - "cli:dev": "cd src/apps/cli && cargo run --", - "cli:build": "cd src/apps/cli && cargo build --release", + "cli:dev": "node scripts/cli-product.mjs dev", + "cli:build": "node scripts/cli-product.mjs build", "cli:install": "node scripts/install-cli.mjs", "cli:install:unix": "bash src/apps/cli/install.sh", "cli:install:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", diff --git a/products/bitfun/locales/en-US.json b/products/bitfun/locales/en-US.json new file mode 100644 index 0000000000..4689acd173 --- /dev/null +++ b/products/bitfun/locales/en-US.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "BitFun CLI", + "product.desktop.name": "BitFun" +} diff --git a/products/bitfun/locales/zh-CN.json b/products/bitfun/locales/zh-CN.json new file mode 100644 index 0000000000..4689acd173 --- /dev/null +++ b/products/bitfun/locales/zh-CN.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "BitFun CLI", + "product.desktop.name": "BitFun" +} diff --git a/products/bitfun/locales/zh-TW.json b/products/bitfun/locales/zh-TW.json new file mode 100644 index 0000000000..4689acd173 --- /dev/null +++ b/products/bitfun/locales/zh-TW.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "BitFun CLI", + "product.desktop.name": "BitFun" +} diff --git a/products/bitfun/product.jsonc b/products/bitfun/product.jsonc new file mode 100644 index 0000000000..82a8bf889c --- /dev/null +++ b/products/bitfun/product.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "../schemas/product-definition.schema.json", + "schemaVersion": 1, + "localeRoot": "./locales", + "members": { + "desktop": { + "displayNameKey": "product.desktop.name", + "binaryName": "bitfun-desktop", + "bundleId": "com.bitfun.desktop" + }, + "cli": { + "displayNameKey": "product.cli.name", + "binaryName": "bitfun" + } + } +} diff --git a/products/fixtures/acme/locales/en-US.json b/products/fixtures/acme/locales/en-US.json new file mode 100644 index 0000000000..a8d066b01c --- /dev/null +++ b/products/fixtures/acme/locales/en-US.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "Acme CLI", + "product.desktop.name": "Acme Desktop" +} diff --git a/products/fixtures/acme/locales/zh-CN.json b/products/fixtures/acme/locales/zh-CN.json new file mode 100644 index 0000000000..140f3111ef --- /dev/null +++ b/products/fixtures/acme/locales/zh-CN.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "Acme 命令行", + "product.desktop.name": "Acme 桌面版" +} diff --git a/products/fixtures/acme/locales/zh-TW.json b/products/fixtures/acme/locales/zh-TW.json new file mode 100644 index 0000000000..770e9abd60 --- /dev/null +++ b/products/fixtures/acme/locales/zh-TW.json @@ -0,0 +1,4 @@ +{ + "product.cli.name": "Acme 命令列", + "product.desktop.name": "Acme 桌面版" +} diff --git a/products/fixtures/acme/product.jsonc b/products/fixtures/acme/product.jsonc new file mode 100644 index 0000000000..1bf27c021d --- /dev/null +++ b/products/fixtures/acme/product.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "../../schemas/product-definition.schema.json", + "schemaVersion": 1, + "localeRoot": "./locales", + "members": { + "desktop": { + "displayNameKey": "product.desktop.name", + "binaryName": "acme-desktop", + "bundleId": "com.acme.desktop" + }, + "cli": { + "displayNameKey": "product.cli.name", + "binaryName": "acme" + } + } +} diff --git a/products/schemas/product-definition.schema.json b/products/schemas/product-definition.schema.json new file mode 100644 index 0000000000..86b1952c8c --- /dev/null +++ b/products/schemas/product-definition.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openbitfun.com/schemas/product-definition-v1.json", + "title": "BitFun product definition C0a", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "localeRoot", "members"], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": 1 }, + "localeRoot": { "type": "string", "minLength": 1 }, + "members": { + "type": "object", + "additionalProperties": false, + "required": ["desktop", "cli"], + "properties": { + "desktop": { "$ref": "#/$defs/desktopMember" }, + "cli": { "$ref": "#/$defs/commonMember" } + } + } + }, + "$defs": { + "binaryName": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9_-])?$" + }, + "bundleId": { + "type": "string", + "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$" + }, + "commonMember": { + "type": "object", + "additionalProperties": false, + "required": ["displayNameKey", "binaryName"], + "properties": { + "displayNameKey": { "type": "string", "minLength": 1 }, + "binaryName": { "$ref": "#/$defs/binaryName" } + } + }, + "desktopMember": { + "type": "object", + "additionalProperties": false, + "required": ["displayNameKey", "binaryName", "bundleId"], + "properties": { + "displayNameKey": { "type": "string", "minLength": 1 }, + "binaryName": { "$ref": "#/$defs/binaryName" }, + "bundleId": { "$ref": "#/$defs/bundleId" } + } + } + } +} diff --git a/scripts/cli-product.mjs b/scripts/cli-product.mjs new file mode 100644 index 0000000000..999ddf4c92 --- /dev/null +++ b/scripts/cli-product.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { extractProductConfigArg } from './product-customization/cli.mjs'; +import { ensureProductOutputDirectory, productBuildEnvironment } from './product-customization/projections.mjs'; +import { ProductDefinitionError, resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); + +function stripDelimiter(args) { + const result = [...args]; + while (result[0] === '--') result.shift(); + return result; +} + +function targetDirOverride(argument, nextArgument) { + if (argument === '--target-dir' || argument.startsWith('--target-dir=')) return true; + const config = argument === '--config' + ? nextArgument + : argument.startsWith('--config=') + ? argument.slice('--config='.length) + : undefined; + return /^\s*build\.target-dir\s*=/.test(config ?? ''); +} + +function cargoBuildTarget(args, environment) { + let target = environment.CARGO_BUILD_TARGET?.trim() || undefined; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (targetDirOverride(argument, args[index + 1])) { + throw new ProductDefinitionError( + 'unsupported_cli_target_dir_override', + 'CLI product builds own the Cargo target directory.', + 'Remove --target-dir or build.target-dir; set CARGO_TARGET_DIR before invoking the build if a custom root is required.', + ); + } + if (argument === '--target') target = args[++index]; + else if (argument.startsWith('--target=')) target = argument.slice('--target='.length); + } + if (!target) return undefined; + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(target) || target.toLowerCase().endsWith('.json')) { + throw new ProductDefinitionError( + 'unsupported_cli_build_target', + `Unsupported CLI build target: ${target}`, + 'Use a standard Rust target triple; custom target specification paths are outside C0a.', + ); + } + return target; +} + +export function cliBuildPlan(resolution, mode, forwardArgs = [], platform = process.platform) { + if (!['dev', 'build'].includes(mode)) { + throw new ProductDefinitionError('invalid_cli_build_mode', `Unsupported CLI build mode: ${mode}`, 'Use dev or build.'); + } + const environment = { ...process.env, ...productBuildEnvironment(resolution) }; + const manifestPath = join(ROOT, 'src', 'apps', 'cli', 'Cargo.toml'); + const cargoArgs = [mode === 'dev' ? 'run' : 'build', '--manifest-path', manifestPath, '--bin', 'bitfun']; + const forwarded = stripDelimiter(forwardArgs); + if (mode === 'build') cargoArgs.push('--release', ...forwarded); + else if (forwarded.length) cargoArgs.push('--', ...forwarded); + const cargoTargetDir = environment.CARGO_TARGET_DIR + ? resolve(ROOT, environment.CARGO_TARGET_DIR) + : join(ROOT, 'target'); + const target = cargoBuildTarget(mode === 'build' ? forwarded : [], environment); + const suffix = target + ? (target.split('-').includes('windows') ? '.exe' : '') + : (platform === 'win32' ? '.exe' : ''); + const profileDir = mode === 'build' ? 'release' : 'debug'; + return { + resolution, + mode, + environment, + cargoArgs, + internalBinaryPath: join(cargoTargetDir, ...(target ? [target] : []), profileDir, `bitfun${suffix}`), + stagedBinaryPath: join(resolution.outputDir, 'package', `${resolution.assembly.binaryName}${suffix}`), + }; +} + +export function selectedCliPlan(args, mode) { + const { productConfig, forwardArgs } = extractProductConfigArg(args); + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'cli' }); + return cliBuildPlan(resolution, mode, forwardArgs); +} + +function run(plan) { + const result = spawnSync('cargo', plan.cargoArgs, { cwd: ROOT, env: plan.environment, stdio: 'inherit' }); + if (result.error || result.status !== 0) throw result.error ?? new Error(`cargo exited with status ${result.status}`); + if (plan.mode === 'build') { + if (!existsSync(plan.internalBinaryPath)) throw new Error(`CLI binary was not produced: ${plan.internalBinaryPath}`); + ensureProductOutputDirectory(plan.resolution); + mkdirSync(join(plan.stagedBinaryPath, '..'), { recursive: true }); + copyFileSync(plan.internalBinaryPath, plan.stagedBinaryPath); + console.log(`[product] staged CLI: ${plan.stagedBinaryPath}`); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const [mode = 'dev', ...args] = process.argv.slice(2); + const plan = selectedCliPlan(args, mode); + console.log(`[product] ${plan.resolution.assembly.member} ${plan.resolution.assembly.assemblyDigest}`); + run(plan); + } catch (error) { + console.error(error instanceof ProductDefinitionError + ? JSON.stringify({ ok: false, code: error.code, message: error.message, action: error.action }) + : error?.stack || error); + process.exitCode = 1; + } +} diff --git a/scripts/cli-product.test.mjs b/scripts/cli-product.test.mjs new file mode 100644 index 0000000000..d9d0391646 --- /dev/null +++ b/scripts/cli-product.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +import { cliBuildPlan } from './cli-product.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); + +test('CLI uses the shared resolver and stages the internal binary under the member name', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + const plan = cliBuildPlan(resolution, 'build', ['--locked'], 'win32'); + + assert.deepEqual(plan.cargoArgs.slice(0, 2), ['build', '--manifest-path']); + assert.ok(plan.cargoArgs.includes('--locked')); + assert.ok(plan.internalBinaryPath.endsWith('bitfun.exe')); + assert.ok(plan.stagedBinaryPath.endsWith('acme.exe')); + assert.equal(plan.environment.BITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); +}); + +test('CLI dev forwards runtime arguments after the Cargo delimiter', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, member: 'cli' }); + const plan = cliBuildPlan(resolution, 'dev', ['--', 'health'], 'linux'); + assert.deepEqual(plan.cargoArgs.slice(-2), ['--', 'health']); +}); + +test('CLI build stages a standard cross-target artifact from the Cargo target subdirectory', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + const plan = cliBuildPlan( + resolution, + 'build', + ['--target', 'aarch64-unknown-linux-gnu'], + 'linux', + ); + + assert.ok(plan.internalBinaryPath.endsWith( + join('aarch64-unknown-linux-gnu', 'release', 'bitfun'), + )); +}); + +test('CLI build uses CARGO_BUILD_TARGET when no explicit target is forwarded', { concurrency: false }, () => { + const previousTarget = process.env.CARGO_BUILD_TARGET; + process.env.CARGO_BUILD_TARGET = 'x86_64-unknown-linux-gnu'; + try { + const resolution = resolveProductDefinition({ rootDir: ROOT, member: 'cli' }); + const plan = cliBuildPlan(resolution, 'build', [], 'linux'); + assert.ok(plan.internalBinaryPath.endsWith( + join('x86_64-unknown-linux-gnu', 'release', 'bitfun'), + )); + } finally { + if (previousTarget === undefined) delete process.env.CARGO_BUILD_TARGET; + else process.env.CARGO_BUILD_TARGET = previousTarget; + } +}); + +test('CLI build rejects custom target specs before resolving an artifact path', { concurrency: false }, () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, member: 'cli' }); + assert.throws( + () => cliBuildPlan(resolution, 'build', ['--target=../custom-target.json'], 'linux'), + (error) => error?.code === 'unsupported_cli_build_target', + ); + assert.throws( + () => cliBuildPlan(resolution, 'build', ['--target', 'custom-target.json'], 'linux'), + (error) => error?.code === 'unsupported_cli_build_target', + ); + const previousTarget = process.env.CARGO_BUILD_TARGET; + process.env.CARGO_BUILD_TARGET = 'custom-target.json'; + try { + assert.throws( + () => cliBuildPlan(resolution, 'build', [], 'linux'), + (error) => error?.code === 'unsupported_cli_build_target', + ); + } finally { + if (previousTarget === undefined) delete process.env.CARGO_BUILD_TARGET; + else process.env.CARGO_BUILD_TARGET = previousTarget; + } +}); + +test('CLI build derives the executable suffix from an explicit target OS', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + const windowsPlan = cliBuildPlan( + resolution, + 'build', + ['--target=x86_64-pc-windows-gnu'], + 'linux', + ); + const linuxPlan = cliBuildPlan( + resolution, + 'build', + ['--target=x86_64-unknown-linux-gnu'], + 'win32', + ); + + assert.ok(windowsPlan.internalBinaryPath.endsWith(join('release', 'bitfun.exe'))); + assert.ok(windowsPlan.stagedBinaryPath.endsWith('acme.exe')); + assert.ok(linuxPlan.internalBinaryPath.endsWith(join('release', 'bitfun'))); + assert.ok(linuxPlan.stagedBinaryPath.endsWith('acme')); +}); + +test('CLI build rejects Cargo target directory overrides that bypass product isolation', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + for (const forwardArgs of [ + ['--target-dir', 'target/other'], + ['--target-dir=target/other'], + ['--config', 'build.target-dir="target/other"'], + ['--config=build.target-dir="target/other"'], + ]) { + assert.throws( + () => cliBuildPlan(resolution, 'build', forwardArgs, 'linux'), + (error) => error?.code === 'unsupported_cli_target_dir_override', + ); + } +}); diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index d683eb328e..bd3219bbdf 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -85,7 +85,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech'] }, { depName: 'git2', ownerFeatures: ['git'] }, - { depName: 'hex', ownerFeatures: ['plugin-source', 'remote-connect'] }, + { depName: 'hex', ownerFeatures: ['mcp', 'plugin-source', 'remote-connect'] }, { depName: 'hostname', ownerFeatures: ['remote-connect'] }, { depName: 'image', ownerFeatures: ['remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, @@ -107,7 +107,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'mcp', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 5f8e205ff0..673f8455ba 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -644,6 +644,17 @@ export const externalSourceContractPublicApiEntries = [ 'SecretValue', 'PreparedExternalMcpTransport', 'PreparedExternalMcpServer', + 'PreparedExternalMcpImportTransport', + 'PreparedExternalMcpImportServer', + 'EXTERNAL_MCP_IMPORT_SCHEMA_V1', + 'ExternalMcpImportDispositionV1', + 'ExternalMcpImportPlanItemV1', + 'ExternalMcpImportPlanV1', + 'ExternalMcpImportSelectionV1', + 'ExternalMcpImportApplyRequestV1', + 'ExternalMcpImportedItemV1', + 'ExternalMcpImportApplyOutcomeV1', + 'ExternalMcpImportApplyResultV1', 'ExternalMcpProviderIdentity', 'ExternalMcpProviderSnapshot', 'ExternalMcpSourceProvider', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 1ef44ad220..0657fe6129 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -16,6 +16,9 @@ import { } from 'fs'; import { ensureOpenSslWindows } from './ensure-openssl-windows.mjs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; +import { extractProductConfigArg } from './product-customization/cli.mjs'; +import { productBuildEnvironment } from './product-customization/projections.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -37,7 +40,10 @@ function tauriBuildArgsFromArgv() { } async function main() { - const forward = tauriBuildArgsFromArgv(); + const { productConfig, forwardArgs: forward } = extractProductConfigArg(tauriBuildArgsFromArgv()); + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'desktop' }); + Object.assign(process.env, productBuildEnvironment(resolution)); + console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`); await ensureOpenSslWindows(); const flashgrepBinary = ensureFlashgrepBinary(); @@ -50,6 +56,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + resolution, }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); const tauriArgs = ['build', '--config', tauriConfig, ...forward]; @@ -164,8 +171,19 @@ function optionValue(args, option) { return undefined; } -function prepareTauriConfig(baseConfigPath, { desktopDir, flashgrepBinary }) { +export function prepareTauriConfig( + baseConfigPath, + { desktopDir, flashgrepBinary, resolution } +) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); + if (resolution) { + const productName = + resolution.productNames[resolution.assembly.fallbackLocale] + ?? resolution.productNames[resolution.assembly.defaultLocale]; + config.productName = productName; + config.mainBinaryName = resolution.assembly.binaryName; + config.identifier = resolution.assembly.bundleId; + } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); const enabled = ['1', 'true', 'yes'].includes( @@ -214,7 +232,12 @@ function prepareTauriConfig(baseConfigPath, { desktopDir, flashgrepBinary }) { const generatedDir = join(desktopDir, 'gen'); mkdirSync(generatedDir, { recursive: true }); - const generatedConfig = join(generatedDir, 'tauri.generated.conf.json'); + const generatedConfig = join( + generatedDir, + resolution + ? `tauri.${resolution.assembly.assemblyDigest}.generated.conf.json` + : 'tauri.generated.conf.json', + ); writeFileSync(generatedConfig, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); return generatedConfig; } diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 764416e51d..ebf643d997 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; -import { mkdirSync, rmSync, utimesSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { shouldRetryMacDmgBuild } from './desktop-tauri-build.mjs'; +import { prepareTauriConfig, shouldRetryMacDmgBuild } from './desktop-tauri-build.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; +const ROOT = join(import.meta.dirname, '..'); function retryFixture() { const root = join(tmpdir(), `bitfun-dmg-retry-${process.pid}-${Date.now()}`); @@ -92,3 +94,33 @@ test('does not retry failures outside the narrow DMG bundling boundary', () => { fixture.cleanup(); } }); + +test('Desktop Tauri projection consumes only the resolved member identity', () => { + const fixture = join(tmpdir(), `bitfun-tauri-product-${process.pid}-${Date.now()}`); + mkdirSync(fixture, { recursive: true }); + const baseConfig = join(fixture, 'tauri.conf.json'); + writeFileSync(baseConfig, JSON.stringify({ + productName: 'BitFun', + identifier: 'com.bitfun.desktop', + bundle: { resources: {} }, + })); + try { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'), + member: 'desktop', + }); + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + resolution, + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal(config.productName, 'Acme Desktop'); + assert.equal(config.mainBinaryName, 'acme-desktop'); + assert.equal(config.identifier, 'com.acme.desktop'); + assert.equal(config.bundle.icon, undefined); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); diff --git a/scripts/product-customization/canonical-json.mjs b/scripts/product-customization/canonical-json.mjs new file mode 100644 index 0000000000..d0b49d5b26 --- /dev/null +++ b/scripts/product-customization/canonical-json.mjs @@ -0,0 +1,21 @@ +import { createHash } from 'node:crypto'; + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort((left, right) => left.localeCompare(right, 'en')) + .map((key) => [key, normalize(value[key])]) + ); + } + return value; +} + +export function canonicalJson(value) { + return `${JSON.stringify(normalize(value))}\n`; +} + +export function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/scripts/product-customization/cli.mjs b/scripts/product-customization/cli.mjs new file mode 100644 index 0000000000..962fc93d43 --- /dev/null +++ b/scripts/product-customization/cli.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ProductDefinitionError, resolveProductDefinition } from './resolver.mjs'; + +export function extractProductConfigArg(args) { + const forwardArgs = []; + let productConfig; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const inline = argument.startsWith('--product-config=') + ? argument.slice('--product-config='.length) + : undefined; + if (argument === '--product-config' || inline !== undefined) { + const value = inline ?? args[++index]; + if (!value || value === '--') { + throw new ProductDefinitionError( + 'missing_product_config_value', + '--product-config requires a file path.', + 'Pass --product-config products/example/product.jsonc.', + ); + } + if (productConfig !== undefined) { + throw new ProductDefinitionError( + 'duplicate_product_config', + '--product-config was provided more than once.', + 'Keep one explicit product-definition path.', + ); + } + productConfig = value; + } else { + forwardArgs.push(argument); + } + } + return { productConfig, forwardArgs }; +} + +function diagnosticArgs(args) { + const { productConfig, forwardArgs } = extractProductConfigArg(args); + let member = 'desktop'; + for (let index = 0; index < forwardArgs.length; index += 1) { + const argument = forwardArgs[index]; + if (argument === '--' && index === 0) continue; + if (argument === '--member') member = forwardArgs[++index]; + else if (argument.startsWith('--member=')) member = argument.slice('--member='.length); + else throw new ProductDefinitionError('unknown_diagnostic_option', `Unsupported option: ${argument}`, 'Use only --product-config and --member.'); + } + return { productConfig, member }; +} + +export function explainProduct(resolution) { + return { + definition: resolution.sourcePath, + member: resolution.assembly.member, + binaryName: resolution.assembly.binaryName, + bundleId: resolution.assembly.bundleId, + localizedNames: resolution.productNames, + localeDigest: resolution.assembly.localeDigest, + assemblyDigest: resolution.assembly.assemblyDigest, + implementedScope: 'identity-and-localized-name-c0a', + }; +} + +async function main() { + const rootDir = resolve(import.meta.dirname, '..', '..'); + const [command = 'check', ...args] = process.argv.slice(2); + const { productConfig, member } = diagnosticArgs(args); + const resolution = resolveProductDefinition({ rootDir, productConfig, member }); + if (command === 'check') { + console.log(JSON.stringify({ + ok: true, + member, + assemblyDigest: resolution.assembly.assemblyDigest, + })); + return; + } + if (command === 'explain') { + console.log(JSON.stringify(explainProduct(resolution), null, 2)); + return; + } + throw new ProductDefinitionError('unknown_product_command', `Unknown command: ${command}`, 'Use check or explain.'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + if (error instanceof ProductDefinitionError) { + console.error(JSON.stringify({ ok: false, code: error.code, message: error.message, action: error.action })); + } else { + console.error(error?.stack || error); + } + process.exitCode = 1; + }); +} diff --git a/scripts/product-customization/projections.mjs b/scripts/product-customization/projections.mjs new file mode 100644 index 0000000000..c94fe762f6 --- /dev/null +++ b/scripts/product-customization/projections.mjs @@ -0,0 +1,59 @@ +import { mkdirSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { ProductDefinitionError } from './resolver.mjs'; + +function inside(root, candidate) { + const path = relative(root, candidate); + return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)); +} + +export function ensureProductOutputDirectory(resolution) { + const target = join(resolution.rootDir, 'target'); + mkdirSync(target, { recursive: true }); + const canonicalTarget = realpathSync.native(target); + if (!inside(resolution.rootDir, canonicalTarget)) { + throw new ProductDefinitionError( + 'generated_path_escape', + 'The generated target directory resolves outside the repository.', + 'Remove the escaping link before resolving product output.', + ); + } + const segments = ['product-assembly', resolution.assembly.assemblyDigest, resolution.assembly.member]; + let current = target; + for (const segment of segments) { + current = join(current, segment); + mkdirSync(current, { recursive: true }); + const canonical = realpathSync.native(current); + if (!inside(canonicalTarget, canonical)) { + throw new ProductDefinitionError( + 'generated_path_escape', + 'A generated product directory resolves outside target.', + 'Remove the escaping link before resolving product output.', + ); + } + } + return current; +} + +export function productBuildEnvironment(resolution) { + const fallbackName = + resolution.productNames[resolution.assembly.fallbackLocale] + ?? resolution.productNames[resolution.assembly.defaultLocale]; + const environment = { + BITFUN_PRODUCT_BINARY_NAME: resolution.assembly.binaryName, + BITFUN_PRODUCT_DISPLAY_NAME: fallbackName, + }; + if (!resolution.isDefaultProduct) { + const cargoTargetRoot = process.env.CARGO_TARGET_DIR + ? resolve(resolution.rootDir, process.env.CARGO_TARGET_DIR) + : join(resolution.rootDir, 'target'); + environment.CARGO_TARGET_DIR = join( + cargoTargetRoot, + '.product-cache', + resolution.assembly.assemblyDigest.slice(0, 24), + resolution.assembly.member, + ); + } + return environment; +} diff --git a/scripts/product-customization/projections.test.mjs b/scripts/product-customization/projections.test.mjs new file mode 100644 index 0000000000..4f116678f7 --- /dev/null +++ b/scripts/product-customization/projections.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +import { ensureProductOutputDirectory, productBuildEnvironment } from './projections.mjs'; +import { resolveProductDefinition } from './resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..', '..'); +const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); + +test('CLI package output uses one deterministic bounded product directory', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + const first = ensureProductOutputDirectory(resolution); + const second = ensureProductOutputDirectory(resolution); + + assert.equal(first, second); + assert.ok(existsSync(first)); + assert.match(first, /target[\\/]product-assembly[\\/]/); +}); + +test('build environment isolates custom Cargo output without overriding the default', () => { + const defaultResolution = resolveProductDefinition({ rootDir: ROOT, member: 'cli' }); + const customResolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + const defaultEnvironment = productBuildEnvironment(defaultResolution); + const customEnvironment = productBuildEnvironment(customResolution); + + assert.equal(defaultEnvironment.CARGO_TARGET_DIR, undefined); + assert.match(customEnvironment.CARGO_TARGET_DIR, /target[\\/]\.product-cache[\\/]/); + assert.equal(customEnvironment.BITFUN_PRODUCT_BINARY_NAME, 'acme'); + assert.equal(customEnvironment.BITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); +}); diff --git a/scripts/product-customization/resolver.mjs b/scripts/product-customization/resolver.mjs new file mode 100644 index 0000000000..2f0be09e36 --- /dev/null +++ b/scripts/product-customization/resolver.mjs @@ -0,0 +1,269 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { canonicalJson, sha256 } from './canonical-json.mjs'; + +const BINARY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const BUNDLE_ID = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const WINDOWS_RESERVED_BASE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const ROOT_FIELDS = new Set(['$schema', 'schemaVersion', 'localeRoot', 'members']); +const MEMBERS_FIELDS = new Set(['desktop', 'cli']); +const COMMON_MEMBER_FIELDS = new Set(['displayNameKey', 'binaryName']); +const DESKTOP_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']); + +export class ProductDefinitionError extends Error { + constructor(code, message, action) { + super(`${code}: ${message}\nAction: ${action}`); + this.name = 'ProductDefinitionError'; + this.code = code; + this.action = action; + } +} + +function fail(code, message, action) { + throw new ProductDefinitionError(code, message, action); +} + +export function parseJsonc(source, sourceName = 'product.jsonc') { + let output = ''; + let inString = false; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (let index = 0; index < source.length; index += 1) { + const current = source[index]; + const next = source[index + 1]; + if (lineComment) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\n' || current === '\r') lineComment = false; + } else if (blockComment) { + if (current === '*' && next === '/') { + output += ' '; + index += 1; + blockComment = false; + } else { + output += current === '\n' || current === '\r' ? current : ' '; + } + } else if (inString) { + output += current; + if (escaped) escaped = false; + else if (current === '\\') escaped = true; + else if (current === '"') inString = false; + } else if (current === '"') { + inString = true; + output += current; + } else if (current === '/' && next === '/') { + output += ' '; + index += 1; + lineComment = true; + } else if (current === '/' && next === '*') { + output += ' '; + index += 1; + blockComment = true; + } else { + output += current; + } + } + if (blockComment) fail('invalid_jsonc', `${sourceName} has an unterminated block comment.`, 'Close the block comment.'); + let withoutTrailingCommas = ''; + inString = false; + escaped = false; + for (let index = 0; index < output.length; index += 1) { + const current = output[index]; + if (inString) { + withoutTrailingCommas += current; + if (escaped) escaped = false; + else if (current === '\\') escaped = true; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + withoutTrailingCommas += current; + continue; + } + if (current === ',') { + let next = index + 1; + while (next < output.length && /\s/.test(output[next])) next += 1; + if (output[next] === '}' || output[next] === ']') continue; + } + withoutTrailingCommas += current; + } + try { + return JSON.parse(withoutTrailingCommas); + } catch (error) { + fail('invalid_jsonc', `${sourceName} is not valid JSONC: ${error.message}`, 'Correct the JSON syntax.'); + } +} + +function requireObject(value, owner) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('invalid_field_type', `${owner} must be an object.`, `Set ${owner} to a JSON object.`); + } + return value; +} + +function rejectUnknownFields(value, allowed, owner) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail('unknown_field', `${owner}.${key} is not supported in schema version 1.`, `Remove ${owner}.${key}.`); + } +} + +function requiredString(value, owner) { + if (typeof value !== 'string' || value.trim() === '') { + fail('missing_required_field', `${owner} must be a non-empty string.`, `Set ${owner}.`); + } + return value; +} + +function binaryName(value, owner) { + const result = requiredString(value, owner); + if (!BINARY_NAME.test(result) || result.endsWith('.') || WINDOWS_RESERVED_BASE.test(result)) { + fail('invalid_binary_name', `${owner} is not a safe executable name.`, `Use letters, digits, dot, dash, or underscore for ${owner}.`); + } + return result; +} + +function bundleId(value, owner) { + const result = requiredString(value, owner); + if (!BUNDLE_ID.test(result)) { + fail('invalid_bundle_id', `${owner} is not a reverse-domain identifier.`, `Set ${owner} to a value such as com.example.product.`); + } + return result; +} + +function inside(root, candidate) { + const path = relative(root, candidate); + return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)); +} + +function ownedDirectory(definitionDir, declaredPath) { + const lexical = resolve(definitionDir, requiredString(declaredPath, 'localeRoot')); + if (!inside(definitionDir, lexical)) { + fail('resource_path_escape', 'localeRoot escapes the product definition directory.', 'Keep product locales beside the definition.'); + } + let canonical; + try { + canonical = realpathSync.native(lexical); + } catch { + fail('resource_not_found', `Locale directory does not exist: ${lexical}`, 'Create the locale directory or correct localeRoot.'); + } + if (!inside(definitionDir, canonical)) { + fail('resource_path_escape', 'localeRoot resolves outside the product definition directory.', 'Remove the escaping link.'); + } + if (!statSync(canonical).isDirectory()) fail('invalid_resource_type', 'localeRoot is not a directory.', 'Point it to a directory.'); + return canonical; +} + +function ownedLocaleFile(localeRoot, locale) { + const lexical = join(localeRoot, `${locale}.json`); + let canonical; + try { + canonical = realpathSync.native(lexical); + } catch { + fail('invalid_locale_resource', `Cannot read ${lexical}.`, 'Add valid JSON for every supported locale.'); + } + if (!inside(localeRoot, canonical) || !statSync(canonical).isFile()) { + fail('resource_path_escape', `${locale}.json is not an owned locale file.`, 'Remove the escaping link.'); + } + return canonical; +} + +function validateMember(raw, member) { + const owner = `members.${member}`; + const value = requireObject(raw, owner); + rejectUnknownFields(value, member === 'desktop' ? DESKTOP_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner); + const result = { + displayNameKey: requiredString(value.displayNameKey, `${owner}.displayNameKey`), + binaryName: binaryName(value.binaryName, `${owner}.binaryName`), + }; + if (member === 'desktop') result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`); + return result; +} + +function loadProductNames(rootDir, localeRoot, displayNameKeys) { + const contract = JSON.parse(readFileSync(join(rootDir, 'src', 'shared', 'i18n', 'contract', 'locales.json'), 'utf8')); + const resources = {}; + let expectedKeys; + for (const locale of contract.locales.map(({ id }) => id)) { + const path = ownedLocaleFile(localeRoot, locale); + let terms; + try { + terms = requireObject(JSON.parse(readFileSync(path, 'utf8')), `locale ${locale}`); + } catch (error) { + if (error instanceof ProductDefinitionError) throw error; + fail('invalid_locale_resource', `Cannot read ${path}: ${error.message}`, 'Add valid JSON for every supported locale.'); + } + const keys = Object.keys(terms).sort(); + if (!expectedKeys) expectedKeys = keys; + else if (canonicalJson(keys) !== canonicalJson(expectedKeys)) { + fail('locale_key_mismatch', `${locale} has a different product-name key set.`, 'Use the same keys in every product locale.'); + } + for (const [key, value] of Object.entries(terms)) requiredString(value, `locale ${locale}.${key}`); + resources[locale] = terms; + } + for (const key of displayNameKeys) { + if (!expectedKeys?.includes(key)) fail('missing_product_name_key', `${key} is absent from product locales.`, `Add ${key} to every locale.`); + } + return { + resources, + digest: sha256(canonicalJson(resources)), + defaultLocale: contract.defaultLocale, + fallbackLocale: contract.fallbackLocale, + }; +} + +export function resolveProductDefinition({ rootDir, productConfig, member }) { + if (!['desktop', 'cli'].includes(member)) fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop or cli.'); + const canonicalRoot = realpathSync.native(resolve(rootDir)); + const defaultPath = realpathSync.native(join(canonicalRoot, 'products', 'bitfun', 'product.jsonc')); + const selectedPath = resolve(productConfig || defaultPath); + let sourcePath; + try { + sourcePath = realpathSync.native(selectedPath); + } catch { + fail('definition_not_found', `Product definition does not exist: ${selectedPath}`, 'Pass an existing JSONC file to --product-config.'); + } + const sourceBytes = readFileSync(sourcePath); + const raw = requireObject(parseJsonc(sourceBytes.toString('utf8'), sourcePath), 'product definition'); + rejectUnknownFields(raw, ROOT_FIELDS, 'product definition'); + if (raw.schemaVersion !== 1) fail('unsupported_schema_version', `schemaVersion ${raw.schemaVersion} is unsupported.`, 'Set schemaVersion to 1.'); + + const definitionDir = realpathSync.native(resolve(sourcePath, '..')); + const localeRoot = ownedDirectory(definitionDir, raw.localeRoot); + + const members = requireObject(raw.members, 'members'); + rejectUnknownFields(members, MEMBERS_FIELDS, 'members'); + const normalizedMembers = { + desktop: validateMember(members.desktop, 'desktop'), + cli: validateMember(members.cli, 'cli'), + }; + const locales = loadProductNames( + canonicalRoot, + localeRoot, + [normalizedMembers.desktop.displayNameKey, normalizedMembers.cli.displayNameKey], + ); + const selected = normalizedMembers[member]; + const assemblyContent = { + schemaVersion: 1, + sourceDigest: sha256(sourceBytes), + member, + displayNameKey: selected.displayNameKey, + binaryName: selected.binaryName, + localeDigest: locales.digest, + defaultLocale: locales.defaultLocale, + fallbackLocale: locales.fallbackLocale, + }; + if (selected.bundleId) assemblyContent.bundleId = selected.bundleId; + const assembly = { ...assemblyContent, assemblyDigest: sha256(canonicalJson(assemblyContent)) }; + return { + rootDir: canonicalRoot, + sourcePath, + productNames: Object.fromEntries( + Object.entries(locales.resources).map(([locale, terms]) => [locale, terms[selected.displayNameKey]]), + ), + assembly, + outputDir: join(canonicalRoot, 'target', 'product-assembly', assembly.assemblyDigest, member), + isDefaultProduct: sourcePath === defaultPath, + }; +} diff --git a/scripts/product-customization/resolver.test.mjs b/scripts/product-customization/resolver.test.mjs new file mode 100644 index 0000000000..e47d6be4e8 --- /dev/null +++ b/scripts/product-customization/resolver.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import test from 'node:test'; + +import { ProductDefinitionError, resolveProductDefinition } from './resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..', '..'); +const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); + +test('default and custom members resolve through one deterministic contract', () => { + const bitfun = resolveProductDefinition({ rootDir: ROOT, member: 'desktop' }); + const desktop = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }); + const cli = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); + + assert.equal(bitfun.assembly.binaryName, 'bitfun-desktop'); + assert.equal(desktop.assembly.bundleId, 'com.acme.desktop'); + assert.equal(cli.assembly.binaryName, 'acme'); + assert.equal(cli.assembly.bundleId, undefined); + assert.notEqual(desktop.assembly.assemblyDigest, cli.assembly.assemblyDigest); + assert.equal( + resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }) + .assembly.assemblyDigest, + desktop.assembly.assemblyDigest, + ); +}); + +test('localized names are validated against the shared locale contract', () => { + const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }); + assert.equal(resolution.productNames['en-US'], 'Acme Desktop'); + assert.equal(resolution.productNames['zh-CN'], 'Acme 桌面版'); + assert.match(resolution.assembly.localeDigest, /^[a-f0-9]{64}$/); +}); + +test('schema version one rejects future owner sections instead of pretending to support them', () => { + const directory = mkdtempSync(join(tmpdir(), 'bitfun-product-c0a-')); + const source = readFileSync(ACME, 'utf8').replace( + /\n}\s*$/, + ',\n "assets": { "desktopAppIcon": "icon.png" }\n}\n', + ); + const config = join(directory, 'product.jsonc'); + writeFileSync(config, source, 'utf8'); + + assert.throws( + () => resolveProductDefinition({ rootDir: ROOT, productConfig: config, member: 'desktop' }), + (error) => error instanceof ProductDefinitionError && error.code === 'unknown_field', + ); +}); + +test('locale paths cannot escape the product definition directory', () => { + const directory = mkdtempSync(join(tmpdir(), 'bitfun-product-c0a-')); + const source = readFileSync(ACME, 'utf8').replace('"./locales"', '"../"'); + const config = join(directory, 'product.jsonc'); + writeFileSync(config, source, 'utf8'); + + assert.throws( + () => resolveProductDefinition({ rootDir: ROOT, productConfig: config, member: 'desktop' }), + (error) => error instanceof ProductDefinitionError && error.code === 'resource_path_escape', + ); +}); + +test('invalid member and unsafe binary identity fail with stable codes', () => { + assert.throws( + () => resolveProductDefinition({ rootDir: ROOT, member: 'server' }), + (error) => error instanceof ProductDefinitionError && error.code === 'invalid_member', + ); + + const directory = mkdtempSync(join(tmpdir(), 'bitfun-product-c0a-')); + cpSync(join(ROOT, 'products', 'fixtures', 'acme', 'locales'), join(directory, 'locales'), { + recursive: true, + }); + const source = readFileSync(ACME, 'utf8').replace('"binaryName": "acme"', '"binaryName": "CON"'); + const config = join(directory, 'product.jsonc'); + writeFileSync(config, source, 'utf8'); + assert.throws( + () => resolveProductDefinition({ rootDir: ROOT, productConfig: config, member: 'cli' }), + (error) => error instanceof ProductDefinitionError && error.code === 'invalid_binary_name', + ); +}); diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 1e24398bb2..12ab842c1b 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -20,10 +20,22 @@ bitfun sessions list bitfun usage bitfun doctor bitfun health +bitfun mcp import # preview safe OpenCode / Claude Code MCP declarations +bitfun mcp import --apply # copy eligible declarations as disabled native entries +bitfun mcp import --apply --candidate # repeat to select a subset +bitfun mcp import --apply --candidate --native-id +bitfun mcp import --format json # versioned machine-readable plan/result bitfun update # GitHub first, openbitfun.com fallback bitfun update --check # report only; do not install ``` +`bitfun mcp import` is an explicit snapshot operation, not continuous sync. It +does not copy credentials, headers, environment values, or working directories, +and Codex MCP import is not supported in the current slice. Apply revalidates the +preview and never overwrites an existing native entry; imported entries remain +disabled until reviewed and enabled through the existing MCP manager. Use +`--format json` for the versioned machine-readable plan or result. + Official Linux archive installations check for updates before interactive TUI startup at most once every six hours. That check only fetches the release manifest — a few KB, bounded at ten seconds — and hands the actual archive to a @@ -246,3 +258,19 @@ pnpm run cli:dev # cargo run pnpm run cli:build # cargo build --release pnpm run cli:install # dispatch to install.ps1 on Windows or install.sh on macOS/Linux ``` + +Default builds require no product argument. Product-author and CI builds may +select one validated definition explicitly: + +```bash +pnpm run cli:build -- --product-config path/to/product.jsonc +``` + +Standard Cargo `--target` triples are supported. The wrapper owns the final +target-directory layout; set `CARGO_TARGET_DIR` before invoking the build rather +than passing `--target-dir` or `--config build.target-dir=...`. + +The current customization slice changes only the localized CLI name, binary +name, and build-output isolation. It does not customize installation, user-data +paths, layouts, themes, bundled plugins, updates, or signing; see the +[product customization architecture](../../../docs/architecture/product-customization-blueprint.md). diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 53ca6bf93e..8f2bf235ee 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -20,6 +20,7 @@ mod daemon; mod diagnostics; mod logging; mod management; +mod mcp_import; mod model_selection; mod modes; mod peer_host; @@ -33,11 +34,12 @@ mod ui; use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; use config::CliConfig; +use mcp_import::{McpImportCommand, McpImportOutputFormat}; use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; @@ -258,6 +260,21 @@ enum McpAction { }, /// Print the stored MCP JSON config Config, + /// Preview or explicitly import external MCP declarations + Import { + /// Apply the current plan; without this flag the command is read-only + #[arg(long)] + apply: bool, + /// Import only this eligible candidate; repeat to select multiple + #[arg(long, action = clap::ArgAction::Append, requires = "apply")] + candidate: Vec, + /// Override the native ID; requires exactly one candidate + #[arg(long, requires = "candidate")] + native_id: Option, + /// Output format for automation + #[arg(long, value_enum, default_value_t = McpImportOutputFormat::Text)] + format: McpImportOutputFormat, + }, } #[derive(Subcommand)] @@ -796,7 +813,17 @@ impl std::error::Error for ReportedCliError {} async fn run_cli() -> Result<()> { let raw_args = std::env::args_os().collect::>(); - let cli = match Cli::try_parse_from(&raw_args) { + let product_binary_name = option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or("bitfun"); + let product_display_name = option_env!("BITFUN_PRODUCT_DISPLAY_NAME").unwrap_or("BitFun CLI"); + let parsed = Cli::command() + .name(product_binary_name) + .bin_name(product_binary_name) + .about(format!( + "{product_display_name} - AI agent-driven command-line programming assistant" + )) + .try_get_matches_from(&raw_args) + .and_then(|matches| Cli::from_arg_matches(&matches)); + let cli = match parsed { Ok(cli) => cli, Err(error) if exec_requests_json_output(&raw_args) @@ -939,6 +966,20 @@ async fn run_cli() -> Result<()> { Some(McpAction::Config) => { management::print_mcp_json_config().await?; } + Some(McpAction::Import { + apply, + candidate, + native_id, + format, + }) => { + management::run_mcp_import(McpImportCommand { + apply, + candidates: candidate, + native_id, + format, + }) + .await?; + } }, Some(Commands::Plugins { action }) => match action { diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index bfd7d49aa0..8063748b1b 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -207,11 +207,16 @@ pub(crate) async fn print_mcp_json_config() -> Result<()> { let config_service = ensure_global_config_service().await?; let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) .map_err(|error| anyhow!(error.to_string()))?; - let json = mcp_service.config_service().load_mcp_json_config().await?; - println!("{}", json); + let snapshot = mcp_service.config_service().load_mcp_json_config().await?; + println!("{}", snapshot.json_config); Ok(()) } +pub(crate) async fn run_mcp_import(command: crate::mcp_import::McpImportCommand) -> Result<()> { + let _config_service = ensure_global_config_service().await?; + crate::mcp_import::execute(command).await +} + fn validate_usage_session_id(session_id: &str) -> Result<()> { bitfun_agent_runtime::session_control::validate_session_id(session_id) .map_err(anyhow::Error::msg) diff --git a/src/apps/cli/src/mcp_import.rs b/src/apps/cli/src/mcp_import.rs new file mode 100644 index 0000000000..dcc6ed632d --- /dev/null +++ b/src/apps/cli/src/mcp_import.rs @@ -0,0 +1,150 @@ +use anyhow::{anyhow, Result}; +use bitfun_product_domains::external_sources::{ + ExternalMcpImportApplyOutcomeV1, ExternalMcpImportApplyRequestV1, + ExternalMcpImportDispositionV1, ExternalMcpImportPlanV1, ExternalMcpImportSelectionV1, + EXTERNAL_MCP_IMPORT_SCHEMA_V1, +}; +use clap::ValueEnum; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(crate) enum McpImportOutputFormat { + Text, + Json, +} + +pub(crate) struct McpImportCommand { + pub apply: bool, + pub candidates: Vec, + pub native_id: Option, + pub format: McpImportOutputFormat, +} + +pub(crate) async fn execute(command: McpImportCommand) -> Result<()> { + let workspace = std::env::current_dir().ok().map(PathBuf::from); + let plan = bitfun_core::external_mcp_import::plan_external_mcp_import(workspace.clone()) + .await + .map_err(operation_error)?; + if !command.apply { + return print_value(command.format, &plan, render_plan(&plan)); + } + let selections = selections(&plan, &command.candidates, command.native_id)?; + if selections.is_empty() { + return Err(anyhow!( + "No eligible external MCP servers are available to import" + )); + } + let result = bitfun_core::external_mcp_import::apply_external_mcp_import( + workspace, + ExternalMcpImportApplyRequestV1 { + schema_version: EXTERNAL_MCP_IMPORT_SCHEMA_V1, + plan_fingerprint: plan.plan_fingerprint.clone(), + selections, + }, + ) + .await + .map_err(operation_error)?; + let text = match &result.outcome { + ExternalMcpImportApplyOutcomeV1::Applied { imported } => format!( + "Imported {} MCP server(s) as disabled native entries. Enable them from MCP settings when ready.", + imported.len() + ), + ExternalMcpImportApplyOutcomeV1::Stale { .. } => { + "The import plan changed. Review the refreshed plan and run --apply again.".to_string() + } + }; + print_value(command.format, &result, text) +} + +fn selections( + plan: &ExternalMcpImportPlanV1, + requested: &[String], + native_id: Option, +) -> Result> { + if native_id.is_some() && requested.len() != 1 { + return Err(anyhow!("--native-id requires exactly one --candidate")); + } + let eligible = + |item: &&bitfun_product_domains::external_sources::ExternalMcpImportPlanItemV1| { + matches!( + item.disposition, + ExternalMcpImportDispositionV1::Eligible + | ExternalMcpImportDispositionV1::AutomaticRename + ) + }; + if requested.is_empty() { + return Ok(plan + .items + .iter() + .filter(eligible) + .map(|item| ExternalMcpImportSelectionV1 { + candidate_id: item.candidate_id.clone(), + requested_native_id: None, + }) + .collect()); + } + requested + .iter() + .map(|candidate_id| { + let item = plan + .items + .iter() + .find(|item| &item.candidate_id == candidate_id) + .filter(eligible) + .ok_or_else(|| { + anyhow!("Candidate is not eligible in the current plan: {candidate_id}") + })?; + Ok(ExternalMcpImportSelectionV1 { + candidate_id: item.candidate_id.clone(), + requested_native_id: native_id.clone(), + }) + }) + .collect() +} + +fn render_plan(plan: &ExternalMcpImportPlanV1) -> String { + let eligible = plan + .items + .iter() + .filter(|item| { + matches!( + item.disposition, + ExternalMcpImportDispositionV1::Eligible + | ExternalMcpImportDispositionV1::AutomaticRename + ) + }) + .collect::>(); + let mut lines = vec![format!( + "{} external MCP server(s) can be imported:", + eligible.len() + )]; + for item in eligible { + lines.push(format!( + "- {} -> {}", + crate::plugin_diagnostics::escape_terminal_text(&item.display_name), + crate::plugin_diagnostics::escape_terminal_text( + item.proposed_native_id.as_deref().unwrap_or("unavailable") + ) + )); + } + lines.push("Preview only. Use --apply to write disabled native entries.".to_string()); + lines.join("\n") +} + +fn print_value( + value_format: McpImportOutputFormat, + value: &impl serde::Serialize, + text: String, +) -> Result<()> { + match value_format { + McpImportOutputFormat::Text => println!("{text}"), + McpImportOutputFormat::Json => println!("{}", serde_json::to_string(value)?), + } + Ok(()) +} + +fn operation_error( + error: bitfun_product_domains::external_sources::ExternalSourceOperationError, +) -> anyhow::Error { + anyhow!("{}: {}", error.code.as_str(), error.detail) +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index cb214f6a6b..7e1b45dd75 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -16,6 +16,9 @@ use bitfun_core::external_sources::{ NativePromptCommandDescriptor, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_product_domains::external_sources::{ + ExternalMcpImportApplyRequestV1, ExternalMcpImportApplyResultV1, ExternalMcpImportPlanV1, +}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -163,11 +166,45 @@ pub struct ChooseExternalMcpConflictRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PlanExternalMcpImportRequest { + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ApplyExternalMcpImportRequest { + pub workspace_path: Option, + pub import_request: ExternalMcpImportApplyRequestV1, +} + pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; pub type ExpandExternalPromptCommandResponse = ExpandedPromptCommand; pub type NativePromptCommandConflictsResponse = NativePromptCommandConflictSnapshot; +#[tauri::command] +pub async fn plan_external_mcp_import_command( + request: PlanExternalMcpImportRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()) + .await? + .map(Path::to_path_buf); + bitfun_core::external_mcp_import::plan_external_mcp_import(workspace).await +} + +#[tauri::command] +pub async fn apply_external_mcp_import_command( + request: ApplyExternalMcpImportRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()) + .await? + .map(Path::to_path_buf); + bitfun_core::external_mcp_import::apply_external_mcp_import(workspace, request.import_request) + .await +} + pub(super) async fn require_local_workspace( workspace_path: Option<&str>, ) -> ExternalSourceOperationResult> { @@ -518,4 +555,35 @@ mod tests { .is_err() ); } + + #[test] + fn desktop_external_mcp_import_requests_use_structured_sanitized_shapes() { + let plan: PlanExternalMcpImportRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "D:/workspace/project" + })) + .unwrap(); + assert_eq!(plan.workspace_path.as_deref(), Some("D:/workspace/project")); + + let apply: ApplyExternalMcpImportRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": null, + "importRequest": { + "schemaVersion": 1, + "planFingerprint": "sha256:plan-v1", + "selections": [{ + "candidateId": "external_mcp:17:opencode.commands6:global4:docs", + "requestedNativeId": "docs" + }] + } + })) + .unwrap(); + assert_eq!(apply.import_request.selections.len(), 1); + + assert!( + serde_json::from_value::(serde_json::json!({ + "workspacePath": null, + "rawSource": { "args": ["secret"] } + })) + .is_err() + ); + } } diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index 96a81b0a37..bfb59d6727 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -475,7 +475,9 @@ pub async fn get_mcp_server_status( } #[tauri::command] -pub async fn load_mcp_json_config(state: State<'_, AppState>) -> Result { +pub async fn load_mcp_json_config( + state: State<'_, AppState>, +) -> Result { let mcp_service = state .mcp_service .as_ref() @@ -492,6 +494,7 @@ pub async fn load_mcp_json_config(state: State<'_, AppState>) -> Result, json_config: String, + expected_fingerprint: String, ) -> Result<(), String> { let mcp_service = state .mcp_service @@ -500,7 +503,7 @@ pub async fn save_mcp_json_config( mcp_service .config_service() - .save_mcp_json_config(&json_config) + .save_mcp_json_config(&json_config, &expected_fingerprint) .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 d53b2676e5..20a8287ad8 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -153,6 +153,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "apply_external_mcp_import_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "apply_external_source_control_action_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1126,6 +1130,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("peer_mode_ping", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "plan_external_mcp_import_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "predownload_acp_client_adapter", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1838,6 +1846,20 @@ mod tests { ); } + #[test] + fn external_mcp_import_commands_explicitly_reject_remote_workspaces() { + for command in [ + "plan_external_mcp_import_command", + "apply_external_mcp_import_command", + ] { + assert_eq!( + remote_workspace_policy(command), + Some(RemoteWorkspacePolicy::RemoteUnsupported), + "{command} must never fall back to the controller's local MCP config" + ); + } + } + #[test] fn external_source_control_web_command_is_registered() { const COMMAND: &str = "get_external_source_control_snapshot"; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b717de8779..7b9011f5bd 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1171,6 +1171,8 @@ pub async fn run() { api::editor_ai_api::editor_ai_cancel, get_external_hook_catalog, get_external_source_snapshot, + plan_external_mcp_import_command, + apply_external_mcp_import_command, reveal_external_source_location, get_external_source_control_snapshot, apply_external_source_control_action_command, diff --git a/src/crates/adapters/claude-code-adapter/src/mcp_source.rs b/src/crates/adapters/claude-code-adapter/src/mcp_source.rs index 3cda21bb2f..cdc48fa91f 100644 --- a/src/crates/adapters/claude-code-adapter/src/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/mcp_source.rs @@ -4,8 +4,8 @@ use bitfun_product_domains::external_sources::{ ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, - PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, - SourceQualifiedMcpServerId, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, }; use bitfun_static_hook_support::{ read_bounded_text, redacted_executable_preview, resolve_bounded_regular_file, @@ -236,33 +236,14 @@ impl ClaudeCodeMcpProvider { .map_err(|error| provider_error("snapshot_invalid", &error.to_string(), false))?; Ok(MaterializedSnapshot { snapshot, prepared }) } -} -impl Default for ClaudeCodeMcpProvider { - fn default() -> Self { - Self::new(ClaudeCodeMcpProviderOptions::default()) - } -} - -impl ExternalMcpSourceProvider for ClaudeCodeMcpProvider { - fn identity(&self) -> ExternalMcpProviderIdentity { - ExternalMcpProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "Claude Code") - .expect("static Claude Code MCP provider identity must be valid") - } - - fn discover( - &self, - input: &ExternalMcpDiscoveryInput, - ) -> Result { - self.materialize(input).map(|result| result.snapshot) - } - - fn prepare_server( + fn current_preparation( &self, input: &ExternalMcpDiscoveryInput, server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, - ) -> Result { + ) -> Result<(ExternalMcpServerDefinition, PreparedTransportTemplate), ExternalSourceProviderError> + { if server_id.source.provider_id.as_str() != PROVIDER_ID { return Err(provider_error( "identity_mismatch", @@ -276,6 +257,7 @@ impl ExternalMcpSourceProvider for ClaudeCodeMcpProvider { .servers .iter() .find(|definition| &definition.id == server_id) + .cloned() .ok_or_else(|| { provider_error( "stale_revision", @@ -286,31 +268,75 @@ impl ExternalMcpSourceProvider for ClaudeCodeMcpProvider { if definition.behavior_version != expected_behavior_version { return Err(provider_error( "stale_revision", - "MCP server behavior changed before activation", + "MCP server behavior changed before preparation", true, )); } - if !matches!(definition.static_status, ExternalMcpStaticStatus::Ready) { + if !definition.source_enabled + || !matches!(definition.static_status, ExternalMcpStaticStatus::Ready) + { return Err(provider_error( "not_activatable", - "MCP server is unsupported or invalid", + "MCP server is disabled or unsupported", false, )); } - let template = materialized + let prepared = materialized .prepared .get(&server_id.stable_key()) .cloned() .ok_or_else(|| { provider_error( "preparation_missing", - "MCP runtime preparation is unavailable", + "MCP preparation is unavailable", false, ) })?; + Ok((definition, prepared)) + } +} + +impl Default for ClaudeCodeMcpProvider { + fn default() -> Self { + Self::new(ClaudeCodeMcpProviderOptions::default()) + } +} + +impl ExternalMcpSourceProvider for ClaudeCodeMcpProvider { + fn identity(&self) -> ExternalMcpProviderIdentity { + ExternalMcpProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "Claude Code") + .expect("static Claude Code MCP provider identity must be valid") + } + + fn discover( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + self.materialize(input).map(|result| result.snapshot) + } + + fn prepare_server( + &self, + input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + let (_, template) = + self.current_preparation(input, server_id, expected_behavior_version)?; prepare_transport(template, server_id.clone(), expected_behavior_version) } + fn prepare_import( + &self, + input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + let (definition, template) = + self.current_preparation(input, server_id, expected_behavior_version)?; + prepare_import_projection(definition, template) + } + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { let mut roots = BTreeMap::new(); if let Some(parent) = self.options.user_config_file.parent() { @@ -855,6 +881,47 @@ fn prepare_transport( }) } +fn prepare_import_projection( + definition: ExternalMcpServerDefinition, + template: PreparedTransportTemplate, +) -> Result { + let transport = match template { + PreparedTransportTemplate::Local { + command, + args, + environment, + working_directory, + } if environment.is_empty() && working_directory.is_none() => { + PreparedExternalMcpImportTransport::Local { command, args } + } + PreparedTransportTemplate::Remote { url, headers } + if headers.is_empty() && import_safe_https_url(&url) => + { + PreparedExternalMcpImportTransport::Remote { url } + } + _ => { + return Err(ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP declaration contains fields that cannot be imported safely", + false, + )); + } + }; + let prepared = PreparedExternalMcpImportServer { + id: definition.id, + behavior_version: definition.behavior_version, + transport, + }; + prepared.validate().map_err(|_| { + ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP declaration contains fields that cannot be imported safely", + false, + ) + })?; + Ok(prepared) +} + fn string_array(value: Option<&Value>) -> Result, String> { let Some(value) = value else { return Ok(Vec::new()); @@ -976,6 +1043,17 @@ fn sanitized_https_origin(value: &str) -> Result { Ok(url.to_string()) } +fn import_safe_https_url(value: &str) -> bool { + url::Url::parse(value).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) +} + fn provider_error(suffix: &str, message: &str, transient: bool) -> ExternalSourceProviderError { ExternalSourceProviderError::new(format!("claude.mcp.{suffix}"), message, transient) } diff --git a/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs b/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs index 7c515ad54c..433a66e261 100644 --- a/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs @@ -2,7 +2,7 @@ use bitfun_claude_code_adapter::{ClaudeCodeMcpProvider, ClaudeCodeMcpProviderOpt use bitfun_product_domains::external_sources::{ ExecutionDomainId, ExternalMcpDiscoveryInput, ExternalMcpRevisionKey, ExternalMcpSourceProvider, ExternalMcpStaticStatus, ExternalMcpTransportKind, - ExternalSourceContext, ExternalSourceScope, + ExternalSourceContext, ExternalSourceScope, PreparedExternalMcpImportTransport, }; use std::collections::BTreeSet; use std::fs; @@ -187,6 +187,81 @@ fn discovery_is_static_and_public_projection_redacts_runtime_values() { assert_eq!(error.code, "claude.mcp.environment_missing"); } +#[test] +fn safe_servers_have_a_native_import_projection_and_unsafe_fields_require_setup() { + let fixture = Fixture::new(); + write( + &fixture.user_config, + r#"{"mcpServers":{ + "local":{"command":"docs-mcp"}, + "remote":{"type":"http","url":"https://docs.example.test/mcp"}, + "args":{"command":"docs-mcp","args":["--stdio"]}, + "env":{"command":"docs-mcp","env":{"TOKEN":"secret"}}, + "cwd":{"command":"docs-mcp","cwd":"tools"}, + "query":{"type":"http","url":"https://docs.example.test/mcp?token=secret"}, + "headers":{"type":"http","url":"https://docs.example.test/mcp","headers":{"Authorization":"secret"}} + }}"#, + ); + let provider = fixture.provider(); + let input = fixture.input(); + let snapshot = provider.discover(&input).unwrap(); + + let local = snapshot + .servers + .iter() + .find(|server| server.name == "local") + .unwrap(); + let prepared_local = provider + .prepare_import(&input, &local.id, &local.behavior_version) + .unwrap(); + assert!(matches!( + prepared_local.transport, + PreparedExternalMcpImportTransport::Local { ref command, ref args } + if command == "docs-mcp" && args.is_empty() + )); + + let remote = snapshot + .servers + .iter() + .find(|server| server.name == "remote") + .unwrap(); + let prepared_remote = provider + .prepare_import(&input, &remote.id, &remote.behavior_version) + .unwrap(); + assert!(matches!( + prepared_remote.transport, + PreparedExternalMcpImportTransport::Remote { ref url } + if url == "https://docs.example.test/mcp" + )); + + let args = snapshot + .servers + .iter() + .find(|server| server.name == "args") + .unwrap(); + let prepared_args = provider + .prepare_import(&input, &args.id, &args.behavior_version) + .unwrap(); + assert!(matches!( + prepared_args.transport, + PreparedExternalMcpImportTransport::Local { ref args, .. } + if args == &["--stdio"] + )); + + for name in ["env", "cwd", "query", "headers"] { + let server = snapshot + .servers + .iter() + .find(|server| server.name == name) + .unwrap(); + let error = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err(); + assert_eq!(error.code, "external_mcp.import_setup_required"); + assert!(!error.message.contains("secret")); + } +} + #[test] fn public_working_directory_uses_a_workspace_relative_or_redacted_label() { let fixture = Fixture::new(); diff --git a/src/crates/adapters/codex-adapter/tests/mcp_source.rs b/src/crates/adapters/codex-adapter/tests/mcp_source.rs index 5d5e89a6d0..c5e4735b4f 100644 --- a/src/crates/adapters/codex-adapter/tests/mcp_source.rs +++ b/src/crates/adapters/codex-adapter/tests/mcp_source.rs @@ -177,6 +177,26 @@ X-Env = "CODEX_MCP_MISSING_HEADER" assert_eq!(error.code, "codex.mcp.environment_missing"); } +#[test] +fn native_import_remains_explicitly_unsupported_for_codex_in_c0a() { + let fixture = Fixture::new(); + write( + fixture.codex_home.join("config.toml"), + r#"[mcp_servers.docs] +command = "docs-mcp" +"#, + ); + let provider = fixture.provider(); + let input = fixture.input(); + let snapshot = provider.discover(&input).unwrap(); + let server = &snapshot.servers[0]; + + let error = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err(); + assert_eq!(error.code, "external_mcp.import_unsupported"); +} + #[test] fn unsupported_runtime_controls_block_but_required_only_warns() { let fixture = Fixture::new(); diff --git a/src/crates/adapters/opencode-adapter/src/mcp_source.rs b/src/crates/adapters/opencode-adapter/src/mcp_source.rs index 20c46f996b..00706342d1 100644 --- a/src/crates/adapters/opencode-adapter/src/mcp_source.rs +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -4,8 +4,8 @@ use bitfun_product_domains::external_sources::{ ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, - PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, - SourceQualifiedMcpServerId, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, }; use bitfun_static_hook_support::{read_bounded_text, BoundedTextRead}; use serde_json::{Map, Value}; @@ -21,6 +21,15 @@ 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; +const LOCAL_FIELDS: &[&str] = &[ + "type", + "command", + "environment", + "enabled", + "timeout", + "cwd", +]; +const REMOTE_FIELDS: &[&str] = &["type", "url", "headers", "oauth", "enabled", "timeout"]; #[derive(Debug, Clone)] pub struct OpenCodeMcpProviderOptions { @@ -269,34 +278,14 @@ impl OpenCodeMcpProvider { })?; 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( + fn current_preparation( &self, input: &ExternalMcpDiscoveryInput, server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, - ) -> Result { + ) -> Result<(ExternalMcpServerDefinition, PreparedTransportTemplate), ExternalSourceProviderError> + { if server_id.source.provider_id.as_str() != PROVIDER_ID { return Err(ExternalSourceProviderError::new( "opencode.mcp.identity_mismatch", @@ -310,6 +299,7 @@ impl ExternalMcpSourceProvider for OpenCodeMcpProvider { .servers .iter() .find(|definition| &definition.id == server_id) + .cloned() .ok_or_else(|| { ExternalSourceProviderError::new( "opencode.mcp.stale_revision", @@ -320,7 +310,7 @@ impl ExternalMcpSourceProvider for OpenCodeMcpProvider { if definition.behavior_version != expected_behavior_version { return Err(ExternalSourceProviderError::new( "opencode.mcp.stale_revision", - "MCP server behavior changed before activation", + "MCP server behavior changed before preparation", true, )); } @@ -340,13 +330,56 @@ impl ExternalMcpSourceProvider for OpenCodeMcpProvider { .ok_or_else(|| { ExternalSourceProviderError::new( "opencode.mcp.preparation_missing", - "MCP runtime preparation is unavailable", + "MCP preparation is unavailable", false, ) })?; + Ok((definition, 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 { + let (_, prepared) = + self.current_preparation(input, server_id, expected_behavior_version)?; resolve_runtime_values(prepared, server_id.clone(), expected_behavior_version) } + fn prepare_import( + &self, + input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + let (definition, prepared) = + self.current_preparation(input, server_id, expected_behavior_version)?; + prepare_import_projection(definition, prepared) + } + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { let mut roots = BTreeMap::new(); add_directory_watch_roots(&mut roots, &self.options.user_config_dir); @@ -389,6 +422,7 @@ enum PreparedTransportTemplate { args: Vec, environment: BTreeMap, working_directory: Option, + working_directory_explicit: bool, }, Remote { url: String, @@ -481,6 +515,7 @@ fn materialize_server( args: Vec::new(), environment: BTreeMap::new(), working_directory: None, + working_directory_explicit: false, }, }) } @@ -497,10 +532,8 @@ fn materialize_local_server( behavior_version: String, ) -> Result { let command_parts = string_array(object.get("command")); - let mut reason = command_parts - .as_ref() - .err() - .cloned() + let mut reason = unsupported_field_reason(object, LOCAL_FIELDS) + .or_else(|| 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(); @@ -523,6 +556,7 @@ fn materialize_local_server( reason.get_or_insert(error.clone()); } let environment_reference_names = environment_reference_names.unwrap_or_default(); + let working_directory_explicit = object.contains_key("cwd"); let cwd = match object.get("cwd") { None => context .workspace_root @@ -591,6 +625,7 @@ fn materialize_local_server( args, environment, working_directory: cwd, + working_directory_explicit, }, }) } @@ -608,8 +643,9 @@ fn materialize_remote_server( .and_then(Value::as_str) .unwrap_or_default() .to_string(); - let mut reason = - timeout_unsupported_reason(object).or_else(|| unsupported_variable_reason(object)); + let mut reason = unsupported_field_reason(object, REMOTE_FIELDS) + .or_else(|| timeout_unsupported_reason(object)) + .or_else(|| unsupported_variable_reason(object)); let preview_url = match sanitized_https_url(&raw_url) { Ok(url) => url, Err(error) => { @@ -698,6 +734,7 @@ fn resolve_runtime_values( args, environment, working_directory, + working_directory_explicit: _, } => { let command = expand_environment_references(&command)?; let args = args @@ -777,6 +814,50 @@ fn resolve_runtime_values( }) } +fn prepare_import_projection( + definition: ExternalMcpServerDefinition, + template: PreparedTransportTemplate, +) -> Result { + let transport = match template { + PreparedTransportTemplate::Local { + command, + args, + environment, + working_directory: _, + working_directory_explicit, + } if environment.is_empty() && !working_directory_explicit => { + PreparedExternalMcpImportTransport::Local { command, args } + } + PreparedTransportTemplate::Remote { + url, + headers, + oauth_enabled, + } if headers.is_empty() && oauth_enabled && import_safe_https_url(&url) => { + PreparedExternalMcpImportTransport::Remote { url } + } + _ => { + return Err(ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP declaration contains fields that cannot be imported safely", + false, + )); + } + }; + let prepared = PreparedExternalMcpImportServer { + id: definition.id, + behavior_version: definition.behavior_version, + transport, + }; + prepared.validate().map_err(|_| { + ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP declaration contains fields that cannot be imported safely", + false, + ) + })?; + Ok(prepared) +} + fn replace_environment_references( value: &str, mut resolve: impl FnMut(&str) -> Result, @@ -854,6 +935,20 @@ fn timeout_unsupported_reason(object: &Map) -> Option { } } +fn unsupported_field_reason(object: &Map, supported: &[&str]) -> Option { + let fields = object + .keys() + .filter(|field| !supported.contains(&field.as_str())) + .cloned() + .collect::>(); + (!fields.is_empty()).then(|| { + format!( + "OpenCode MCP fields are not supported: {}", + fields.join(", ") + ) + }) +} + fn unsupported_variable_reason(object: &Map) -> Option { let encoded = serde_json::to_string(object).ok()?; if encoded.contains("{file:") { @@ -933,6 +1028,17 @@ fn sanitized_https_url(value: &str) -> Result { Ok(url.to_string()) } +fn import_safe_https_url(value: &str) -> bool { + url::Url::parse(value).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) +} + struct ConfigLayer { path: PathBuf, scope: ExternalSourceScope, diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs index 067f80e801..6bfa23fabd 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs @@ -2,7 +2,8 @@ use bitfun_opencode_adapter::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; use bitfun_product_domains::external_sources::{ ExecutionDomainId, ExternalMcpDiscoveryInput, ExternalMcpRevisionKey, ExternalMcpSourceProvider, ExternalMcpStaticStatus, ExternalMcpTransportKind, - ExternalSourceContext, ExternalSourceScope, PreparedExternalMcpTransport, + ExternalSourceContext, ExternalSourceScope, PreparedExternalMcpImportTransport, + PreparedExternalMcpTransport, }; use std::collections::BTreeSet; use std::fs; @@ -187,6 +188,135 @@ fn local_server_without_cwd_uses_the_workspace_like_opencode() { } } +#[test] +fn safe_user_servers_have_a_native_import_projection_without_runtime_resolution() { + 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":["docs-mcp","--stdio"]}, + "remote":{"type":"remote","url":"https://docs.example.test/mcp"} + }}"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user)); + let input = ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + revision_key: revision_key(), + }; + let snapshot = provider.discover(&input).unwrap(); + let local = snapshot + .servers + .iter() + .find(|server| server.name == "local") + .unwrap(); + let remote = snapshot + .servers + .iter() + .find(|server| server.name == "remote") + .unwrap(); + + let prepared_local = provider + .prepare_import(&input, &local.id, &local.behavior_version) + .unwrap(); + match prepared_local.transport { + PreparedExternalMcpImportTransport::Local { command, args } => { + assert_eq!(command, "docs-mcp"); + assert_eq!(args, ["--stdio"]); + } + other => panic!("expected local import, got {other:?}"), + } + let prepared_remote = provider + .prepare_import(&input, &remote.id, &remote.behavior_version) + .unwrap(); + match prepared_remote.transport { + PreparedExternalMcpImportTransport::Remote { url } => { + assert_eq!(url, "https://docs.example.test/mcp"); + } + other => panic!("expected remote import, got {other:?}"), + } +} + +#[test] +fn unsafe_user_servers_require_setup_instead_of_copying_opaque_fields() { + 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":{ + "env":{"type":"local","command":["docs-mcp"],"environment":{"TOKEN":"secret"}}, + "cwd":{"type":"local","command":["docs-mcp"],"cwd":"tools"}, + "query":{"type":"remote","url":"https://docs.example.test/mcp?token=secret"}, + "headers":{"type":"remote","url":"https://docs.example.test/mcp","headers":{"Authorization":"secret"}}, + "no-oauth":{"type":"remote","url":"https://docs.example.test/mcp","oauth":false} + }}"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user)); + let input = ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + revision_key: revision_key(), + }; + let snapshot = provider.discover(&input).unwrap(); + + for server in &snapshot.servers { + let error = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err(); + assert_eq!(error.code, "external_mcp.import_setup_required"); + assert!(!error.message.contains("secret")); + assert!(!error.message.contains("--opaque")); + } +} + +#[test] +fn unknown_local_and_remote_fields_fail_closed_before_import() { + 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":["docs-mcp"],"providerMode":"opaque"}, + "remote":{"type":"remote","url":"https://docs.example.test/mcp","providerMode":"opaque"} + }}"#, + ) + .unwrap(); + + let provider = OpenCodeMcpProvider::new(options(user)); + let input = ExternalMcpDiscoveryInput { + context: context(project), + suppressed_sources: BTreeSet::new(), + revision_key: revision_key(), + }; + let snapshot = provider.discover(&input).unwrap(); + + for server in &snapshot.servers { + assert!(matches!( + server.static_status, + ExternalMcpStaticStatus::Unsupported { .. } + )); + let error = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err(); + assert_eq!(error.code, "opencode.mcp.not_activatable"); + assert!(!error.message.contains("opaque")); + } +} + #[test] fn suppression_recomputes_the_opencode_merge_and_stale_prepare_fails_closed() { let temp = TempDir::new().unwrap(); diff --git a/src/crates/assembly/core/src/external_mcp_import.rs b/src/crates/assembly/core/src/external_mcp_import.rs new file mode 100644 index 0000000000..6b15429531 --- /dev/null +++ b/src/crates/assembly/core/src/external_mcp_import.rs @@ -0,0 +1,571 @@ +//! Thin orchestration for explicitly copying external MCP declarations into +//! the existing native user configuration owner. + +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalMcpImportApplyOutcomeV1, ExternalMcpImportApplyRequestV1, + ExternalMcpImportApplyResultV1, ExternalMcpImportDispositionV1, ExternalMcpImportPlanItemV1, + ExternalMcpImportPlanV1, ExternalMcpImportedItemV1, ExternalMcpServerDefinition, + ExternalMcpStaticStatus, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, PreparedExternalMcpImportServer, + PreparedExternalMcpImportTransport, EXTERNAL_MCP_IMPORT_SCHEMA_V1, +}; +use bitfun_services_integrations::mcp::config::{ + MCPImportError, MCPImportServer, MCPImportTransport, MCPUserImportSnapshot, +}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExternalMcpImportPreparation { + Prepared(PreparedExternalMcpImportServer), + Unavailable(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExternalMcpImportCandidate { + pub definition: ExternalMcpServerDefinition, + pub ecosystem_id: EcosystemId, + pub preparation: ExternalMcpImportPreparation, +} + +struct ComputedPlan { + public: ExternalMcpImportPlanV1, + target_fingerprint: String, + target_native_ids: BTreeSet, + prepared: BTreeMap, +} + +const MAX_IMPORT_PLAN_ITEMS: usize = 256; +const MAX_NATIVE_ID_BYTES: usize = 160; + +pub async fn plan_external_mcp_import( + workspace_root: Option, +) -> ExternalSourceOperationResult { + let config_service = mcp_config_service().await?; + Ok(compute_current_plan(workspace_root, &config_service) + .await? + .public) +} + +pub async fn apply_external_mcp_import( + workspace_root: Option, + request: ExternalMcpImportApplyRequestV1, +) -> ExternalSourceOperationResult { + request.validate().map_err(|_| { + operation_error( + ExternalSourceOperationErrorCode::InvalidRequest, + "The MCP import request is invalid", + false, + ) + })?; + let config_service = mcp_config_service().await?; + let computed = compute_current_plan(workspace_root.clone(), &config_service).await?; + if request.plan_fingerprint != computed.public.plan_fingerprint { + return Ok(stale_result(computed.public)); + } + let imports = match selected_imports(&computed, &request) { + Ok(imports) => imports, + Err(SelectionError::Stale) => return Ok(stale_result(computed.public)), + Err(SelectionError::Invalid) => { + return Err(operation_error( + ExternalSourceOperationErrorCode::InvalidRequest, + "The requested MCP native id is invalid", + false, + )); + } + }; + let imported = imports + .iter() + .map(|import| ExternalMcpImportedItemV1 { + candidate_id: import.candidate_id.clone(), + native_id: import.native_id.clone(), + }) + .collect(); + match config_service + .apply_user_import(&computed.target_fingerprint, imports) + .await + { + Ok(()) => Ok(ExternalMcpImportApplyResultV1 { + schema_version: EXTERNAL_MCP_IMPORT_SCHEMA_V1, + outcome: ExternalMcpImportApplyOutcomeV1::Applied { imported }, + }), + Err(MCPImportError::StaleConfiguration | MCPImportError::TargetConflict { .. }) => { + let refreshed = compute_current_plan(workspace_root, &config_service).await?; + Ok(stale_result(refreshed.public)) + } + Err(error) => Err(map_import_error(error)), + } +} + +async fn mcp_config_service( +) -> ExternalSourceOperationResult { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|_| { + operation_error( + ExternalSourceOperationErrorCode::Unavailable, + "The MCP configuration service is unavailable", + true, + ) + })?; + crate::service::mcp::config::MCPConfigService::new(config_service).map_err(|_| { + operation_error( + ExternalSourceOperationErrorCode::Unavailable, + "The MCP configuration service is unavailable", + true, + ) + }) +} + +async fn compute_current_plan( + workspace_root: Option, + config_service: &crate::service::mcp::config::MCPConfigService, +) -> ExternalSourceOperationResult { + let workspace_root = ensure_local_workspace(workspace_root).await?; + let candidates = + crate::external_sources::collect_external_mcp_import_candidates(workspace_root.as_deref()) + .await + .map_err(|_| { + operation_error( + ExternalSourceOperationErrorCode::Unavailable, + "External MCP sources could not be refreshed", + true, + ) + })?; + if candidates.len() > MAX_IMPORT_PLAN_ITEMS { + return Err(operation_error( + ExternalSourceOperationErrorCode::Unsupported, + "External MCP import has too many candidates to review safely", + false, + )); + } + let target = config_service + .user_import_snapshot() + .await + .map_err(map_import_error)?; + Ok(build_import_plan(&target, candidates)) +} + +async fn ensure_local_workspace( + workspace_root: Option, +) -> ExternalSourceOperationResult> { + if let Some(root) = workspace_root.as_ref() { + if crate::service::remote_ssh::workspace_state::is_remote_path( + root.to_string_lossy().as_ref(), + ) + .await + { + return Err(operation_error( + ExternalSourceOperationErrorCode::Unsupported, + "External MCP import is not available for a remote workspace", + false, + )); + } + } + Ok(workspace_root) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionError { + Stale, + Invalid, +} + +fn selected_imports( + current: &ComputedPlan, + request: &ExternalMcpImportApplyRequestV1, +) -> Result, SelectionError> { + let items = current + .public + .items + .iter() + .map(|item| (item.candidate_id.as_str(), item)) + .collect::>(); + let mut native_ids = BTreeSet::new(); + let mut imports = Vec::with_capacity(request.selections.len()); + for selection in &request.selections { + let item = items + .get(selection.candidate_id.as_str()) + .ok_or(SelectionError::Stale)?; + if !matches!( + item.disposition, + ExternalMcpImportDispositionV1::Eligible + | ExternalMcpImportDispositionV1::AutomaticRename + ) { + return Err(SelectionError::Stale); + } + let native_id = selection + .requested_native_id + .clone() + .or_else(|| item.proposed_native_id.clone()) + .ok_or(SelectionError::Stale)?; + if !valid_native_id(&native_id) { + return Err(SelectionError::Invalid); + } + if current.target_native_ids.contains(&native_id) || !native_ids.insert(native_id.clone()) { + return Err(SelectionError::Invalid); + } + let prepared = current + .prepared + .get(&selection.candidate_id) + .ok_or(SelectionError::Stale)?; + imports.push(MCPImportServer { + native_id, + candidate_id: selection.candidate_id.clone(), + behavior_version: prepared.behavior_version.clone(), + display_name: item.display_name.clone(), + transport: match &prepared.transport { + PreparedExternalMcpImportTransport::Local { command, args } => { + MCPImportTransport::Local { + command: command.clone(), + args: args.clone(), + } + } + PreparedExternalMcpImportTransport::Remote { url } => { + MCPImportTransport::Remote { url: url.clone() } + } + }, + }); + } + Ok(imports) +} + +fn build_import_plan( + target: &MCPUserImportSnapshot, + mut candidates: Vec, +) -> ComputedPlan { + candidates.sort_by(|left, right| left.definition.id.cmp(&right.definition.id)); + let mut reserved = target.native_ids.clone(); + let mut prepared = BTreeMap::new(); + let mut items = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let candidate_id = candidate.definition.candidate_id(); + let already_imported = target.imports.iter().any(|import| { + import.candidate_id == candidate_id + && import.behavior_version == candidate.definition.behavior_version + }); + let (proposed_native_id, disposition, reason_code) = if already_imported { + (None, ExternalMcpImportDispositionV1::AlreadyImported, None) + } else if !candidate.definition.source_enabled + || !matches!( + candidate.definition.static_status, + ExternalMcpStaticStatus::Ready + ) + { + ( + None, + ExternalMcpImportDispositionV1::Unavailable, + Some("external_mcp.import_unavailable".to_string()), + ) + } else { + match candidate.preparation { + ExternalMcpImportPreparation::Unavailable(reason) => ( + None, + ExternalMcpImportDispositionV1::Unavailable, + Some(bounded_reason_code(&reason)), + ), + ExternalMcpImportPreparation::Prepared(value) => { + let (native_id, renamed) = reserve_native_id( + &candidate.definition.name, + candidate.ecosystem_id.as_str(), + &mut reserved, + ); + prepared.insert(candidate_id.clone(), value); + ( + Some(native_id), + if renamed { + ExternalMcpImportDispositionV1::AutomaticRename + } else { + ExternalMcpImportDispositionV1::Eligible + }, + None, + ) + } + } + }; + items.push(ExternalMcpImportPlanItemV1 { + candidate_id, + display_name: candidate.definition.name, + transport: candidate.definition.transport, + proposed_native_id, + disposition, + reason_code, + }); + } + let mut public = ExternalMcpImportPlanV1 { + schema_version: EXTERNAL_MCP_IMPORT_SCHEMA_V1, + plan_fingerprint: String::new(), + items, + }; + public.plan_fingerprint = plan_fingerprint(target, &public, &prepared); + ComputedPlan { + public, + target_fingerprint: target.fingerprint.clone(), + target_native_ids: target.native_ids.clone(), + prepared, + } +} + +fn reserve_native_id( + raw_base: &str, + ecosystem: &str, + reserved: &mut BTreeSet, +) -> (String, bool) { + let base = bounded_native_id(raw_base); + let normalized = base != raw_base; + if reserved.insert(base.clone()) { + return (base, normalized); + } + let suffix = native_id_suffix(ecosystem); + for index in 1_u32.. { + let tail = if index == 1 { + suffix.clone() + } else { + format!("{suffix}-{index}") + }; + let candidate = bounded_renamed_id(&base, &tail); + if reserved.insert(candidate.clone()) { + return (candidate, true); + } + } + unreachable!("native id suffix space cannot be exhausted") +} + +fn bounded_native_id(value: &str) -> String { + if value.len() <= MAX_NATIVE_ID_BYTES { + return value.to_string(); + } + let digest = hex::encode(Sha256::digest(value.as_bytes())); + let tail = format!("-{}", &digest[..12]); + format!( + "{}{}", + truncate_utf8(value, MAX_NATIVE_ID_BYTES - tail.len()), + tail + ) +} + +fn bounded_renamed_id(base: &str, suffix: &str) -> String { + let full = format!("{base}-{suffix}"); + if full.len() <= MAX_NATIVE_ID_BYTES { + return full; + } + let digest = hex::encode(Sha256::digest(full.as_bytes())); + let tail = format!("-{}-{}", truncate_utf8(suffix, 24), &digest[..12]); + format!( + "{}{}", + truncate_utf8(base, MAX_NATIVE_ID_BYTES - tail.len()), + tail + ) +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> &str { + let mut end = value.len().min(max_bytes); + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn native_id_suffix(ecosystem: &str) -> String { + let value = ecosystem + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let value = value + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + if value.is_empty() { + "external".to_string() + } else { + value + } +} + +fn bounded_reason_code(value: &str) -> String { + if value.is_empty() || value.len() > 160 || value.chars().any(char::is_control) { + "external_mcp.import_unavailable".to_string() + } else { + value.to_string() + } +} + +fn valid_native_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_NATIVE_ID_BYTES + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn plan_fingerprint( + target: &MCPUserImportSnapshot, + plan: &ExternalMcpImportPlanV1, + prepared: &BTreeMap, +) -> String { + let mut facts = plan.clone(); + facts.plan_fingerprint.clear(); + let mut hasher = Sha256::new(); + hash_part(&mut hasher, target.fingerprint.as_bytes()); + hash_part( + &mut hasher, + &serde_json::to_vec(&facts).expect("MCP import plan serialization cannot fail"), + ); + for (candidate_id, server) in prepared { + hash_part(&mut hasher, candidate_id.as_bytes()); + hash_part(&mut hasher, server.behavior_version.as_bytes()); + match &server.transport { + PreparedExternalMcpImportTransport::Local { command, args } => { + hash_part(&mut hasher, command.as_bytes()); + for argument in args { + hash_part(&mut hasher, argument.as_bytes()); + } + } + PreparedExternalMcpImportTransport::Remote { url } => { + hash_part(&mut hasher, url.as_bytes()); + } + } + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn hash_part(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn stale_result(plan: ExternalMcpImportPlanV1) -> ExternalMcpImportApplyResultV1 { + ExternalMcpImportApplyResultV1 { + schema_version: EXTERNAL_MCP_IMPORT_SCHEMA_V1, + outcome: ExternalMcpImportApplyOutcomeV1::Stale { + refreshed_plan: plan, + }, + } +} + +fn map_import_error(error: MCPImportError) -> ExternalSourceOperationError { + match error { + MCPImportError::InvalidRequest(_) => operation_error( + ExternalSourceOperationErrorCode::InvalidRequest, + "The MCP import request is invalid", + false, + ), + MCPImportError::UnsupportedTargetFormat => operation_error( + ExternalSourceOperationErrorCode::Unsupported, + "The user MCP configuration format cannot be updated safely", + false, + ), + MCPImportError::StaleConfiguration => operation_error( + ExternalSourceOperationErrorCode::StaleRevision, + "The user MCP configuration changed; refresh and retry", + true, + ), + MCPImportError::TargetConflict { .. } => operation_error( + ExternalSourceOperationErrorCode::Conflict, + "An MCP native id became unavailable; refresh and retry", + true, + ), + MCPImportError::Store(_) => operation_error( + ExternalSourceOperationErrorCode::Internal, + "The user MCP configuration could not be updated", + true, + ), + } +} + +fn operation_error( + code: ExternalSourceOperationErrorCode, + detail: &'static str, + retryable: bool, +) -> ExternalSourceOperationError { + ExternalSourceOperationError::new(code, detail, retryable).with_default_recovery_actions() +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::external_sources::{ + ExternalMcpTransportKind, SourceKey, SourceQualifiedMcpServerId, + }; + + fn candidate(command: &str) -> ExternalMcpImportCandidate { + let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); + let id = SourceQualifiedMcpServerId::new(source.clone(), "docs").unwrap(); + ExternalMcpImportCandidate { + definition: ExternalMcpServerDefinition { + id: id.clone(), + provenance: vec![source], + name: "docs".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("docs-mcp".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: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }, + ecosystem_id: EcosystemId::new("opencode").unwrap(), + preparation: ExternalMcpImportPreparation::Prepared(PreparedExternalMcpImportServer { + id, + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Local { + command: command.to_string(), + args: Vec::new(), + }, + }), + } + } + + fn target(ids: &[&str]) -> MCPUserImportSnapshot { + MCPUserImportSnapshot { + fingerprint: "sha256:target".to_string(), + native_ids: ids.iter().map(|id| (*id).to_string()).collect(), + imports: Vec::new(), + } + } + + #[test] + fn plan_renames_conflicts_without_exposing_private_preparation() { + let plan = build_import_plan(&target(&["docs"]), vec![candidate("private-command")]); + assert_eq!( + plan.public.items[0].disposition, + ExternalMcpImportDispositionV1::AutomaticRename + ); + assert_eq!( + plan.public.items[0].proposed_native_id.as_deref(), + Some("docs-opencode") + ); + let encoded = serde_json::to_string(&plan.public).unwrap(); + assert!(!encoded.contains("private-command")); + } + + #[test] + fn private_projection_changes_invalidate_the_public_plan_fingerprint() { + let first = build_import_plan(&target(&[]), vec![candidate("command-a")]); + let second = build_import_plan(&target(&[]), vec![candidate("command-b")]); + assert_ne!( + first.public.plan_fingerprint, + second.public.plan_fingerprint + ); + } + + #[test] + fn long_source_names_are_bounded_and_reported_as_automatic_renames() { + let (native_id, renamed) = + reserve_native_id(&"文".repeat(200), "opencode", &mut BTreeSet::new()); + assert!(renamed); + assert!(native_id.len() <= MAX_NATIVE_ID_BYTES); + assert!(valid_native_id(&native_id)); + } +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 4ee464e840..6dfab5fd4b 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -71,8 +71,9 @@ use bitfun_product_domains::external_integration_policy::{ EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR, }; use bitfun_product_domains::external_sources::{ - ExecutionDomainId, ExternalMcpRevisionKey, ExternalMcpSourceProvider, ExternalSourceContext, - ExternalSourceScope, ExternalToolSourceProvider, PromptCommandSourceProvider, + ExecutionDomainId, ExternalMcpRevisionKey, ExternalMcpSourceProvider, ExternalMcpStaticStatus, + ExternalSourceContext, ExternalSourceScope, ExternalToolSourceProvider, + PromptCommandSourceProvider, }; use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; use bitfun_services_core::json_store::JsonFileStore; @@ -3533,6 +3534,51 @@ async fn service_for( service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution).await } +pub(crate) async fn collect_external_mcp_import_candidates( + workspace_root: Option<&Path>, +) -> Result, String> { + let service = read_only_service_for(workspace_root).await?; + service.refresh().await?; + let coordinator = lock_mcp_coordinator(&service.control_plane); + let snapshot = coordinator.snapshot(); + let input_candidates = snapshot + .servers + .iter() + .cloned() + .map(|definition| { + let ecosystem_id = coordinator + .ecosystem_for_provider(&definition.id.source.provider_id) + .ok_or_else(|| "External MCP provider ecosystem is unavailable".to_string())?; + let preparation = if definition.source_enabled + && matches!(definition.static_status, ExternalMcpStaticStatus::Ready) + { + match coordinator + .prepare_import_guarded(&definition.id, &definition.behavior_version) + { + Ok(prepared) => { + crate::external_mcp_import::ExternalMcpImportPreparation::Prepared(prepared) + } + Err(error) => { + crate::external_mcp_import::ExternalMcpImportPreparation::Unavailable( + error.code, + ) + } + } + } else { + crate::external_mcp_import::ExternalMcpImportPreparation::Unavailable( + "external_mcp.import_candidate_unsupported".to_string(), + ) + }; + Ok(crate::external_mcp_import::ExternalMcpImportCandidate { + definition, + ecosystem_id, + preparation, + }) + }) + .collect::, String>>()?; + Ok(input_candidates) +} + async fn read_only_service_for( workspace_root: Option<&Path>, ) -> Result, String> { diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 0ec1e22d49..882ae93df3 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -13,6 +13,8 @@ pub mod external_hooks; mod external_hooks_tests; #[cfg(feature = "product-full")] mod external_mcp; +#[cfg(feature = "product-full")] +pub mod external_mcp_import; #[cfg(all(test, feature = "product-full"))] mod external_mcp_tests; #[cfg(feature = "product-full")] diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index d028dfa554..99486e0e16 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -117,6 +117,28 @@ impl ConfigService { Ok(()) } + /// Atomically replaces one JSON configuration value when its current value + /// still matches the caller's snapshot. The read, comparison, and persisted + /// write share the existing manager write lock. + pub(crate) async fn compare_and_set_json_config( + &self, + path: &str, + expected: Option, + replacement: serde_json::Value, + ) -> BitFunResult { + let mut manager = self.manager.write().await; + let current = match manager.get::(path) { + Ok(value) => Some(value), + Err(BitFunError::NotFound(_)) => None, + Err(error) => return Err(error), + }; + if current != expected { + return Ok(false); + } + manager.set(path, replacement).await?; + Ok(true) + } + fn path_touches_models(path: &str) -> bool { path == "ai" || path.starts_with("ai.models") @@ -705,6 +727,33 @@ mod tests { (service, dir) } + #[tokio::test] + async fn compare_and_set_json_config_rejects_a_stale_snapshot() { + let (service, _dir) = test_service("config-cas").await; + assert!(service + .compare_and_set_json_config( + "mcp_servers", + None, + serde_json::json!({ "mcpServers": { "first": {} } }), + ) + .await + .unwrap()); + assert!(!service + .compare_and_set_json_config( + "mcp_servers", + None, + serde_json::json!({ "mcpServers": { "stale": {} } }), + ) + .await + .unwrap()); + let current = service + .get_config::(Some("mcp_servers")) + .await + .unwrap(); + assert!(current["mcpServers"].get("first").is_some()); + assert!(current["mcpServers"].get("stale").is_none()); + } + #[tokio::test] async fn reconcile_models_repairs_image_understanding_default_to_capable_model() { let (service, _dir) = test_service("vision-default-repair").await; diff --git a/src/crates/assembly/core/src/service/mcp/config/json_config.rs b/src/crates/assembly/core/src/service/mcp/config/json_config.rs index 59c74c4a9b..9e95b37038 100644 --- a/src/crates/assembly/core/src/service/mcp/config/json_config.rs +++ b/src/crates/assembly/core/src/service/mcp/config/json_config.rs @@ -1,31 +1,38 @@ -use bitfun_services_integrations::mcp::config::{ - format_mcp_json_config_value, validate_mcp_json_config, -}; +use bitfun_services_integrations::mcp::config::{validate_mcp_json_config, MCPImportError}; use log::{debug, error, info}; +use serde::Serialize; use crate::util::errors::{BitFunError, BitFunResult}; use super::service::MCPConfigService; +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MCPJsonConfigSnapshot { + pub json_config: String, + pub fingerprint: String, +} + impl MCPConfigService { /// Loads MCP JSON config (Cursor format). - pub async fn load_mcp_json_config(&self) -> BitFunResult { - match self - .config_service - .get_config::(Some("mcp_servers")) + pub async fn load_mcp_json_config(&self) -> BitFunResult { + let snapshot = self + .inner + .user_json_config_snapshot() .await - { - Ok(value) => format_mcp_json_config_value(Some(&value)).map_err(|e| { - BitFunError::serialization(format!("Failed to serialize MCP config: {}", e)) - }), - Err(_) => format_mcp_json_config_value(None).map_err(|e| { - BitFunError::serialization(format!("Failed to serialize MCP config: {}", e)) - }), - } + .map_err(|error| BitFunError::config(format!("Failed to load MCP config: {error}")))?; + Ok(MCPJsonConfigSnapshot { + json_config: snapshot.json_config, + fingerprint: snapshot.fingerprint, + }) } /// Saves MCP JSON config (Cursor format). - pub async fn save_mcp_json_config(&self, json_config: &str) -> BitFunResult<()> { + pub async fn save_mcp_json_config( + &self, + json_config: &str, + expected_fingerprint: &str, + ) -> BitFunResult<()> { debug!("Saving MCP JSON config to app.json"); let config_value: serde_json::Value = serde_json::from_str(json_config).map_err(|e| { @@ -40,18 +47,15 @@ impl MCPConfigService { BitFunError::validation(error_msg) })?; - self.config_service - .set_config("mcp_servers", config_value) + self.inner + .replace_user_json_config(expected_fingerprint, config_value) .await .map_err(|e| { let error_msg = match e { - BitFunError::Io(ref io_err) => { - format!("Failed to write config file: {}", io_err) - } - BitFunError::Serialization(ref ser_err) => { - format!("Failed to serialize config: {}", ser_err) + MCPImportError::StaleConfiguration => { + "MCP configuration changed; reload before saving".to_string() } - _ => format!("Failed to save config: {}", e), + _ => format!("Failed to save config: {e}"), }; error!("{}", error_msg); BitFunError::config(error_msg) diff --git a/src/crates/assembly/core/src/service/mcp/config/mod.rs b/src/crates/assembly/core/src/service/mcp/config/mod.rs index bdd0ce6325..78c30b2826 100644 --- a/src/crates/assembly/core/src/service/mcp/config/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/config/mod.rs @@ -4,5 +4,6 @@ mod json_config; mod location; mod service; +pub use json_config::MCPJsonConfigSnapshot; pub use location::ConfigLocation; pub use service::MCPConfigService; 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 7d907aa4d1..626a4513bb 100644 --- a/src/crates/assembly/core/src/service/mcp/config/service.rs +++ b/src/crates/assembly/core/src/service/mcp/config/service.rs @@ -6,8 +6,7 @@ use crate::service::mcp::server::MCPServerConfig; use crate::util::errors::BitFunResult; pub struct MCPConfigService { - pub(super) config_service: Arc, - inner: bitfun_services_integrations::mcp::config::MCPConfigService, + pub(super) inner: bitfun_services_integrations::mcp::config::MCPConfigService, } struct CoreMCPConfigStore { @@ -26,7 +25,12 @@ impl bitfun_services_integrations::mcp::config::MCPConfigStore for CoreMCPConfig .await { Ok(value) => Ok(Some(value)), - Err(_) => Ok(None), + Err(crate::util::errors::BitFunError::NotFound(_)) => Ok(None), + Err(error) => Err( + bitfun_services_integrations::mcp::MCPRuntimeError::configuration( + error.to_string(), + ), + ), } } @@ -42,6 +46,20 @@ impl bitfun_services_integrations::mcp::config::MCPConfigStore for CoreMCPConfig bitfun_services_integrations::mcp::MCPRuntimeError::configuration(e.to_string()) }) } + + async fn compare_and_set_config_value( + &self, + key: &str, + expected: Option, + replacement: serde_json::Value, + ) -> bitfun_services_integrations::mcp::MCPRuntimeResult { + self.config_service + .compare_and_set_json_config(key, expected, replacement) + .await + .map_err(|error| { + bitfun_services_integrations::mcp::MCPRuntimeError::configuration(error.to_string()) + }) + } } impl MCPConfigService { @@ -72,11 +90,8 @@ impl MCPConfigService { } pub fn new(config_service: Arc) -> BitFunResult { - let store = Arc::new(CoreMCPConfigStore { - config_service: config_service.clone(), - }); + let store = Arc::new(CoreMCPConfigStore { config_service }); Ok(Self { - config_service, inner: bitfun_services_integrations::mcp::config::MCPConfigService::new(store), }) } @@ -117,6 +132,25 @@ impl MCPConfigService { pub async fn delete_server_config(&self, server_id: &str) -> BitFunResult<()> { Ok(self.inner.delete_server_config(server_id).await?) } + + pub async fn user_import_snapshot( + &self, + ) -> Result< + bitfun_services_integrations::mcp::config::MCPUserImportSnapshot, + bitfun_services_integrations::mcp::config::MCPImportError, + > { + self.inner.user_import_snapshot().await + } + + pub async fn apply_user_import( + &self, + expected_fingerprint: &str, + imports: Vec, + ) -> Result<(), bitfun_services_integrations::mcp::config::MCPImportError> { + self.inner + .apply_user_import(expected_fingerprint, imports) + .await + } } #[cfg(test)] diff --git a/src/crates/assembly/external-sources/src/mcp.rs b/src/crates/assembly/external-sources/src/mcp.rs index de10f89cd8..c4d8d9ac58 100644 --- a/src/crates/assembly/external-sources/src/mcp.rs +++ b/src/crates/assembly/external-sources/src/mcp.rs @@ -4,8 +4,8 @@ use bitfun_product_domains::external_sources::{ ExternalMcpSourceProvider, ExternalMcpStaticStatus, ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, - ExternalWatchRoot, PreparedExternalMcpServer, ProviderId, SourceKey, - SourceQualifiedMcpServerId, + ExternalWatchRoot, PreparedExternalMcpImportServer, PreparedExternalMcpServer, ProviderId, + SourceKey, SourceQualifiedMcpServerId, }; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -277,6 +277,50 @@ impl ExternalMcpCoordinator { server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, ) -> Result { + let (provider, input) = + self.guarded_preparation(server_id, expected_behavior_version, "activation")?; + let prepared = + provider + .provider + .prepare_server(&input, 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 prepare_import_guarded( + &self, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + let (provider, input) = + self.guarded_preparation(server_id, expected_behavior_version, "import")?; + let prepared = + provider + .provider + .prepare_import(&input, server_id, expected_behavior_version)?; + if prepared.id != *server_id || prepared.behavior_version != expected_behavior_version { + return Err(ExternalSourceProviderError::new( + "external_mcp.prepared_import_mismatch", + "MCP provider prepared a different import revision", + false, + )); + } + Ok(prepared) + } + + fn guarded_preparation( + &self, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + purpose: &'static str, + ) -> Result<(&McpProviderGeneration, ExternalMcpDiscoveryInput), ExternalSourceProviderError> + { let current = self.snapshot.servers.iter().find(|server| { &server.id == server_id && server.behavior_version == expected_behavior_version }); @@ -291,7 +335,7 @@ impl ExternalMcpCoordinator { { return Err(ExternalSourceProviderError::new( "external_mcp.server_unavailable", - "MCP server configuration is not available for activation", + format!("MCP server configuration is not available for {purpose}"), false, )); } @@ -306,23 +350,14 @@ impl ExternalMcpCoordinator { false, ) })?; - let prepared = provider.provider.prepare_server( - &ExternalMcpDiscoveryInput { + Ok(( + provider, + ExternalMcpDiscoveryInput { context: self.context.clone(), suppressed_sources: self.suppressed_source_keys(), revision_key: self.revision_key.clone(), }, - 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 { diff --git a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs index e332c4671f..07933f0681 100644 --- a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs +++ b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs @@ -5,8 +5,8 @@ use bitfun_product_domains::external_sources::{ ExternalMcpSourceProvider, ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceContext, ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, - PreparedExternalMcpServer, PreparedExternalMcpTransport, ProviderId, SourceKey, - SourceQualifiedMcpServerId, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, ProviderId, SourceKey, SourceQualifiedMcpServerId, }; use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; @@ -140,6 +140,21 @@ impl ExternalMcpSourceProvider for FakeProvider { }) } + fn prepare_import( + &self, + _input: &ExternalMcpDiscoveryInput, + server_id: &SourceQualifiedMcpServerId, + expected_behavior_version: &str, + ) -> Result { + Ok(PreparedExternalMcpImportServer { + id: server_id.clone(), + behavior_version: expected_behavior_version.to_string(), + transport: PreparedExternalMcpImportTransport::Remote { + url: "https://example.test/mcp".to_string(), + }, + }) + } + fn watch_roots(&self, _context: &ExternalSourceContext) -> Vec { vec![ ExternalWatchRoot { @@ -226,6 +241,36 @@ fn coordinator_passes_suppression_to_the_provider_and_guards_preparation_revisio .expect("current approved revision can be prepared"); } +#[test] +fn coordinator_guards_import_preparation_by_current_revision_and_availability() { + let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); + let provider_trait: Arc = provider.clone(); + let mut coordinator = + ExternalMcpCoordinator::new(context(), revision_key(), vec![provider_trait]).unwrap(); + let first = coordinator.refresh(); + let server = first.servers[0].clone(); + let source_key = first.sources[0].stable_key.clone(); + + let stale = coordinator + .prepare_import_guarded(&server.id, "behavior-v0") + .unwrap_err(); + assert_eq!(stale.code, "external_mcp.stale_revision"); + + coordinator.set_source_enabled(&source_key, false).unwrap(); + let unavailable = coordinator + .prepare_import_guarded(&server.id, &server.behavior_version) + .unwrap_err(); + assert_eq!(unavailable.code, "external_mcp.stale_revision"); + + coordinator.set_source_enabled(&source_key, true).unwrap(); + coordinator.refresh(); + let prepared = coordinator + .prepare_import_guarded(&server.id, &server.behavior_version) + .expect("current import revision can be prepared"); + assert_eq!(prepared.id, server.id); + assert_eq!(prepared.behavior_version, server.behavior_version); +} + #[test] fn coordinator_keeps_last_success_for_a_known_unavailable_source() { let provider = Arc::new(FakeProvider::new(snapshot("project", "behavior-v1"))); diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 150e08d2d0..1d607872de 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -813,6 +813,182 @@ pub struct PreparedExternalMcpServer { pub transport: PreparedExternalMcpTransport, } +/// Private, provider-owned projection for copying a supported external MCP +/// declaration into native user configuration. It is intentionally not +/// serializable so command arguments and URLs cannot cross the product API. +#[derive(Clone, PartialEq, Eq)] +pub enum PreparedExternalMcpImportTransport { + Local { command: String, args: Vec }, + Remote { url: String }, +} + +impl fmt::Debug for PreparedExternalMcpImportTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local { args, .. } => formatter + .debug_struct("Local") + .field("command", &"[REDACTED]") + .field("argument_count", &args.len()) + .finish(), + Self::Remote { .. } => formatter + .debug_struct("Remote") + .field("url", &"[REDACTED]") + .finish(), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PreparedExternalMcpImportServer { + pub id: SourceQualifiedMcpServerId, + pub behavior_version: String, + pub transport: PreparedExternalMcpImportTransport, +} + +impl fmt::Debug for PreparedExternalMcpImportServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedExternalMcpImportServer") + .field("id", &self.id) + .field("behavior_version", &self.behavior_version) + .field("transport", &self.transport) + .finish() + } +} + +impl PreparedExternalMcpImportServer { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_text( + &self.behavior_version, + "prepared MCP import behavior version", + )?; + match &self.transport { + PreparedExternalMcpImportTransport::Local { command, args } => { + validate_text(command, "prepared MCP import command")?; + if args.len() > 256 { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared MCP import argument count", + )); + } + for argument in args { + validate_text(argument, "prepared MCP import argument")?; + } + } + PreparedExternalMcpImportTransport::Remote { url } => { + validate_text(url, "prepared MCP import URL")?; + } + } + Ok(()) + } +} + +pub const EXTERNAL_MCP_IMPORT_SCHEMA_V1: u16 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalMcpImportDispositionV1 { + Eligible, + AutomaticRename, + AlreadyImported, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportPlanItemV1 { + pub candidate_id: String, + pub display_name: String, + pub transport: ExternalMcpTransportKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proposed_native_id: Option, + pub disposition: ExternalMcpImportDispositionV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportPlanV1 { + pub schema_version: u16, + pub plan_fingerprint: String, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportSelectionV1 { + pub candidate_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_native_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportApplyRequestV1 { + pub schema_version: u16, + pub plan_fingerprint: String, + pub selections: Vec, +} + +impl ExternalMcpImportApplyRequestV1 { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + if self.schema_version != EXTERNAL_MCP_IMPORT_SCHEMA_V1 + || self.selections.is_empty() + || self.selections.len() > 256 + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external MCP import request", + )); + } + validate_text( + &self.plan_fingerprint, + "external MCP import plan fingerprint", + )?; + let mut candidate_ids = BTreeSet::new(); + for selection in &self.selections { + validate_text(&selection.candidate_id, "external MCP import candidate id")?; + if !candidate_ids.insert(&selection.candidate_id) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external MCP import selection", + )); + } + if let Some(native_id) = &selection.requested_native_id { + validate_text(native_id, "external MCP native id")?; + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportedItemV1 { + pub candidate_id: String, + pub native_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ExternalMcpImportApplyOutcomeV1 { + Applied { + imported: Vec, + }, + Stale { + refreshed_plan: ExternalMcpImportPlanV1, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpImportApplyResultV1 { + pub schema_version: u16, + pub outcome: ExternalMcpImportApplyOutcomeV1, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalMcpProviderIdentity { @@ -903,6 +1079,19 @@ pub trait ExternalMcpSourceProvider: Send + Sync { expected_behavior_version: &str, ) -> Result; + fn prepare_import( + &self, + _input: &ExternalMcpDiscoveryInput, + _server_id: &SourceQualifiedMcpServerId, + _expected_behavior_version: &str, + ) -> Result { + Err(ExternalSourceProviderError::new( + "external_mcp.import_unsupported", + "This MCP provider does not expose an import-safe projection", + false, + )) + } + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; } 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 b5ab585eef..fcc7d7cdd7 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -15,20 +15,21 @@ use bitfun_product_domains::external_sources::{ external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, - ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, - ExternalMcpProviderSnapshot, ExternalMcpRevisionKey, ExternalMcpServerDefinition, - ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, - ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, - ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, - ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, + ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpImportApplyRequestV1, + ExternalMcpImportSelectionV1, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, + ExternalMcpRevisionKey, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry, + ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceHostCapabilities, ExternalSourceLifecycleState, + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceProviderError, + ExternalSourcePublicSnapshot, ExternalSourceRecord, ExternalSourceScope, + ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, - PreparedExternalMcpServer, PreparedExternalMcpTransport, PromptCommandAvailability, - PromptCommandCatalogEntry, PromptCommandDefinition, PromptCommandProviderIdentity, - PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, - SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, - SourceQualifiedToolTargetId, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, + 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, @@ -54,6 +55,36 @@ fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { assert!(descriptor.validate().is_err()); } +#[test] +fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { + let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); + let prepared = PreparedExternalMcpImportServer { + id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Local { + command: "secret-command".to_string(), + args: vec!["secret-argument".to_string()], + }, + }; + let debug = format!("{prepared:?}"); + assert!(!debug.contains("secret-command")); + assert!(!debug.contains("secret-argument")); + prepared.validate().unwrap(); + + let request = ExternalMcpImportApplyRequestV1 { + schema_version: 1, + plan_fingerprint: "sha256:plan-v1".to_string(), + selections: vec![ExternalMcpImportSelectionV1 { + candidate_id: "opencode:mcp:docs".to_string(), + requested_native_id: None, + }], + }; + request.validate().unwrap(); + let encoded = serde_json::to_string(&request).unwrap(); + assert!(!encoded.contains("command")); + assert!(!encoded.contains("argument")); +} + fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { ExternalSourceRecord { key: SourceKey::new(provider_id, source_id).expect("valid source key"), diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 8aef730c71..6603d4a72c 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -59,6 +59,7 @@ tokio-util = { workspace = true, optional = true } tokio-tungstenite = { workspace = true, optional = true } urlencoding = { workspace = true, optional = true } uuid = { workspace = true, optional = true } +url = { workspace = true, optional = true } which = { workspace = true, optional = true } dirs = { workspace = true, optional = true } russh = { workspace = true, optional = true } @@ -109,11 +110,14 @@ mcp = [ "bitfun-agent-tools", "bitfun-services-core", "futures", + "hex", "rand", "reqwest", "rmcp", "rmcp/transport-streamable-http-client-reqwest", + "sha2", "sse-stream", + "url", "process-tree", ] miniapp-runtime = [ diff --git a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs index e95bb74f7f..9a4935d001 100644 --- a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs +++ b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs @@ -67,6 +67,13 @@ pub fn config_to_cursor_format(config: &MCPServerConfig) -> serde_json::Value { cursor_config.insert("command".to_string(), serde_json::json!(command)); } + if let Some(inherit) = config.inherit_parent_environment { + cursor_config.insert( + "inheritParentEnvironment".to_string(), + serde_json::json!(inherit), + ); + } + if !config.args.is_empty() { cursor_config.insert("args".to_string(), serde_json::json!(config.args)); } @@ -212,6 +219,10 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { .and_then(|v| v.as_bool()) .unwrap_or(true); + let inherit_parent_environment = obj + .get("inheritParentEnvironment") + .and_then(|value| value.as_bool()); + let server_config = MCPServerConfig { id: server_id.clone(), name, @@ -221,7 +232,7 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { args, env, working_directory: None, - inherit_parent_environment: None, + inherit_parent_environment, headers, url, auto_start, diff --git a/src/crates/services/services-integrations/src/mcp/config/import.rs b/src/crates/services/services-integrations/src/mcp/config/import.rs new file mode 100644 index 0000000000..2053cfb01b --- /dev/null +++ b/src/crates/services/services-integrations/src/mcp/config/import.rs @@ -0,0 +1,365 @@ +//! Atomic external MCP snapshot import into the existing user configuration. + +use super::service::MCPConfigService; +use crate::mcp::{MCPRuntimeError, MCPRuntimeResult}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; + +const USER_CONFIG_KEY: &str = "mcp_servers"; +const IMPORT_METADATA_KEY: &str = "_bitfunImport"; +const MAX_IMPORT_SERVERS: usize = 256; +const MAX_IMPORT_ID_BYTES: usize = 512; +const MAX_IMPORT_TEXT_BYTES: usize = 4096; +const USER_MUTATION_ATTEMPTS: usize = 3; + +#[derive(Clone, PartialEq, Eq)] +pub enum MCPImportTransport { + Local { command: String, args: Vec }, + Remote { url: String }, +} + +impl fmt::Debug for MCPImportTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local { args, .. } => formatter + .debug_struct("Local") + .field("command", &"[REDACTED]") + .field("argument_count", &args.len()) + .finish(), + Self::Remote { .. } => formatter + .debug_struct("Remote") + .field("url", &"[REDACTED]") + .finish(), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct MCPImportServer { + pub native_id: String, + pub candidate_id: String, + pub behavior_version: String, + pub display_name: String, + pub transport: MCPImportTransport, +} + +impl fmt::Debug for MCPImportServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MCPImportServer") + .field("native_id", &self.native_id) + .field("candidate_id", &self.candidate_id) + .field("behavior_version", &self.behavior_version) + .field("display_name", &self.display_name) + .field("transport", &self.transport) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MCPImportedServerSummary { + pub native_id: String, + pub candidate_id: String, + pub behavior_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MCPUserImportSnapshot { + pub fingerprint: String, + pub native_ids: BTreeSet, + pub imports: Vec, +} + +pub struct MCPUserJsonConfigSnapshot { + pub json_config: String, + pub fingerprint: String, +} + +#[derive(Debug)] +pub enum MCPImportError { + InvalidRequest(&'static str), + UnsupportedTargetFormat, + StaleConfiguration, + TargetConflict { native_id: String }, + Store(MCPRuntimeError), +} + +impl fmt::Display for MCPImportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRequest(label) => write!(formatter, "Invalid MCP import {label}"), + Self::UnsupportedTargetFormat => { + formatter.write_str("User MCP configuration is not an object") + } + Self::StaleConfiguration => { + formatter.write_str("User MCP configuration changed before write") + } + Self::TargetConflict { native_id } => { + write!(formatter, "MCP server id already exists: {native_id}") + } + Self::Store(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for MCPImportError {} + +impl From for MCPImportError { + fn from(error: MCPRuntimeError) -> Self { + Self::Store(error) + } +} + +impl MCPConfigService { + pub async fn user_json_config_snapshot( + &self, + ) -> Result { + let current = self.config_store.get_config_value(USER_CONFIG_KEY).await?; + let json_config = + super::format_mcp_json_config_value(current.as_ref()).map_err(|error| { + MCPImportError::Store(MCPRuntimeError::configuration(error.to_string())) + })?; + Ok(MCPUserJsonConfigSnapshot { + fingerprint: config_fingerprint(¤t), + json_config, + }) + } + + pub async fn replace_user_json_config( + &self, + expected_fingerprint: &str, + replacement: Value, + ) -> Result<(), MCPImportError> { + validate_id(expected_fingerprint, "fingerprint")?; + let current = self.config_store.get_config_value(USER_CONFIG_KEY).await?; + if config_fingerprint(¤t) != expected_fingerprint { + return Err(MCPImportError::StaleConfiguration); + } + if !self + .config_store + .compare_and_set_config_value(USER_CONFIG_KEY, current, replacement) + .await? + { + return Err(MCPImportError::StaleConfiguration); + } + Ok(()) + } + + pub async fn user_import_snapshot(&self) -> Result { + let current = self.config_store.get_config_value(USER_CONFIG_KEY).await?; + let servers = cursor_servers(¤t)?; + let mut imports = servers + .iter() + .filter_map(|(native_id, server)| import_summary(native_id, server)) + .collect::>(); + imports.sort_by(|left, right| left.native_id.cmp(&right.native_id)); + Ok(MCPUserImportSnapshot { + fingerprint: config_fingerprint(¤t), + native_ids: servers.keys().cloned().collect(), + imports, + }) + } + + pub async fn apply_user_import( + &self, + expected_fingerprint: &str, + imports: Vec, + ) -> Result<(), MCPImportError> { + validate_id(expected_fingerprint, "fingerprint")?; + if imports.is_empty() || imports.len() > MAX_IMPORT_SERVERS { + return Err(MCPImportError::InvalidRequest("server count")); + } + let mut native_ids = BTreeSet::new(); + let mut candidate_ids = BTreeSet::new(); + for import in &imports { + import.validate()?; + if !native_ids.insert(import.native_id.as_str()) + || !candidate_ids.insert(import.candidate_id.as_str()) + { + return Err(MCPImportError::InvalidRequest("server identity")); + } + } + + let current = self.config_store.get_config_value(USER_CONFIG_KEY).await?; + if config_fingerprint(¤t) != expected_fingerprint { + return Err(MCPImportError::StaleConfiguration); + } + let mut replacement = cursor_root(¤t)?; + let servers = replacement + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .expect("cursor_root always creates an MCP object"); + for import in &imports { + if servers.contains_key(&import.native_id) { + return Err(MCPImportError::TargetConflict { + native_id: import.native_id.clone(), + }); + } + } + for import in imports { + servers.insert(import.native_id.clone(), imported_server_value(import)); + } + if !self + .config_store + .compare_and_set_config_value(USER_CONFIG_KEY, current, replacement) + .await? + { + return Err(MCPImportError::StaleConfiguration); + } + Ok(()) + } + + pub(super) async fn mutate_user_config( + &self, + mut mutate: impl FnMut(&mut Map) -> MCPRuntimeResult<()>, + ) -> MCPRuntimeResult<()> { + for _ in 0..USER_MUTATION_ATTEMPTS { + let current = self.config_store.get_config_value(USER_CONFIG_KEY).await?; + let mut replacement = cursor_root(¤t) + .map_err(|error| MCPRuntimeError::configuration(error.to_string()))?; + let servers = replacement + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .expect("cursor_root always creates an MCP object"); + mutate(servers)?; + if self + .config_store + .compare_and_set_config_value(USER_CONFIG_KEY, current, replacement) + .await? + { + return Ok(()); + } + } + Err(MCPRuntimeError::configuration( + "User MCP configuration changed repeatedly during update", + )) + } +} + +impl MCPImportServer { + fn validate(&self) -> Result<(), MCPImportError> { + validate_id(&self.native_id, "native id")?; + validate_id(&self.candidate_id, "candidate id")?; + validate_id(&self.behavior_version, "behavior version")?; + validate_text(&self.display_name, "display name")?; + match &self.transport { + MCPImportTransport::Local { command, args } => { + validate_text(command, "command")?; + if args.len() > MAX_IMPORT_SERVERS { + return Err(MCPImportError::InvalidRequest("argument count")); + } + for argument in args { + validate_text(argument, "argument")?; + } + } + MCPImportTransport::Remote { url } => { + validate_text(url, "URL")?; + let parsed = + url::Url::parse(url).map_err(|_| MCPImportError::InvalidRequest("URL"))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(MCPImportError::InvalidRequest("URL")); + } + } + } + Ok(()) + } +} + +fn validate_id(value: &str, label: &'static str) -> Result<(), MCPImportError> { + if value.is_empty() + || value.len() > MAX_IMPORT_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(MCPImportError::InvalidRequest(label)); + } + Ok(()) +} + +fn validate_text(value: &str, label: &'static str) -> Result<(), MCPImportError> { + if value.is_empty() + || value.len() > MAX_IMPORT_TEXT_BYTES + || value.chars().any(char::is_control) + { + return Err(MCPImportError::InvalidRequest(label)); + } + Ok(()) +} + +fn cursor_root(current: &Option) -> Result { + match current { + None | Some(Value::Null) => Ok(serde_json::json!({ "mcpServers": {} })), + Some(Value::Object(root)) if root.get("mcpServers").is_some_and(Value::is_object) => { + Ok(Value::Object(root.clone())) + } + _ => Err(MCPImportError::UnsupportedTargetFormat), + } +} + +fn cursor_servers(current: &Option) -> Result, MCPImportError> { + Ok(cursor_root(current)? + .get("mcpServers") + .and_then(Value::as_object) + .cloned() + .expect("cursor_root always creates an MCP object")) +} + +fn imported_server_value(import: MCPImportServer) -> Value { + let mut server = Map::new(); + match import.transport { + MCPImportTransport::Local { command, args } => { + server.insert("type".to_string(), Value::String("stdio".to_string())); + server.insert("command".to_string(), Value::String(command)); + if !args.is_empty() { + server.insert("args".to_string(), serde_json::json!(args)); + } + server.insert("inheritParentEnvironment".to_string(), Value::Bool(false)); + } + MCPImportTransport::Remote { url } => { + server.insert( + "type".to_string(), + Value::String("streamable-http".to_string()), + ); + server.insert("url".to_string(), Value::String(url)); + } + } + if import.display_name != import.native_id { + server.insert("name".to_string(), Value::String(import.display_name)); + } + server.insert("enabled".to_string(), Value::Bool(false)); + server.insert("autoStart".to_string(), Value::Bool(false)); + server.insert( + IMPORT_METADATA_KEY.to_string(), + serde_json::json!({ + "sourceCandidateId": import.candidate_id, + "behaviorVersion": import.behavior_version, + }), + ); + Value::Object(server) +} + +fn import_summary(native_id: &str, value: &Value) -> Option { + let metadata = value.get(IMPORT_METADATA_KEY)?.as_object()?; + let candidate_id = metadata.get("sourceCandidateId")?.as_str()?; + let behavior_version = metadata.get("behaviorVersion")?.as_str()?; + validate_id(candidate_id, "candidate id").ok()?; + validate_id(behavior_version, "behavior version").ok()?; + Some(MCPImportedServerSummary { + native_id: native_id.to_string(), + candidate_id: candidate_id.to_string(), + behavior_version: behavior_version.to_string(), + }) +} + +fn config_fingerprint(value: &Option) -> String { + let bytes = serde_json::to_vec(value).expect("JSON value serialization cannot fail"); + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} diff --git a/src/crates/services/services-integrations/src/mcp/config/mod.rs b/src/crates/services/services-integrations/src/mcp/config/mod.rs index e878af3808..2b88893f0b 100644 --- a/src/crates/services/services-integrations/src/mcp/config/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/config/mod.rs @@ -1,12 +1,17 @@ //! MCP configuration data contracts. mod cursor_format; +mod import; mod json_config; mod location; mod service; mod service_helpers; pub use cursor_format::{config_to_cursor_format, parse_cursor_format}; +pub use import::{ + MCPImportError, MCPImportServer, MCPImportTransport, MCPImportedServerSummary, + MCPUserImportSnapshot, MCPUserJsonConfigSnapshot, +}; pub use json_config::{ format_mcp_json_config_value, validate_mcp_json_config, MCPJsonConfigValidationError, }; diff --git a/src/crates/services/services-integrations/src/mcp/config/service.rs b/src/crates/services/services-integrations/src/mcp/config/service.rs index 8dce08ab26..517812fa1a 100644 --- a/src/crates/services/services-integrations/src/mcp/config/service.rs +++ b/src/crates/services/services-integrations/src/mcp/config/service.rs @@ -19,10 +19,16 @@ use super::{ pub trait MCPConfigStore: Send + Sync { async fn get_config_value(&self, key: &str) -> MCPRuntimeResult>; async fn set_config_value(&self, key: &str, value: Value) -> MCPRuntimeResult<()>; + async fn compare_and_set_config_value( + &self, + key: &str, + expected: Option, + replacement: Value, + ) -> MCPRuntimeResult; } pub struct MCPConfigService { - config_store: Arc, + pub(super) config_store: Arc, } impl MCPConfigService { @@ -207,29 +213,22 @@ impl MCPConfigService { } async fn save_user_config(&self, config: &MCPServerConfig) -> MCPRuntimeResult<()> { - let current_value = self - .config_store - .get_config_value("mcp_servers") - .await? - .unwrap_or_else(|| serde_json::json!({ "mcpServers": {} })); - - let mut mcp_servers = - if let Some(obj) = current_value.get("mcpServers").and_then(|v| v.as_object()) { - obj.clone() - } else { - serde_json::Map::new() - }; - - mcp_servers.insert(config.id.clone(), config_to_cursor_format(config)); - - self.config_store - .set_config_value( - "mcp_servers", - serde_json::json!({ - "mcpServers": mcp_servers - }), - ) - .await?; + self.mutate_user_config(|servers| { + let import_metadata = servers + .get(&config.id) + .and_then(Value::as_object) + .and_then(|server| server.get("_bitfunImport")) + .cloned(); + let mut replacement = config_to_cursor_format(config); + if let (Some(metadata), Some(replacement)) = + (import_metadata, replacement.as_object_mut()) + { + replacement.insert("_bitfunImport".to_string(), metadata); + } + servers.insert(config.id.clone(), replacement); + Ok(()) + }) + .await?; info!( "Saved user-level MCP server config (Cursor format): {}", config.id @@ -257,37 +256,15 @@ impl MCPConfigService { } pub async fn delete_server_config(&self, server_id: &str) -> MCPRuntimeResult<()> { - let current_value = self - .config_store - .get_config_value("mcp_servers") - .await? - .unwrap_or_else(|| serde_json::json!({ "mcpServers": {} })); - - let mut mcp_servers = - if let Some(obj) = current_value.get("mcpServers").and_then(|v| v.as_object()) { - obj.clone() - } else { + self.mutate_user_config(|servers| { + if servers.remove(server_id).is_none() { return Err(MCPRuntimeError::not_found(format!( - "MCP server config not found: {}", - server_id + "MCP server config not found: {server_id}" ))); - }; - - if mcp_servers.remove(server_id).is_none() { - return Err(MCPRuntimeError::not_found(format!( - "MCP server config not found: {}", - server_id - ))); - } - - self.config_store - .set_config_value( - "mcp_servers", - serde_json::json!({ - "mcpServers": mcp_servers - }), - ) - .await?; + } + Ok(()) + }) + .await?; info!("Deleted MCP server config: {}", server_id); Ok(()) } diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index 74b00b0570..18e132fa1d 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -10,7 +10,7 @@ use bitfun_services_integrations::mcp::config::{ get_mcp_remote_authorization_value, has_mcp_remote_authorization, has_mcp_remote_oauth, has_mcp_remote_xaa, merge_mcp_server_config_sources, normalize_mcp_authorization_value, parse_cursor_format, remove_mcp_authorization_keys, validate_mcp_json_config, MCPConfigService, - MCPConfigStore, + MCPConfigStore, MCPImportError, MCPImportServer, MCPImportTransport, }; use bitfun_services_integrations::mcp::protocol::{ create_initialize_request, create_mcp_client_info, create_ping_request, @@ -99,6 +99,20 @@ impl MCPConfigStore for InMemoryMCPConfigStore { self.values.lock().await.insert(key.to_string(), value); Ok(()) } + + async fn compare_and_set_config_value( + &self, + key: &str, + expected: Option, + replacement: serde_json::Value, + ) -> MCPRuntimeResult { + let mut values = self.values.lock().await; + if values.get(key).cloned() != expected { + return Ok(false); + } + values.insert(key.to_string(), replacement); + Ok(true) + } } struct FailingMCPConfigStore; @@ -120,6 +134,19 @@ impl MCPConfigStore for FailingMCPConfigStore { )), ) } + + async fn compare_and_set_config_value( + &self, + key: &str, + _expected: Option, + _replacement: serde_json::Value, + ) -> MCPRuntimeResult { + Err( + bitfun_services_integrations::mcp::MCPRuntimeError::configuration(format!( + "backend unavailable for {key}" + )), + ) + } } struct FakeMCPToolCatalogClient { @@ -1005,6 +1032,110 @@ async fn mcp_config_service_orchestration_preserves_load_save_delete_contract() .is_none()); } +#[tokio::test] +async fn external_mcp_import_is_atomic_disabled_and_idempotence_visible() { + let store = Arc::new(InMemoryMCPConfigStore::default()); + let service = MCPConfigService::new(store.clone()); + let snapshot = service.user_import_snapshot().await.unwrap(); + service + .apply_user_import( + &snapshot.fingerprint, + vec![MCPImportServer { + native_id: "docs".to_string(), + candidate_id: "opencode:mcp:docs".to_string(), + behavior_version: "sha256:behavior-v1".to_string(), + display_name: "Docs".to_string(), + transport: MCPImportTransport::Local { + command: "docs-mcp".to_string(), + args: vec!["--stdio".to_string()], + }, + }], + ) + .await + .unwrap(); + + let stored = store.values.lock().await["mcp_servers"].clone(); + assert_eq!(stored["mcpServers"]["docs"]["enabled"], false); + assert_eq!(stored["mcpServers"]["docs"]["autoStart"], false); + assert_eq!( + stored["mcpServers"]["docs"]["_bitfunImport"]["sourceCandidateId"], + "opencode:mcp:docs" + ); + assert!(stored.get("_bitfunImportJournal").is_none()); + + let refreshed = service.user_import_snapshot().await.unwrap(); + assert_eq!(refreshed.imports.len(), 1); + assert_eq!(refreshed.imports[0].native_id, "docs"); + let stale = service + .apply_user_import( + &snapshot.fingerprint, + vec![MCPImportServer { + native_id: "other".to_string(), + candidate_id: "opencode:mcp:other".to_string(), + behavior_version: "sha256:behavior-v1".to_string(), + display_name: "Other".to_string(), + transport: MCPImportTransport::Remote { + url: "https://example.com/mcp".to_string(), + }, + }], + ) + .await + .unwrap_err(); + assert!(matches!(stale, MCPImportError::StaleConfiguration)); +} + +#[tokio::test] +async fn stale_full_json_save_cannot_overwrite_a_concurrent_import() { + let store = Arc::new(InMemoryMCPConfigStore::default()); + let service = MCPConfigService::new(store.clone()); + let editor_snapshot = service.user_json_config_snapshot().await.unwrap(); + let import_snapshot = service.user_import_snapshot().await.unwrap(); + + service + .apply_user_import( + &import_snapshot.fingerprint, + vec![MCPImportServer { + native_id: "docs".to_string(), + candidate_id: "opencode:mcp:docs".to_string(), + behavior_version: "sha256:behavior-v1".to_string(), + display_name: "Docs".to_string(), + transport: MCPImportTransport::Local { + command: "private-command".to_string(), + args: vec!["private-argument".to_string()], + }, + }], + ) + .await + .unwrap(); + + let replacement = serde_json::from_str(&editor_snapshot.json_config).unwrap(); + let error = service + .replace_user_json_config(&editor_snapshot.fingerprint, replacement) + .await + .unwrap_err(); + assert!(matches!(error, MCPImportError::StaleConfiguration)); + assert!(store.values.lock().await["mcp_servers"]["mcpServers"]["docs"].is_object()); +} + +#[test] +fn import_debug_output_redacts_private_transport_values() { + let import = MCPImportServer { + native_id: "docs".to_string(), + candidate_id: "opencode:mcp:docs".to_string(), + behavior_version: "sha256:behavior-v1".to_string(), + display_name: "Docs".to_string(), + transport: MCPImportTransport::Local { + command: "private-command".to_string(), + args: vec!["private-argument".to_string()], + }, + }; + + let rendered = format!("{import:?}"); + assert!(!rendered.contains("private-command")); + assert!(!rendered.contains("private-argument")); + assert!(rendered.contains("argument_count")); +} + #[tokio::test] async fn mcp_config_service_keeps_load_failures_as_empty_baseline() { let service = MCPConfigService::new(Arc::new(FailingMCPConfigStore)); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index 176a9fdc47..b7c8fd1c7b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -87,6 +87,28 @@ describe('ExternalSourcesAPI', () => { }); }); + it('sends only typed selection intent when applying an MCP import plan', async () => { + const plan = { + schemaVersion: 1 as const, + planFingerprint: 'sha256:plan-v1', + items: [], + }; + await externalSourcesAPI.applyMcpImport('D:/workspace/project', plan, [{ + candidateId: 'opencode:mcp:docs', + }]); + + expect(invokeMock).toHaveBeenCalledWith('apply_external_mcp_import_command', { + request: { + workspacePath: 'D:/workspace/project', + importRequest: { + schemaVersion: 1, + planFingerprint: 'sha256:plan-v1', + selections: [{ candidateId: 'opencode:mcp:docs' }], + }, + }, + }); + }); + it('reveals a source by stable identity without sending its display location', async () => { await externalSourcesAPI.revealSourceLocation( 'D:/workspace/project', 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 6947754409..5b1c0032b9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -354,6 +354,37 @@ export interface ExternalMcpCatalogEntry { activationState: ExternalMcpActivation; } +export type ExternalMcpImportDispositionV1 = + | 'eligible' + | 'automatic_rename' + | 'already_imported' + | 'unavailable'; + +export interface ExternalMcpImportPlanV1 { + schemaVersion: 1; + planFingerprint: string; + items: Array<{ + candidateId: string; + displayName: string; + transport: 'local_stdio' | 'streamable_http'; + proposedNativeId?: string; + disposition: ExternalMcpImportDispositionV1; + reasonCode?: string; + }>; +} + +export interface ExternalMcpImportSelectionV1 { + candidateId: string; + requestedNativeId?: string; +} + +export interface ExternalMcpImportApplyResultV1 { + schemaVersion: 1; + outcome: + | { status: 'applied'; imported: Array<{ candidateId: string; nativeId: string }> } + | { status: 'stale'; refreshedPlan: ExternalMcpImportPlanV1 }; +} + export interface ExternalMcpApprovalRequest { candidateId: string; approvalKey: string; @@ -1108,6 +1139,33 @@ function controlRequest( } export const externalSourcesAPI = { + planMcpImport(workspacePath?: string) { + return invokeExternalSourceCommand( + 'plan_external_mcp_import_command', + { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath) } }, + ); + }, + + applyMcpImport( + workspacePath: string | undefined, + plan: ExternalMcpImportPlanV1, + selections: ExternalMcpImportSelectionV1[], + ) { + return invokeExternalSourceCommand( + 'apply_external_mcp_import_command', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + importRequest: { + schemaVersion: 1, + planFingerprint: plan.planFingerprint, + selections, + }, + }, + }, + ); + }, + async getControlSnapshot(workspacePath?: string, forceRefresh = false) { return invokeCompatibleSurfaceSnapshot({ request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), forceRefresh }, diff --git a/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts index 9bbfa682fa..7294e5ad45 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts @@ -330,13 +330,13 @@ export class MCPAPI { } - static async loadMCPJsonConfig(): Promise { + static async loadMCPJsonConfig(): Promise<{ jsonConfig: string; fingerprint: string }> { return api.invoke('load_mcp_json_config'); } - static async saveMCPJsonConfig(jsonConfig: string): Promise { - return api.invoke('save_mcp_json_config', { jsonConfig }); + static async saveMCPJsonConfig(jsonConfig: string, expectedFingerprint: string): Promise { + return api.invoke('save_mcp_json_config', { jsonConfig, expectedFingerprint }); } /** diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx index 73a96378dc..a01f1afeb9 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx @@ -7,7 +7,9 @@ import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import ExternalMcpOverview from './ExternalMcpOverview'; const getSnapshotMock = vi.hoisted(() => vi.fn()); -const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project' })); +const planMcpImportMock = vi.hoisted(() => vi.fn()); +const applyMcpImportMock = vi.hoisted(() => vi.fn()); +const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project', kind: 'normal' })); const peerState = vi.hoisted(() => ({ deviceId: '' })); const warnMock = vi.hoisted(() => vi.fn()); const apiErrorState = vi.hoisted(() => ({ @@ -32,7 +34,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ - workspace: { id: workspaceState.path, workspaceKind: 'local' }, + workspace: { id: workspaceState.path, workspaceKind: workspaceState.kind }, workspacePath: workspaceState.path, }), })); @@ -46,7 +48,11 @@ vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ ExternalSourceApiError: apiErrorState.ExternalSourceApiError, - externalSourcesAPI: { getSnapshot: getSnapshotMock }, + externalSourcesAPI: { + getSnapshot: getSnapshotMock, + planMcpImport: planMcpImportMock, + applyMcpImport: applyMcpImportMock, + }, })); vi.mock('@/shared/utils/logger', () => ({ @@ -130,8 +136,27 @@ describe('ExternalMcpOverview', () => { document.body.appendChild(container); root = createRoot(container); getSnapshotMock.mockReset().mockResolvedValue(snapshot); + planMcpImportMock.mockReset().mockResolvedValue({ + schemaVersion: 1, + planFingerprint: 'sha256:plan-v1', + items: [{ + candidateId: 'opencode-project-docs', + displayName: 'docs', + transport: 'local_stdio', + proposedNativeId: 'docs', + disposition: 'eligible', + }], + }); + applyMcpImportMock.mockReset().mockResolvedValue({ + schemaVersion: 1, + outcome: { + status: 'applied', + imported: [{ candidateId: 'opencode-project-docs', nativeId: 'docs' }], + }, + }); warnMock.mockReset(); workspaceState.path = 'D:/workspace/project'; + workspaceState.kind = 'normal'; peerState.deviceId = ''; useSettingsStore.setState({ activeTab: 'mcp-tools', searchQuery: '' }); }); @@ -172,6 +197,52 @@ describe('ExternalMcpOverview', () => { expect(useSettingsStore.getState().activeTab).toBe('external-sources'); }); + it('previews before applying and delegates later enablement to MCP settings', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + const importArea = container.querySelector('[data-testid="external-mcp-import"]')!; + await act(async () => { + (importArea.querySelector('button') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + expect(planMcpImportMock).toHaveBeenCalledWith('D:/workspace/project'); + expect(importArea.textContent).toContain('external.import.confirm'); + await act(async () => { + (importArea.querySelector('button') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + expect(applyMcpImportMock).toHaveBeenCalledWith( + 'D:/workspace/project', + expect.objectContaining({ planFingerprint: 'sha256:plan-v1' }), + [{ candidateId: 'opencode-project-docs' }], + ); + expect(importArea.textContent).toContain('external.import.applied'); + }); + + it('does not expose import mutation in Peer or remote workspace mode', async () => { + peerState.deviceId = 'peer-a'; + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="external-mcp-import"]')).toBeNull(); + expect(planMcpImportMock).not.toHaveBeenCalled(); + + peerState.deviceId = ''; + workspaceState.kind = 'remote'; + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="external-mcp-import"]')).toBeNull(); + expect(applyMcpImportMock).not.toHaveBeenCalled(); + }); + it('clears the previous workspace snapshot while the next host loads or fails', async () => { await act(async () => { root.render(); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx index 83bbb518eb..b0a8679c7e 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx @@ -8,12 +8,14 @@ import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDevi import { type ExternalMcpActivation, type ExternalMcpCatalogEntry, + type ExternalMcpImportPlanV1, ExternalSourceApiError, type ExternalSourceCatalogSnapshot, type ExternalSourceScope, externalSourcesAPI, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { createLogger } from '@/shared/utils/logger'; +import { WorkspaceKind } from '@/shared/types/global-state'; import { ConfigCollectionItem, ConfigPageSection } from './common'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; @@ -114,7 +116,9 @@ const ExternalMcpOverview: React.FC = () => { const peerDevice = usePeerDeviceModeOptional(); const setSettingsTab = useSettingsStore((state) => state.setActiveTab); const requestIdRef = useRef(0); + const importRequestIdRef = useRef(0); const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; + const importSupported = !peerDeviceId && workspace?.workspaceKind !== WorkspaceKind.Remote; const requestScope = externalSourceRequestScopeKey({ peerDeviceId, workspaceId: workspace?.id, @@ -129,6 +133,9 @@ const ExternalMcpOverview: React.FC = () => { } | null>(null); const [loading, setLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); + const [importPlan, setImportPlan] = useState(null); + const [importBusy, setImportBusy] = useState(false); + const [importNotice, setImportNotice] = useState<'applied' | 'stale' | 'empty' | 'failed' | null>(null); const snapshot = snapshotState?.scope === requestScope ? snapshotState.snapshot : null; const scopedLoading = loading || snapshotState?.scope !== requestScope; @@ -165,6 +172,13 @@ const ExternalMcpOverview: React.FC = () => { }; }, [loadSnapshot]); + useEffect(() => { + importRequestIdRef.current += 1; + setImportPlan(null); + setImportNotice(null); + setImportBusy(false); + }, [requestScope]); + useEffect(() => { if (!snapshot?.discoveryPending) return undefined; let cancelled = false; @@ -223,6 +237,59 @@ const ExternalMcpOverview: React.FC = () => { diagnostic.severity !== 'info' && (!diagnostic.assetKind || diagnostic.assetKind === 'source' || diagnostic.assetKind === 'mcp') )); + const eligibleImportItems = (importPlan?.items ?? []).filter((item) => ( + item.disposition === 'eligible' || item.disposition === 'automatic_rename' + )); + + const previewImport = async () => { + if (!importSupported) return; + const requestId = ++importRequestIdRef.current; + setImportBusy(true); + setImportNotice(null); + try { + const plan = await externalSourcesAPI.planMcpImport(workspacePath || undefined); + if (requestId !== importRequestIdRef.current) return; + const hasEligible = plan.items.some((item) => ( + item.disposition === 'eligible' || item.disposition === 'automatic_rename' + )); + setImportPlan(hasEligible ? plan : null); + if (!hasEligible) setImportNotice('empty'); + } catch (error) { + if (requestId !== importRequestIdRef.current) return; + setImportNotice('failed'); + log.warn('Failed to preview external MCP import', safeLoadErrorFacts(error)); + } finally { + if (requestId === importRequestIdRef.current) setImportBusy(false); + } + }; + + const applyImport = async () => { + if (!importSupported || !importPlan || eligibleImportItems.length === 0) return; + const requestId = ++importRequestIdRef.current; + setImportBusy(true); + setImportNotice(null); + try { + const result = await externalSourcesAPI.applyMcpImport( + workspacePath || undefined, + importPlan, + eligibleImportItems.map((item) => ({ candidateId: item.candidateId })), + ); + if (requestId !== importRequestIdRef.current) return; + if (result.outcome.status === 'stale') { + setImportPlan(result.outcome.refreshedPlan); + setImportNotice('stale'); + } else { + setImportPlan(null); + setImportNotice('applied'); + } + } catch (error) { + if (requestId !== importRequestIdRef.current) return; + setImportNotice('failed'); + log.warn('Failed to apply external MCP import', safeLoadErrorFacts(error)); + } finally { + if (requestId === importRequestIdRef.current) setImportBusy(false); + } + }; const scopeLabel = (scope: ExternalSourceScope | undefined): string => { switch (scope) { @@ -364,6 +431,35 @@ const ExternalMcpOverview: React.FC = () => { )} > + {entries.length > 0 && !hostReadOnly && importSupported ? ( +
+ {importPlan ? ( + <> +

{t('external.import.confirm', { count: eligibleImportItems.length })}

+
    + {eligibleImportItems.map((item) => ( +
  • + {item.displayName} → {item.proposedNativeId} +
  • + ))} +
+
+ + +
+ + ) : ( + + )} + {importNotice ?

{t(`external.import.${importNotice}`)}

: null} +
+ ) : null} {scopedLoading && !snapshot ? (

{t('external.loading')}

) : loadFailed && !snapshot ? ( diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.scss b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.scss index 1920a52ac6..086dd9b7a2 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.scss @@ -199,3 +199,32 @@ } } } +.bitfun-mcp-tools__import { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + margin-bottom: 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + + p { + margin: 0; + } + + ul { + max-height: 120px; + margin: 0; + overflow: auto; + } + + button { + padding: 5px 10px; + } +} + +.bitfun-mcp-tools__import-actions { + display: flex; + gap: 8px; +} diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx index 99543e1030..1608e63d7e 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx @@ -9,6 +9,8 @@ const peerState = vi.hoisted(() => ({ active: true })); const runtimeState = vi.hoisted(() => ({ desktop: true })); const getServersMock = vi.hoisted(() => vi.fn()); const loadJsonConfigMock = vi.hoisted(() => vi.fn()); +const saveJsonConfigMock = vi.hoisted(() => vi.fn()); +const initializeServersMock = vi.hoisted(() => vi.fn()); const startServerMock = vi.hoisted(() => vi.fn()); const notificationMocks = vi.hoisted(() => ({ success: vi.fn(), @@ -37,6 +39,8 @@ vi.mock('../../api/service-api/MCPAPI', () => ({ MCPAPI: { getServers: getServersMock, loadMCPJsonConfig: loadJsonConfigMock, + saveMCPJsonConfig: saveJsonConfigMock, + initializeServers: initializeServersMock, startServer: startServerMock, }, })); @@ -56,7 +60,12 @@ describe('McpToolsConfig remote behavior', () => { peerState.active = true; runtimeState.desktop = true; getServersMock.mockReset().mockResolvedValue([]); - loadJsonConfigMock.mockReset().mockResolvedValue('{"mcpServers":{}}'); + loadJsonConfigMock.mockReset().mockResolvedValue({ + jsonConfig: '{"mcpServers":{}}', + fingerprint: 'sha256:test', + }); + saveJsonConfigMock.mockReset().mockResolvedValue(undefined); + initializeServersMock.mockReset().mockResolvedValue(undefined); startServerMock.mockReset().mockResolvedValue(undefined); notificationMocks.success.mockReset(); notificationMocks.warning.mockReset(); @@ -185,6 +194,28 @@ describe('McpToolsConfig remote behavior', () => { expect(container.querySelector('.bitfun-mcp-tools__json-textarea')).not.toBeNull(); }); + it('saves the JSON editor against the fingerprint that was loaded with it', async () => { + peerState.active = false; + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + (container.querySelector('[aria-label="actions.jsonConfig"]') as HTMLButtonElement).click(); + }); + const saveButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'actions.saveConfig', + ); + await act(async () => { + saveButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(saveJsonConfigMock).toHaveBeenCalledWith('{"mcpServers":{}}', 'sha256:test'); + }); + it('does not notify or reload after a pending start loses desktop capability', async () => { let resolveStart: (() => void) | undefined; startServerMock.mockReturnValueOnce(new Promise((resolve) => { diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx index bcc56af703..eadd42fd7b 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx @@ -183,6 +183,7 @@ const McpToolsConfig: React.FC = () => { const [serverLoadFailed, setServerLoadFailed] = useState(false); const [showJsonEditor, setShowJsonEditor] = useState(false); const [jsonConfig, setJsonConfig] = useState(''); + const [jsonConfigFingerprint, setJsonConfigFingerprint] = useState(''); const [jsonLoading, setJsonLoading] = useState(true); const [jsonLoadFailed, setJsonLoadFailed] = useState(false); const [authDialogServer, setAuthDialogServer] = useState(null); @@ -281,7 +282,8 @@ const McpToolsConfig: React.FC = () => { ) { return false; } - setJsonConfig(config); + setJsonConfig(config.jsonConfig); + setJsonConfigFingerprint(config.fingerprint); setJsonLoadFailed(false); return true; } catch (error) { @@ -385,6 +387,7 @@ const McpToolsConfig: React.FC = () => { setMcpLoading(false); setServerLoadFailed(false); setShowJsonEditor(false); + setJsonConfigFingerprint(''); setJsonLoading(false); setJsonLoadFailed(false); setAuthDialogServer(null); @@ -467,7 +470,10 @@ const McpToolsConfig: React.FC = () => { if (typeof parsedConfig.mcpServers !== 'object' || Array.isArray(parsedConfig.mcpServers)) throw new Error(tMcp('errors.mcpServersMustBeObject')); - await MCPAPI.saveMCPJsonConfig(jsonConfig); + if (!jsonConfigFingerprint) { + throw new Error('MCP configuration snapshot is unavailable; reload before saving'); + } + await MCPAPI.saveMCPJsonConfig(jsonConfig, jsonConfigFingerprint); if (!capabilityIsCurrent(capabilityEpoch)) return; notification.success(tMcp('messages.saveSuccess'), { title: tMcp('notifications.saveSuccess'), diff --git a/src/web-ui/src/locales/en-US/settings/mcp.json b/src/web-ui/src/locales/en-US/settings/mcp.json index 94ed90d72e..0a3788203e 100644 --- a/src/web-ui/src/locales/en-US/settings/mcp.json +++ b/src/web-ui/src/locales/en-US/settings/mcp.json @@ -19,6 +19,16 @@ "loading": "Checking external MCP sources...", "unavailable": "External MCP is temporarily unavailable.", "empty": "No external MCP found.", + "import": { + "preview": "Import into BitFun", + "confirm": "Import {{count}} supported server(s) as disabled native entries?", + "apply": "Import", + "cancel": "Cancel", + "applied": "Imported. Review and enable servers from MCP settings when ready.", + "stale": "Sources or target settings changed. Review the refreshed plan.", + "empty": "No supported servers are available to import.", + "failed": "Import is temporarily unavailable." + }, "unknown": "Unknown", "scope": { "userGlobal": "Global", diff --git a/src/web-ui/src/locales/zh-CN/settings/mcp.json b/src/web-ui/src/locales/zh-CN/settings/mcp.json index 8a5f7909f9..a19d53ec2b 100644 --- a/src/web-ui/src/locales/zh-CN/settings/mcp.json +++ b/src/web-ui/src/locales/zh-CN/settings/mcp.json @@ -19,6 +19,16 @@ "loading": "正在检查外部 MCP 来源...", "unavailable": "外部 MCP 暂不可用。", "empty": "未发现外部 MCP。", + "import": { + "preview": "导入到 BitFun", + "confirm": "将 {{count}} 个受支持的服务器作为停用的原生配置导入?", + "apply": "导入", + "cancel": "取消", + "applied": "已导入。请在 MCP 设置中检查并按需启用。", + "stale": "来源或目标设置已变化,请检查刷新后的方案。", + "empty": "当前没有可导入的受支持服务器。", + "failed": "暂时无法导入。" + }, "unknown": "未知", "scope": { "userGlobal": "全局", diff --git a/src/web-ui/src/locales/zh-TW/settings/mcp.json b/src/web-ui/src/locales/zh-TW/settings/mcp.json index 9d4134fdc8..0219a97429 100644 --- a/src/web-ui/src/locales/zh-TW/settings/mcp.json +++ b/src/web-ui/src/locales/zh-TW/settings/mcp.json @@ -19,6 +19,16 @@ "loading": "正在檢查外部 MCP 來源...", "unavailable": "外部 MCP 暫時無法使用。", "empty": "未發現外部 MCP。", + "import": { + "preview": "匯入到 BitFun", + "confirm": "將 {{count}} 個受支援的伺服器作為停用的原生設定匯入?", + "apply": "匯入", + "cancel": "取消", + "applied": "已匯入。請在 MCP 設定中檢查並按需啟用。", + "stale": "來源或目標設定已變更,請檢查重新整理後的方案。", + "empty": "目前沒有可匯入的受支援伺服器。", + "failed": "暫時無法匯入。" + }, "unknown": "未知", "scope": { "userGlobal": "全域",