diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 655b1d40c3..390133ff34 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**319 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**320 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **319** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **320** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **645** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **646** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **645** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **646** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **210**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **211**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 319 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 320 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **210 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **211 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 319 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 320 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index d8414e402e..2854c87626 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "79584f59ffb49edb846b0beacc31f5393d81c3f117f3630445ab034c27546736", + "digest": "1230aa44c72c2cfb44fd28cb3da60fe04548cefd323b610b28e44bf48d574773", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 319, + "documentedItems": 320, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 210, + "interactive": 211, "unsupported": 0 } }, @@ -197,6 +197,7 @@ "permission-mailbox", "history-search", "agent-session-coordination", + "mcp-mentions", "tool-activity", "goals", "goal-maintenance", @@ -618,6 +619,52 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:open:mcp-mentions", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "mcp-mentions" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:tool-activity", "capabilityId": "feature.ai-assistant", @@ -19214,6 +19261,17 @@ ] } }, + { + "id": "mcp-mentions", + "titleZh": "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "titleEn": "Use @ in chat to select MCP servers available to the current mode", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“在对话中通过 @ 选择当前模式可用的 MCP 服务”需要用户在当前输入框选择服务并编辑草稿,没有可直接完成该交互的结构化 Command。也可通过工具名称或描述搜索所属服务;工具不会单独列出。目录按当前主机、工作区和模式过滤;Minimal 隐藏 MCP 入口。远程工作区暂不支持此选择器,Peer Host 必须协商 chat_mcp_catalog_v1 能力。", + "reasonEn": "“Use @ in chat to select MCP servers available to the current mode” requires the user to select a server and edit the current composer draft; no structured Command completes that interaction directly. Tool names and descriptions can find their owning server without separate tool rows. The catalog is scoped to the current host, workspace, and mode; Minimal hides MCP. Remote workspaces are unsupported; Peer Hosts must advertise chat_mcp_catalog_v1." + } + }, { "id": "tool-activity", "titleZh": "查看工具调用与后台命令,读取输出并继续输入或停止", @@ -19396,6 +19454,8 @@ "Page through, search, and backfill historical turns and session content", "让 Agent 创建、列出、取消、删除会话或向另一会话发送消息", "Let an agent create, list, cancel, or delete sessions and send messages to another session", + "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "Use @ in chat to select MCP servers available to the current mode", "查看工具调用与后台命令,读取输出并继续输入或停止", "Inspect tool calls and background commands, read output, send input, or stop them", "查看、创建并完成或阻塞当前会话目标", diff --git a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md index a44de93b96..0f275dbf69 100644 --- a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md +++ b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md @@ -33,6 +33,8 @@ Work with AI in project context, create sessions, continue tasks, answer permiss - Page through, search, and backfill historical turns and session content - **由专用 Agent 工具控制 / Delegated Agent tool** · `SessionControl` / `SessionMessage` · 让 Agent 创建、列出、取消、删除会话或向另一会话发送消息 - Let an agent create, list, cancel, or delete sessions and send messages to another session +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 在对话中通过 @ 选择当前模式可用的 MCP 服务 + - Use @ in chat to select MCP servers available to the current mode - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看工具调用与后台命令,读取输出并继续输入或停止 - Inspect tool calls and background commands, read output, send input, or stop them - **由专用 Agent 工具控制 / Delegated Agent tool** · `get_goal` / `create_goal` / `update_goal` · 查看、创建并完成或阻塞当前会话目标 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index edcdac956a..8edd8c81a7 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "79584f59ffb49edb846b0beacc31f5393d81c3f117f3630445ab034c27546736", - "count": 210, + "catalogDigest": "1230aa44c72c2cfb44fd28cb3da60fe04548cefd323b610b28e44bf48d574773", + "count": 211, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 183, + "unstructuredInteraction": 184, "visualSelection": 18 }, "entries": [ @@ -96,6 +96,24 @@ "command:submit_user_answers" ] }, + { + "capabilityId": "feature.ai-assistant", + "itemId": "mcp-mentions", + "titleZh": "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "titleEn": "Use @ in chat to select MCP servers available to the current mode", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“在对话中通过 @ 选择当前模式可用的 MCP 服务”需要用户在当前输入框选择服务并编辑草稿,没有可直接完成该交互的结构化 Command。也可通过工具名称或描述搜索所属服务;工具不会单独列出。目录按当前主机、工作区和模式过滤;Minimal 隐藏 MCP 入口。远程工作区暂不支持此选择器,Peer Host 必须协商 chat_mcp_catalog_v1 能力。", + "reasonEn": "“Use @ in chat to select MCP servers available to the current mode” requires the user to select a server and edit the current composer draft; no structured Command completes that interaction directly. Tool names and descriptions can find their owning server without separate tool rows. The catalog is scoped to the current host, workspace, and mode; Minimal hides MCP. Remote workspaces are unsupported; Peer Hosts must advertise chat_mcp_catalog_v1.", + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + }, + "evidence": [ + "command:get_chat_mcp_catalog", + "source:src/web-ui/src/flow_chat/components/ChatContextPicker.tsx#onSelectMcp", + "source:src/web-ui/src/flow_chat/components/ChatInput.tsx#selectContextMcp" + ] + }, { "capabilityId": "feature.ai-assistant", "itemId": "tool-activity", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index db34a90719..15af3120c1 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "79584f59ffb49edb846b0beacc31f5393d81c3f117f3630445ab034c27546736", - "commandCount": 645, + "catalogDigest": "1230aa44c72c2cfb44fd28cb3da60fe04548cefd323b610b28e44bf48d574773", + "commandCount": 646, "coverage": { - "commandCount": 645, - "documentedCommandCount": 608, + "commandCount": 646, + "documentedCommandCount": 609, "implementationCommandCount": 37, "implementationDigest": "3c5e64b6ff16d55d95c2a6b4f77046be009a554b395b4a52bf8ed7c162ab1093" }, @@ -2690,6 +2690,22 @@ "signature": "fn get_baseline_snapshot_diff( request: GetBaselineSnapshotDiffRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "get_chat_mcp_catalog", + "moduleId": "tool", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [ + "feature.ai-assistant:mcp-mentions" + ], + "visibility": "documented", + "rustPath": "get_chat_mcp_catalog", + "sourceFile": "src/apps/desktop/src/api/tool_api.rs", + "signature": "fn get_chat_mcp_catalog( request: openbitfun_core::agentic::tools::product_runtime::ChatMcpCatalogRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, { "id": "get_clipboard_files", "moduleId": "clipboard_file", diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 08d5a984ef..d6f455ce3a 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -430,6 +430,8 @@ mod tests { #[cfg(unix)] #[test] fn cancellation_does_not_signal_an_unverified_group_after_leader_exit() { + use std::io::Write; + struct ProcessGroupGuard(i32); impl Drop for ProcessGroupGuard { fn drop(&mut self) { @@ -440,16 +442,43 @@ mod tests { } } - let mut command = Command::new("/bin/sh"); + let ready_dir = tempfile::tempdir().expect("ready directory"); + let ready_path = ready_dir.path().join("hup-resistant-child-ready"); + let mut command = openbitfun_services_core::process_manager::create_command("/bin/sh"); command - .args(["-c", "trap '' HUP; sleep 30 &"]) - .stdin(Stdio::null()) + .arg("-c") + // Keep the leader alive until the child has installed its HUP + // disposition; exiting immediately races child startup on macOS. + .arg( + "trap '' HUP; \ + sh -c 'trap \"\" HUP; printf ready > \"$OPENBITFUN_DISPATCH_HUP_TEST_READY\"; \ + exec sleep 30' & \ + read release", + ) + .env("OPENBITFUN_DISPATCH_HUP_TEST_READY", &ready_path) + .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); configure_detached_process(&mut command); let mut leader = command.spawn().expect("spawn process-group leader"); let process_group = i32::try_from(leader.id()).expect("safe pid"); let _guard = ProcessGroupGuard(process_group); + for _ in 0..100 { + if ready_path.is_file() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + ready_path.is_file(), + "HUP-resistant child must be ready before the leader exits" + ); + leader + .stdin + .take() + .expect("leader release pipe") + .write_all(b"release\n") + .expect("release process-group leader"); leader.wait().expect("reap process-group leader"); assert!(!process_alive(process_group as u32)); assert!(process_group_alive(process_group)); diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 9168c2a7dc..f80083f196 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -82,6 +82,7 @@ pub(crate) async fn dispatch( // really has no tools". Without this the call fell into the unsupported // dispatch branch and the UI silently rendered an empty tool list. "get_all_tools_info" => tools::get_all_tools_info().await, + "get_chat_mcp_catalog" => tools::get_chat_mcp_catalog(args).await, // Sessions "list_persisted_sessions" => session::list_persisted_sessions(state, args).await, @@ -187,6 +188,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "get_agent_profile_config", "get_agent_profile_configs", "get_all_tools_info", + "get_chat_mcp_catalog", "get_available_modes", "get_config", "get_configs", diff --git a/src/apps/cli/src/peer_host/commands/tools.rs b/src/apps/cli/src/peer_host/commands/tools.rs index 0d6c2227f8..d0bcb2bb67 100644 --- a/src/apps/cli/src/peer_host/commands/tools.rs +++ b/src/apps/cli/src/peer_host/commands/tools.rs @@ -4,6 +4,15 @@ use serde_json::Value; use openbitfun_core::agentic::tools::product_runtime::build_all_tools_info; +pub(crate) async fn get_chat_mcp_catalog(args: &Value) -> Result { + let request = serde_json::from_value(crate::peer_host::args::request_value(args).clone()) + .map_err(|error| format!("Invalid MCP catalog request: {error}"))?; + let catalog = + openbitfun_core::agentic::tools::product_runtime::build_chat_mcp_catalog(request).await?; + serde_json::to_value(catalog) + .map_err(|error| format!("Failed to serialize MCP catalog: {error}")) +} + /// Read-only tool catalog for the Agents / Assistant Defaults UI. /// /// CLI Host assembles the same Core tool registry as Desktop; this returns the diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 7b0541d622..82010e89d3 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -28,6 +28,13 @@ use crate::runtime::DesktopRuntimeContext; /// controller cannot tell "unsupported" from "empty". pub type ToolInfo = ToolInfoDto; +#[tauri::command] +pub async fn get_chat_mcp_catalog( + request: openbitfun_core::agentic::tools::product_runtime::ChatMcpCatalogRequest, +) -> Result { + openbitfun_core::agentic::tools::product_runtime::build_chat_mcp_catalog(request).await +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecutionRequest { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 4784c4168f..604636ba3f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1348,6 +1348,7 @@ pub async fn run() { choose_external_mcp_conflict_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, + get_chat_mcp_catalog, get_readonly_tools_info, get_tool_info, validate_tool_input, diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index ac6104e9ac..07e5103d1e 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -238,6 +238,13 @@ cargo test -p openbitfun-core --no-default-features --features agent-runtime,git cargo test -p openbitfun-core --no-default-features --features agent-runtime,remote-workspace,git --lib service::snapshot:: ``` +MCP chat discovery and deferred-tool manifest contracts (Git is needed by the +existing Agent tool test assembly): + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features mcp-runtime,git --lib agentic::tools::product_runtime:: +``` + Skill discovery, installation provenance, and local/remote registry regressions: ```bash diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index f6ebb7f826..8e58cfa697 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -10,6 +10,7 @@ mod catalog; mod get_tool_spec_tool; mod loaded_spec_state; mod materialization; +mod mcp_catalog; mod snapshot; use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRegistry}; @@ -34,6 +35,7 @@ pub(crate) use catalog::{ pub use catalog::{ResolvedToolManifest, ResolvedVisibleTools}; pub use get_tool_spec_tool::GetToolSpecTool; pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; +pub use mcp_catalog::{build_chat_mcp_catalog, ChatMcpCatalog, ChatMcpCatalogRequest, ChatMcpTool}; #[derive(Clone)] pub(crate) struct ProductToolRuntime { diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs new file mode 100644 index 0000000000..dab8b86878 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs @@ -0,0 +1,290 @@ +//! Read-only, mode/workspace-scoped MCP choices for chat composers. + +use crate::agentic::agents::get_agent_registry; +use crate::agentic::tools::framework::ToolUseContext; +use crate::agentic::tools::registry::{get_global_tool_registry, ToolRef}; +use crate::agentic::tools::tool_context_runtime::build_tool_description_context; +use crate::agentic::WorkspaceBinding; +#[cfg(feature = "remote-workspace")] +use crate::service::remote_ssh::workspace_state::lookup_remote_connection; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatMcpCatalogRequest { + pub mode_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatMcpTool { + pub name: String, + pub server_id: String, + pub server_name: String, + pub tool_name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatMcpCatalog { + pub tools: Vec, + pub mode_restricted: bool, +} + +pub async fn build_chat_mcp_catalog( + request: ChatMcpCatalogRequest, +) -> Result { + // Never interpret a remote POSIX path on the controller. Remote workspace + // MCP discovery needs a target-owned catalog before this entry can support it. + if request + .remote_connection_id + .as_deref() + .is_some_and(|id| !id.trim().is_empty()) + { + return Err("MCP chat discovery is unsupported for remote workspaces".into()); + } + let workspace_path = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()); + if let Some(path) = workspace_path { + #[cfg(feature = "remote-workspace")] + if lookup_remote_connection(path).await.is_some() { + return Err("MCP chat discovery is unsupported for remote workspaces".into()); + } + if !std::path::Path::new(path).is_absolute() { + return Err("MCP chat discovery requires an absolute workspace path".into()); + } + } + let workspace = workspace_path.map(|path| WorkspaceBinding::new(None, PathBuf::from(path))); + let mode_id = request.mode_id.trim(); + let registry = get_agent_registry(); + let root = workspace.as_ref().map(WorkspaceBinding::root_path); + if registry.get_agent(mode_id, root).is_none() { + return Err(format!("Agent mode is unavailable: {mode_id}")); + } + let policy = registry.get_agent_tool_policy(mode_id, root).await; + let context = build_tool_description_context( + mode_id, + workspace.as_ref(), + None, + None, + None, + None, + None, + &HashMap::new(), + &Default::default(), + ); + let tools = get_global_tool_registry().read().await.get_all_tools(); + project_chat_mcp_catalog(&tools, &policy.allowed_tools, &context).await +} + +async fn project_chat_mcp_catalog( + snapshot: &[ToolRef], + allowed_tools: &[String], + context: &ToolUseContext, +) -> Result { + let allowed: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); + let mut tools = Vec::new(); + let mut mode_restricted = false; + for tool in snapshot { + let Some(mcp) = tool.dynamic_tool_info().and_then(|info| info.mcp) else { + continue; + }; + if !tool.is_enabled().await || !tool.is_available_in_context(Some(context)).await { + continue; + } + if !allowed.contains(tool.name()) + || !context + .runtime_tool_restrictions + .is_tool_allowed(tool.name()) + { + mode_restricted = true; + continue; + } + tools.push(ChatMcpTool { + name: tool.name().to_string(), + server_id: mcp.server_id, + server_name: mcp.server_name, + tool_name: mcp.tool_name, + description: tool + .description() + .await + .map_err(|error| error.to_string())?, + }); + } + tools.sort_by(|a, b| a.server_name.cmp(&b.server_name).then(a.name.cmp(&b.name))); + Ok(ChatMcpCatalog { + mode_restricted: mode_restricted && tools.is_empty(), + tools, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::agents::AgentRegistry; + use crate::agentic::tools::framework::{ + DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolExposure, ToolResult, + }; + use crate::util::errors::OpenBitFunResult; + use serde_json::{json, Value}; + use std::sync::Arc; + + struct CatalogTool { + name: &'static str, + available: bool, + } + + #[async_trait::async_trait] + impl Tool for CatalogTool { + fn name(&self) -> &str { + self.name + } + async fn description(&self) -> OpenBitFunResult { + Ok("Search documents".into()) + } + fn short_description(&self) -> String { + "Search documents".into() + } + fn input_schema(&self) -> Value { + json!({"type": "object", "properties": {"query": {"type": "string"}}}) + } + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn dynamic_tool_info(&self) -> Option { + Some(DynamicToolInfo { + provider_id: "docs".into(), + provider_kind: Some("mcp".into()), + mcp: Some(DynamicMcpToolInfo { + server_id: "docs".into(), + server_name: "Docs".into(), + tool_name: "search".into(), + }), + }) + } + async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { + self.available + } + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> OpenBitFunResult> { + panic!("catalog must never execute a tool") + } + } + + fn context(mode: &str) -> ToolUseContext { + build_tool_description_context( + mode, + None, + None, + None, + None, + None, + None, + &HashMap::new(), + &Default::default(), + ) + } + + #[tokio::test] + async fn chat_catalog_matches_the_models_deferred_mcp_inventory() { + let snapshot: Vec = vec![Arc::new(CatalogTool { + name: "mcp__docs__search", + available: true, + })]; + let allowed = vec!["mcp__docs__search".to_string()]; + let context = context("Standard"); + let catalog = project_chat_mcp_catalog(&snapshot, &allowed, &context) + .await + .unwrap(); + let manifest = openbitfun_agent_tools::resolve_contextual_tool_manifest( + &snapshot, + &allowed, + &Default::default(), + &context, + "GetToolSpec", + ) + .await; + assert_eq!( + catalog + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(), + manifest.deferred_tool_names + ); + assert!(!catalog.mode_restricted); + let wire = serde_json::to_value(&catalog).unwrap(); + assert_eq!(wire["tools"][0]["serverName"], "Docs"); + assert_eq!(wire["tools"][0]["description"], "Search documents"); + assert!(wire["tools"][0].get("inputSchema").is_none()); + } + + #[tokio::test] + async fn unavailable_workspace_tools_and_empty_catalog_are_not_mode_restrictions() { + let snapshot: Vec = vec![Arc::new(CatalogTool { + name: "mcp__other_workspace__search", + available: false, + })]; + for snapshot in [&snapshot[..], &[][..]] { + let catalog = project_chat_mcp_catalog(snapshot, &[], &context("Standard")) + .await + .unwrap(); + assert!(catalog.tools.is_empty()); + assert!(!catalog.mode_restricted); + } + } + + #[tokio::test] + async fn minimal_keeps_its_mcp_policy_and_the_picker_explains_the_exclusion() { + let registry = AgentRegistry::new(); + let minimal = registry.get_agent("Minimal", None).unwrap(); + assert!(!minimal.include_dynamic_mcp_tools()); + assert!(registry + .get_agent("Standard", None) + .unwrap() + .include_dynamic_mcp_tools()); + let snapshot: Vec = vec![Arc::new(CatalogTool { + name: "mcp__docs__search", + available: true, + })]; + let catalog = + project_chat_mcp_catalog(&snapshot, &minimal.default_tools(), &context("Minimal")) + .await + .unwrap(); + assert!(catalog.tools.is_empty()); + assert!(catalog.mode_restricted); + } + + #[tokio::test] + async fn remote_requests_are_rejected_before_reading_local_registries() { + let request: ChatMcpCatalogRequest = serde_json::from_value(json!({ + "modeId": "Standard", "workspacePath": "/srv/project", "remoteConnectionId": "ssh-peer", + })) + .unwrap(); + assert!(build_chat_mcp_catalog(request) + .await + .unwrap_err() + .contains("unsupported for remote workspaces")); + } + + #[test] + fn request_tolerates_optional_and_future_fields() { + let request: ChatMcpCatalogRequest = + serde_json::from_value(json!({"modeId": "Standard", "futureField": true})).unwrap(); + assert!(request.workspace_path.is_none()); + assert!(request.remote_connection_id.is_none()); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 6781b4f043..23f0597652 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "79584f59ffb49edb846b0beacc31f5393d81c3f117f3630445ab034c27546736", + "digest": "1230aa44c72c2cfb44fd28cb3da60fe04548cefd323b610b28e44bf48d574773", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 319, + "documentedItems": 320, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 210, + "interactive": 211, "unsupported": 0 } }, @@ -197,6 +197,7 @@ "permission-mailbox", "history-search", "agent-session-coordination", + "mcp-mentions", "tool-activity", "goals", "goal-maintenance", @@ -618,6 +619,52 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:open:mcp-mentions", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "mcp-mentions" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:tool-activity", "capabilityId": "feature.ai-assistant", @@ -19214,6 +19261,17 @@ ] } }, + { + "id": "mcp-mentions", + "titleZh": "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "titleEn": "Use @ in chat to select MCP servers available to the current mode", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“在对话中通过 @ 选择当前模式可用的 MCP 服务”需要用户在当前输入框选择服务并编辑草稿,没有可直接完成该交互的结构化 Command。也可通过工具名称或描述搜索所属服务;工具不会单独列出。目录按当前主机、工作区和模式过滤;Minimal 隐藏 MCP 入口。远程工作区暂不支持此选择器,Peer Host 必须协商 chat_mcp_catalog_v1 能力。", + "reasonEn": "“Use @ in chat to select MCP servers available to the current mode” requires the user to select a server and edit the current composer draft; no structured Command completes that interaction directly. Tool names and descriptions can find their owning server without separate tool rows. The catalog is scoped to the current host, workspace, and mode; Minimal hides MCP. Remote workspaces are unsupported; Peer Hosts must advertise chat_mcp_catalog_v1." + } + }, { "id": "tool-activity", "titleZh": "查看工具调用与后台命令,读取输出并继续输入或停止", @@ -19400,6 +19458,8 @@ "Page through, search, and backfill historical turns and session content", "让 Agent 创建、列出、取消、删除会话或向另一会话发送消息", "Let an agent create, list, cancel, or delete sessions and send messages to another session", + "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "Use @ in chat to select MCP servers available to the current mode", "查看工具调用与后台命令,读取输出并继续输入或停止", "Inspect tool calls and background commands, read output, send input, or stop them", "查看、创建并完成或阻塞当前会话目标", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index d415750918..8d142c433c 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:7976cf996632e304", + "digest": "fnv1a64:7a261da74dcfbe89", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -20,6 +20,7 @@ "product_control_presentation_v1", "cancel_tool", "tool_catalog", + "chat_mcp_catalog_v1", "user_question_response" ], "desktop": [ @@ -34,6 +35,7 @@ "product_control_presentation_v1", "cancel_tool", "tool_catalog", + "chat_mcp_catalog_v1", "user_question_response" ], "cli": [ @@ -44,6 +46,7 @@ "product_control_v1", "cancel_tool", "tool_catalog", + "chat_mcp_catalog_v1", "user_question_response" ] }, @@ -2187,6 +2190,17 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "get_chat_mcp_catalog", + "surface": "tauri_command", + "remoteWorkspace": "RemoteUnsupported", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "handled" + } + }, { "id": "get_clipboard_files", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index c48279cd7f..1b63f835fd 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -45,6 +45,8 @@ pub enum PeerHostCapability { CancelTool, /// Read-only `get_all_tools_info` catalog is implemented. ToolCatalog, + /// Mode/workspace-scoped `get_chat_mcp_catalog` is implemented. + ChatMcpCatalogV1, /// `submit_user_answers` is implemented for Runtime-owned questions. UserQuestionResponse, } @@ -63,6 +65,7 @@ impl PeerHostCapability { Self::ProductControlPresentationV1, Self::CancelTool, Self::ToolCatalog, + Self::ChatMcpCatalogV1, Self::UserQuestionResponse, ]; @@ -80,6 +83,7 @@ impl PeerHostCapability { Self::ProductControlPresentationV1 => "product_control_presentation_v1", Self::CancelTool => "cancel_tool", Self::ToolCatalog => "tool_catalog", + Self::ChatMcpCatalogV1 => "chat_mcp_catalog_v1", Self::UserQuestionResponse => "user_question_response", } } @@ -102,6 +106,7 @@ const CLI_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::ProductControlV1, PeerHostCapability::CancelTool, PeerHostCapability::ToolCatalog, + PeerHostCapability::ChatMcpCatalogV1, PeerHostCapability::UserQuestionResponse, ]; diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 2c0671fc0d..59f47e8749 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -265,6 +265,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_available_modes", Unaudited, Proxied, HANDLED), op("get_available_tools", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_baseline_snapshot_diff", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("get_chat_mcp_catalog", Unsupported, Proxied, HANDLED), op("get_clipboard_files", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("get_config", Unaudited, Proxied, HANDLED), op("get_configs", Unaudited, Proxied, HANDLED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 6bce575411..64297625f8 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -470,6 +470,22 @@ "command:delete_session" ] }, + { + "id": "mcp-mentions", + "titleZh": "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "titleEn": "Use @ in chat to select MCP servers available to the current mode", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“在对话中通过 @ 选择当前模式可用的 MCP 服务”需要用户在当前输入框选择服务并编辑草稿,没有可直接完成该交互的结构化 Command。也可通过工具名称或描述搜索所属服务;工具不会单独列出。目录按当前主机、工作区和模式过滤;Minimal 隐藏 MCP 入口。远程工作区暂不支持此选择器,Peer Host 必须协商 chat_mcp_catalog_v1 能力。", + "reasonEn": "“Use @ in chat to select MCP servers available to the current mode” requires the user to select a server and edit the current composer draft; no structured Command completes that interaction directly. Tool names and descriptions can find their owning server without separate tool rows. The catalog is scoped to the current host, workspace, and mode; Minimal hides MCP. Remote workspaces are unsupported; Peer Hosts must advertise chat_mcp_catalog_v1." + }, + "evidence": [ + "command:get_chat_mcp_catalog", + "source:src/web-ui/src/flow_chat/components/ChatContextPicker.tsx#onSelectMcp", + "source:src/web-ui/src/flow_chat/components/ChatInput.tsx#selectContextMcp" + ] + }, { "id": "tool-activity", "titleZh": "查看工具调用与后台命令,读取输出并继续输入或停止", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index d8414e402e..2854c87626 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "79584f59ffb49edb846b0beacc31f5393d81c3f117f3630445ab034c27546736", + "digest": "1230aa44c72c2cfb44fd28cb3da60fe04548cefd323b610b28e44bf48d574773", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 319, + "documentedItems": 320, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 210, + "interactive": 211, "unsupported": 0 } }, @@ -197,6 +197,7 @@ "permission-mailbox", "history-search", "agent-session-coordination", + "mcp-mentions", "tool-activity", "goals", "goal-maintenance", @@ -618,6 +619,52 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:open:mcp-mentions", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "mcp-mentions" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:tool-activity", "capabilityId": "feature.ai-assistant", @@ -19214,6 +19261,17 @@ ] } }, + { + "id": "mcp-mentions", + "titleZh": "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "titleEn": "Use @ in chat to select MCP servers available to the current mode", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“在对话中通过 @ 选择当前模式可用的 MCP 服务”需要用户在当前输入框选择服务并编辑草稿,没有可直接完成该交互的结构化 Command。也可通过工具名称或描述搜索所属服务;工具不会单独列出。目录按当前主机、工作区和模式过滤;Minimal 隐藏 MCP 入口。远程工作区暂不支持此选择器,Peer Host 必须协商 chat_mcp_catalog_v1 能力。", + "reasonEn": "“Use @ in chat to select MCP servers available to the current mode” requires the user to select a server and edit the current composer draft; no structured Command completes that interaction directly. Tool names and descriptions can find their owning server without separate tool rows. The catalog is scoped to the current host, workspace, and mode; Minimal hides MCP. Remote workspaces are unsupported; Peer Hosts must advertise chat_mcp_catalog_v1." + } + }, { "id": "tool-activity", "titleZh": "查看工具调用与后台命令,读取输出并继续输入或停止", @@ -19396,6 +19454,8 @@ "Page through, search, and backfill historical turns and session content", "让 Agent 创建、列出、取消、删除会话或向另一会话发送消息", "Let an agent create, list, cancel, or delete sessions and send messages to another session", + "在对话中通过 @ 选择当前模式可用的 MCP 服务", + "Use @ in chat to select MCP servers available to the current mode", "查看工具调用与后台命令,读取输出并继续输入或停止", "Inspect tool calls and background commands, read output, send input, or stop them", "查看、创建并完成或阻塞当前会话目标", diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss index d236e9f190..9b8c0a99d6 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss +++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss @@ -118,17 +118,22 @@ } &--skills { - width: max-content; + // Keep mixed search results bounded independently of intrinsic row widths. + width: min(560px, calc(100vw - 16px)); min-width: min(300px, calc(100vw - 16px)); max-width: calc(100vw - 16px); } &__skill-option > [data-openbitfun-part='content'] { - flex: 0 0 auto; + flex: 0 1 auto; + min-inline-size: 0; + max-inline-size: 60%; } &__skill-name { display: block; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx index 79973147d3..a44b4879da 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx +++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx @@ -1,6 +1,6 @@ /** * Unified chat context picker. - * The source level exposes files, skills, and images; typing searches providers together. + * The source level exposes files, skills, MCP, and images; typing searches providers together. */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; @@ -16,7 +16,7 @@ import { Tooltip, } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; -import { File, Loader2, MessageCircle, RotateCcw } from 'lucide-react'; +import { File, Loader2, MessageCircle, Plug, RotateCcw } from 'lucide-react'; import { sessionAPI, workspaceAPI } from '@/infrastructure/api'; import { externalSourcesAPI, @@ -40,6 +40,8 @@ import { type FileItem, } from './workspaceReferenceItems'; import type { SkillScanDiagnostic } from '@/infrastructure/config/types'; +import type { ChatMcpCatalog } from '@/infrastructure/api/service-api/ChatMcpAPI'; +import { chatMcpItems, type ContextPickerMcpItem } from './chatMcpItems'; import './ChatContextPicker.scss'; const log = createLogger('ChatContextPicker'); @@ -69,6 +71,12 @@ export interface ChatContextPickerProps { skillDiagnosticsAvailable?: boolean; onRetrySkills?: () => void; onSelectSkill?: (skill: ContextPickerSkill) => void; + mcpCatalog?: ChatMcpCatalog; + mcpLoading?: boolean; + mcpLoadFailed?: boolean; + mcpUnavailable?: 'remoteWorkspace' | 'unsupportedHost'; + onRefreshMcp?: () => void; + onSelectMcp?: (item: ContextPickerMcpItem) => void; onAddImage?: () => void; } @@ -83,13 +91,14 @@ export interface ContextPickerSkill { export type ChatContextPickerEntryView = 'sources' | 'files'; -type ContextPickerView = ChatContextPickerEntryView | 'skills'; +type ContextPickerView = ChatContextPickerEntryView | 'skills' | 'mcp'; type ContextPickerItem = | { kind: 'file'; item: FileItem } | { kind: 'session'; item: SessionReferenceCandidate } | { kind: 'skill'; item: ContextPickerSkill } - | { kind: 'source'; id: 'files' | 'skills' } + | { kind: 'mcp'; item: ContextPickerMcpItem } + | { kind: 'source'; id: 'files' | 'skills' | 'mcp' } | { kind: 'action'; id: 'add-image' | 'retry-skills' }; function mergeFileSearchResults( @@ -136,6 +145,12 @@ export const ChatContextPicker: React.FC = ({ skillDiagnosticsAvailable = true, onRetrySkills, onSelectSkill, + mcpCatalog, + mcpLoading = false, + mcpLoadFailed = false, + mcpUnavailable, + onRefreshMcp, + onSelectMcp, onAddImage, }) => { const { t } = useTranslation('flow-chat'); @@ -473,8 +488,14 @@ export const ChatContextPicker: React.FC = ({ const sourceItems = useMemo(() => [ { kind: 'source', id: 'files' }, ...(onSelectSkill ? [{ kind: 'source' as const, id: 'skills' as const }] : []), + ...(onSelectMcp ? [{ kind: 'source' as const, id: 'mcp' as const }] : []), ...(onAddImage ? [{ kind: 'action' as const, id: 'add-image' as const }] : []), - ], [onAddImage, onSelectSkill]); + ], [onAddImage, onSelectSkill, onSelectMcp]); + const mcpItems = useMemo(() => ( + onSelectMcp && !mcpLoading && !mcpLoadFailed && !mcpUnavailable + ? chatMcpItems(mcpCatalog?.tools ?? [], searchQuery).map(item => ({ kind: 'mcp', item })) + : [] + ), [onSelectMcp, mcpLoading, mcpLoadFailed, mcpUnavailable, mcpCatalog, searchQuery]); const skillItems = useMemo(() => { if (skillsLoading) return []; if (skillsLoadFailed) { @@ -493,6 +514,7 @@ export const ChatContextPicker: React.FC = ({ ? filteredSkills.map(item => ({ kind: 'skill' as const, item })) : []), ...sessionResults.map(item => ({ kind: 'session' as const, item })), + ...mcpItems, ...(onSelectSkill && skillsLoadFailed && onRetrySkills ? [{ kind: 'action' as const, id: 'retry-skills' as const }] : []), @@ -503,6 +525,7 @@ export const ChatContextPicker: React.FC = ({ } if (entryView === 'sources' && view === 'sources') return sourceItems; if (view === 'skills') return skillItems; + if (view === 'mcp') return mcpItems; return [ ...currentFiles.map(item => ({ kind: 'file' as const, item })), ...(currentPath ? [] : referenceItems.map(item => ({ kind: 'file' as const, item }))), @@ -522,6 +545,7 @@ export const ChatContextPicker: React.FC = ({ sessionResults, entryView, skillItems, + mcpItems, skillsLoadFailed, t, view, @@ -547,6 +571,8 @@ export const ChatContextPicker: React.FC = ({ const currentDirectoryTailStart = Math.max(0, currentDirectoryPath.lastIndexOf('/')); const currentViewLabel = view === 'skills' ? t('chatInput.boostSkills') + : view === 'mcp' + ? t('contextPicker.mcp.label') : view === 'sources' ? t('contextPicker.menuTitle') : currentDirectoryPath; @@ -575,6 +601,13 @@ export const ChatContextPicker: React.FC = ({ setSelectedIndex(0); }, []); + useEffect(() => { + if (view === 'mcp' && !onSelectMcp) { + setView('sources'); + setSelectedIndex(0); + } + }, [onSelectMcp, view]); + const handleSelect = useCallback((selection: ContextPickerItem) => { if (selection.kind === 'source') { openSource(selection.id); @@ -594,6 +627,11 @@ export const ChatContextPicker: React.FC = ({ onClose(); return; } + if (selection.kind === 'mcp') { + onSelectMcp?.(selection.item); + onClose(); + return; + } const timestamp = Date.now(); if (selection.kind === 'session') { @@ -630,7 +668,7 @@ export const ChatContextPicker: React.FC = ({ timestamp, }); onClose(); - }, [onAddImage, onClose, onRetrySkills, onSelectContext, onSelectSkill, openSource]); + }, [onAddImage, onClose, onRetrySkills, onSelectContext, onSelectSkill, onSelectMcp, openSource]); const handleItemClick = useCallback((selection: ContextPickerItem) => { if (selection.kind === 'file' && selection.item.isDirectory && !isSearchMode) { @@ -725,11 +763,19 @@ export const ChatContextPicker: React.FC = ({ const isSkillLoading = Boolean(onSelectSkill) && skillsLoading && (isSearchMode || view === 'skills'); - const isLoading = isFileLoading || isSessionLoading || isSkillLoading; + const showMcpStatus = Boolean(onSelectMcp) && (view === 'mcp' || isSearchMode); + const isLoading = isFileLoading || isSessionLoading || isSkillLoading || (showMcpStatus && mcpLoading); + const mcpStatus = mcpLoadFailed ? t('contextPicker.mcp.loadFailed') + : mcpUnavailable === 'remoteWorkspace' ? t('contextPicker.mcp.remoteWorkspace') + : mcpUnavailable === 'unsupportedHost' ? t('contextPicker.mcp.unsupportedHost') + : mcpCatalog?.modeRestricted ? t('contextPicker.mcp.modeRestricted') + : null; const emptyLabel = isSearchMode ? t('contextPicker.noMatchingResults') : view === 'skills' ? t('chatInput.boostSkillsEmpty') + : view === 'mcp' + ? mcpStatus ?? t('contextPicker.mcp.empty') : t('contextPicker.emptyDirectory'); const picker = ( @@ -738,7 +784,7 @@ export const ChatContextPicker: React.FC = ({ data-openbitfun-part="root" data-openbitfun-state={[ isLoading && 'loading', - fileLoadError && 'error', + (fileLoadError || (showMcpStatus && mcpLoadFailed)) && 'error', ].filter(Boolean).join(' ') || undefined} data-openbitfun-placement={isOverlay ? overlayLayout?.placement ?? 'top' : undefined} ref={containerRef} @@ -786,6 +832,17 @@ export const ChatContextPicker: React.FC = ({ )} )} + {showMcpStatus && onRefreshMcp && !mcpUnavailable && ( +