diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index f56d98a5a1..9647e321eb 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -46,9 +46,10 @@ native ID、disposition 和稳定 reason code,不包含 command arguments、UR environment 值或引用、header/authorization、cwd、未知字段和其他 transport 不猜测、不复制、不记录。导入条目始终为 `enabled: false` 与 `autoStart: false`;local 条目不继承完整父进程环境,只保留 MCP runtime owner 提供的安全环境。 -Codex 的 legacy `name` 是上游忽略的展示字段,不进入导入结果或行为版本;`startup_timeout_sec`、`tool_timeout_sec`、 -`enabled_tools`、`disabled_tools`、approval、environment/scopes/OAuth 与并行调用等运行敏感字段仍按不支持处理,不能因 -静态发现成功而丢弃语义后导入。 +Codex 的 legacy `name` 是上游忽略的展示字段,不进入导入结果或行为版本;`startup_timeout_sec`(含旧 +`startup_timeout_ms`)和 `tool_timeout_sec` 可进入受审批保护的兼容运行投影,但当前原生快照格式不能无损保留它们, +因此仍会阻断 C0a 导入。`enabled_tools`、`disabled_tools`、approval、environment/scopes/OAuth 与并行调用等运行敏感字段 +仍按不支持处理,不能因静态发现成功而丢弃语义后导入。 Codex 未显式声明 cwd 时,其兼容运行投影仍会把当前 workspace 作为 effective cwd;现有原生快照格式不会保留这项隐式 语义,因此 workspace 场景的 local 声明返回“需要设置”,不能以“没有 cwd 字段”为由导入后继承 BitFun 进程目录。 @@ -628,8 +629,12 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 扩展。删除项的一次性用户通知/有界墓碑尚未实现,当前行会在稳定重扫后消失;该展示增强 留待后续 PR,不能改变“先撤 route、禁止新调用”的运行语义。 8. 外部本地进程不继承 BitFun 的完整父进程环境,只保留启动所需的系统基线和配置显式声明的变量;这仍不是 OS - 沙箱,进程继续拥有当前用户的文件、网络和子进程权限。Remote 执行域、OpenCode OAuth client 配置、SSE、完整 - `timeout`/Agent 范围和通用凭据归属模块明确延后。 + 沙箱,进程继续拥有当前用户的文件、网络和子进程权限。OpenCode V1 标量 `timeout`、Codex 启动/工具 timeout 与 + Claude Code 单服务器执行 timeout 已映射为统一的启动、目录读取、执行阶段事实;Codex `startup_timeout_sec` 同时约束 + 初始化和首次工具目录请求,`tool_timeout_sec` 只约束工具执行。只有来源显式声明时才覆盖现有运行行为;当前使用每次请求的 + 硬期限,不因 progress 重置,超时只停止 + BitFun 的当前等待,不承诺服务端工作已经取消,也不触发自动重放或重启。Remote 执行域、OpenCode OAuth client 配置、SSE、OpenCode V2 分阶段 timeout + 配置格式、Agent 范围和通用凭据归属模块明确延后。 9. 本阶段只把外部 MCP 的 Tool 目录接入 Agent Tool 归属模块。通用 Resource/Prompt/MCP App Desktop 接口不接受无工作区 上下文的外部 runtime id;外部服务器发起的 roots、sampling 和 elicitation 请求也一律拒绝,防止跨工作区读取或借用 BitFun 宿主能力。后续若接入这些能力,必须先补独立契约、工作区路由与权限交互,不能复用全局连接绕过当前边界。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 896504605e..209a16a6ae 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -210,7 +210,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | OpenCode adapter 输出来源无关的 Reference provider snapshot;Product Assembly 生命周期协调器与 BitFun 原生关联目录合成唯一有效引用目录;关联目录视图和既有目录选择器消费 | 当前支持本地声明路径、description/hidden、异步刷新和 `@alias` 展示;原生关联目录始终在 OpenCode 引用之前,外部引用只读、不自动进入 Prompt 且不改变权限 | Git 引用、Remote 发现和下载/缓存不实现;无效高优先级 entry 阻断同 alias 的旧值并给出诊断,不回退到更宽松来源。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;adapter 提取静态文件引用与 shell 计划,Product Assembly 负责审批指纹和装配,Terminal owner 负责进程执行 | prompt 与静态 workspace 相对 UTF-8 `@file` 可发送;`!shell` 展示精确命令、工作目录与绝对 shell 路径,经重新校验后以不加载 profile 的隔离式 argv 执行,并仅把 stdout 按模板顺序加入 Prompt。为保持 OpenCode 语义,正常退出后的非零退出码仍使用 stdout。静态计划可记住,参数相关计划仅可单次运行;显式 agent 加缺省/true subtask 可走 approved fresh Subagent,其余 agent/model/variant/subtask 组合以及 shell 与委派的组合整体受限 | 任一文件读取、进程启动、超时或超限失败时不发送部分 Prompt;进程副作用不可回滚。最多 8 文件、单文件 64 KiB、文件总量 128 KiB;最多 8 条 shell 指令、单条 64 KiB、总计 128 KiB、每条 stdout 256 Ki 字符、30 秒;最终命令 1 MiB。安全模式禁用,Remote 不回退到本机。 | -| 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。 | +| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 当前支持 local stdio 和 HTTPS remote 的静态发现、首次/行为变化审批、冲突选择与 workspace 隔离的运行期接纳;显式 V1 `timeout` 作为毫秒值约束启动、目录读取和执行,并在 GUI/TUI 审阅详情中可见;C0a 快照导入只复制无 env/cwd/timeout 的 local command/args 或无 header/query/fragment/timeout 的 HTTPS remote,并保持 disabled | `{env:NAME}` 当前只允许用于运行期兼容来源的 environment/Header 值,不进入 C0a 快照;SSE、OpenCode OAuth client 配置、Agent 范围、Remote 执行域与 V2 分阶段 timeout 配置格式保持明确不支持;凭据、超时或网络失败只影响单个 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 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | @@ -384,8 +384,10 @@ OpenCode 生态内部仍按其规则覆盖同名内置命令,但跨独立 prov `enabled`,并在批准后按 workspace 交给现有 MCP 归属模块;工具在调用前复核 workspace route,Remote 不回退到本机实例。 远端静态摘要只展示 HTTPS origin,环境引用只展示变量名;为避免审批后通过环境变量改变已经确认的运行条件,`{env:NAME}` 仅 支持 environment/Header 值,展开后重新校验大小和协议。未配置 `cwd` 时遵循 OpenCode,使用当前 workspace。 - 外部本地进程默认不继承 BitFun 的完整父进程环境。SSE、OpenCode - `clientId/clientSecret/scope/callbackPort/redirectUri`、完整超时和 Agent 范围仍需后续接入,不能静默忽略。 + 外部本地进程默认不继承 BitFun 的完整父进程环境。显式 V1 `timeout` 通过现有 MCP runtime owner 分别约束启动、目录读取 + 和执行;缺省值继续使用 BitFun 既有行为。当前是每次请求的硬期限,不因 progress 重置;超时只停止 BitFun 的当前等待, + 不承诺服务端工作已经取消,也不自动重放或重启。SSE、OpenCode + `clientId/clientSecret/scope/callbackPort/redirectUri`、V2 分阶段 timeout 配置格式和 Agent 范围仍需后续接入,不能静默忽略。 - LSP 必须覆盖 initialization、扩展名匹配、环境变量和工作区生命周期。 - Formatter 必须覆盖写入后时机、`$FILE` 替换、`environment`、多个 Formatter 顺序和失败行为。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 32c97d9d0d..d78bd70af1 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -788,6 +788,8 @@ export const externalSourceContractPublicApiEntries = [ 'SourceQualifiedMcpServerId', 'ExternalMcpTransportKind', 'ExternalMcpStaticStatus', + 'ExternalMcpTimeouts', + 'MAX_EXTERNAL_MCP_TIMEOUT_MS', 'ExternalMcpServerDefinition', 'ExternalMcpActivationState', 'ExternalMcpCatalogEntry', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index d22f57ec00..993522ae44 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1454,6 +1454,8 @@ export function runManifestParserSelfTest({ } } for (const requiredSymbol of [ + 'ExternalMcpTimeouts', + 'MAX_EXTERNAL_MCP_TIMEOUT_MS', 'ExternalMcpServerDefinition', 'ExternalMcpSourceProvider', 'PreparedExternalMcpServer', diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index e20260cb1b..8f20a4b393 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -9,6 +9,20 @@ fn bounded_mcp_terminal_text(value: &str) -> String { } } +fn external_mcp_timeout_detail( + timeouts: &bitfun_product_domains::external_sources::ExternalMcpTimeouts, +) -> Option { + let phases = [ + ("startup", timeouts.startup_ms), + ("catalog", timeouts.catalog_ms), + ("execution", timeouts.execution_ms), + ] + .into_iter() + .filter_map(|(phase, timeout)| timeout.map(|timeout| format!("{phase} {timeout} ms"))) + .collect::>(); + (!phases.is_empty()).then(|| format!("timeouts: {}", phases.join(", "))) +} + fn external_mcp_state_label( state: &bitfun_core::external_sources::ExternalMcpActivationState, ) -> &'static str { @@ -306,6 +320,12 @@ impl ChatMode { ), _ => "unsupported external MCP transport".to_string(), }; + if let Some(timeouts) = + external_mcp_timeout_detail(&entry.definition.timeouts) + { + detail.push_str("; "); + detail.push_str(&timeouts); + } if let bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { reason } = &entry.activation_state { detail.push_str(&format!( "; unavailable reason: {}; next step: disable this server, fix its source configuration or authentication, then enable it", @@ -772,6 +792,7 @@ impl ChatMode { .get("xaa") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + timeouts: Default::default(), }; mcp_service.server_manager().add_server(config).await?; @@ -897,3 +918,22 @@ impl ChatMode { } } } + +#[cfg(test)] +mod external_mcp_timeout_tests { + use super::external_mcp_timeout_detail; + use bitfun_product_domains::external_sources::ExternalMcpTimeouts; + + #[test] + fn external_mcp_timeout_detail_lists_only_explicit_phases() { + let detail = external_mcp_timeout_detail(&ExternalMcpTimeouts { + startup_ms: Some(1_000), + catalog_ms: None, + execution_ms: Some(30_000), + }) + .expect("explicit timeouts should be visible"); + + assert_eq!(detail, "timeouts: startup 1000 ms, execution 30000 ms"); + assert!(external_mcp_timeout_detail(&ExternalMcpTimeouts::default()).is_none()); + } +} 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 47f9535b83..68d6168af6 100644 --- a/src/crates/adapters/claude-code-adapter/src/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/mcp_source.rs @@ -1,11 +1,12 @@ use bitfun_product_domains::external_sources::{ EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, - ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalMcpStaticStatus, ExternalMcpTimeouts, ExternalMcpTransportKind, + ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, + MAX_EXTERNAL_MCP_TIMEOUT_MS, }; use bitfun_static_hook_support::{ read_bounded_text, redacted_executable_preview, resolve_bounded_regular_file, @@ -24,8 +25,8 @@ 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 STDIO_FIELDS: &[&str] = &["type", "command", "args", "env", "cwd"]; -const HTTP_FIELDS: &[&str] = &["type", "url", "headers"]; +const STDIO_FIELDS: &[&str] = &["type", "command", "args", "env", "cwd", "timeout"]; +const HTTP_FIELDS: &[&str] = &["type", "url", "headers", "timeout"]; #[derive(Debug, Clone)] pub struct ClaudeCodeMcpProviderOptions { @@ -216,6 +217,16 @@ impl ClaudeCodeMcpProvider { let mut servers = Vec::new(); let mut prepared = BTreeMap::new(); for (name, (source, value)) in winners { + if timeout_is_ignored(&value) { + diagnostics.push( + ExternalSourceDiagnostic::warning( + "claude.mcp.execution_limit_ignored", + format!("Claude Code MCP server '{name}' timeout below 1000 ms is ignored"), + Some(source.clone()), + ) + .with_asset_kind(ExternalSourceAssetKind::Mcp), + ); + } let materialized = materialize_server(&input.context, &input.revision_key, source, name, value)?; prepared.insert( @@ -321,9 +332,14 @@ impl ExternalMcpSourceProvider for ClaudeCodeMcpProvider { server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, ) -> Result { - let (_, template) = + let (definition, template) = self.current_preparation(input, server_id, expected_behavior_version)?; - prepare_transport(template, server_id.clone(), expected_behavior_version) + prepare_transport( + template, + server_id.clone(), + expected_behavior_version, + definition.timeouts, + ) } fn prepare_import( @@ -588,6 +604,7 @@ fn materialize_local( let args = string_array(object.get("args")); let environment = string_map(object.get("env")); let mut reason = unsupported_field_reason(object, STDIO_FIELDS); + let timeouts = timeout_overrides(object, &mut reason); if let Err(error) = &args { reason.get_or_insert(error.clone()); } @@ -656,6 +673,7 @@ fn materialize_local( environment_reference_names: references, remote_url_preview: None, header_names: Vec::new(), + timeouts, source_enabled: true, behavior_version, static_status: status, @@ -683,6 +701,7 @@ fn materialize_remote( .to_string(); let headers = string_map(object.get("headers")); let mut reason = unsupported_field_reason(object, HTTP_FIELDS); + let timeouts = timeout_overrides(object, &mut reason); if let Err(error) = &headers { reason.get_or_insert(error.clone()); } @@ -726,6 +745,7 @@ fn materialize_remote( environment_reference_names: references, remote_url_preview: Some(preview), header_names: headers.keys().cloned().collect(), + timeouts, source_enabled: true, behavior_version, static_status: status, @@ -748,6 +768,54 @@ fn unsupported_field_reason(object: &Map, supported: &[&str]) -> }) } +fn timeout_is_ignored(value: &Value) -> bool { + value + .as_object() + .and_then(|object| object.get("timeout")) + .and_then(Value::as_u64) + .is_some_and(|timeout| timeout < 1_000) +} + +fn timeout_overrides( + object: &Map, + reason: &mut Option, +) -> ExternalMcpTimeouts { + let execution_ms = match object.get("timeout") { + None => None, + Some(Value::Number(number)) => match number.as_u64() { + Some(timeout) if (1_000..=MAX_EXTERNAL_MCP_TIMEOUT_MS).contains(&timeout) => { + Some(timeout) + } + Some(timeout) if timeout < 1_000 => None, + Some(_) => { + reason.get_or_insert_with(|| { + format!( + "Claude Code MCP timeout must not exceed {MAX_EXTERNAL_MCP_TIMEOUT_MS} milliseconds" + ) + }); + None + } + None => { + reason.get_or_insert_with(|| { + "Claude Code MCP timeout must be a non-negative integer".to_string() + }); + None + } + }, + Some(_) => { + reason.get_or_insert_with(|| { + "Claude Code MCP timeout must be a non-negative integer".to_string() + }); + None + } + }; + ExternalMcpTimeouts { + startup_ms: None, + catalog_ms: None, + execution_ms, + } +} + fn cwd_preview(path: &Path, context: &ExternalSourceContext) -> String { context .workspace_root @@ -783,6 +851,7 @@ fn unsupported_local( environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version, static_status: ExternalMcpStaticStatus::Unsupported { @@ -823,6 +892,7 @@ fn unsupported_remote( .unwrap_or_else(|| "https://unsupported.invalid/".to_string()), ), header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version, static_status: ExternalMcpStaticStatus::Unsupported { @@ -840,6 +910,7 @@ fn prepare_transport( template: PreparedTransportTemplate, id: SourceQualifiedMcpServerId, behavior_version: &str, + timeouts: ExternalMcpTimeouts, ) -> Result { let transport = match template { PreparedTransportTemplate::Local { @@ -877,6 +948,7 @@ fn prepare_transport( Ok(PreparedExternalMcpServer { id, behavior_version: behavior_version.to_string(), + timeouts, transport, }) } @@ -885,6 +957,13 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { + if !definition.timeouts.is_empty() { + return Err(ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP timeout overrides cannot be imported into native configuration", + false, + )); + } let transport = match template { PreparedTransportTemplate::Local { command, 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 433a66e261..5a6fce7ed2 100644 --- a/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs @@ -354,6 +354,69 @@ fn unsupported_upstream_fields_fail_closed_instead_of_changing_behavior_silently } } +#[test] +fn claude_timeout_controls_execution_and_subsecond_values_are_ignored() { + let fixture = Fixture::new(); + write( + fixture.project.join(".mcp.json"), + r#"{"mcpServers":{ + "docs":{"command":"docs-server","timeout":2500}, + "native-ignored":{"command":"ignored-server","timeout":500} + }}"#, + ); + let provider = fixture.provider(); + let input = fixture.input(); + + let snapshot = provider.discover(&input).unwrap(); + let docs = snapshot + .servers + .iter() + .find(|server| server.name == "docs") + .unwrap(); + let ignored = snapshot + .servers + .iter() + .find(|server| server.name == "native-ignored") + .unwrap(); + assert_eq!(docs.static_status, ExternalMcpStaticStatus::Ready); + assert_eq!(docs.timeouts.startup_ms, None); + assert_eq!(docs.timeouts.catalog_ms, None); + assert_eq!(docs.timeouts.execution_ms, Some(2_500)); + assert_eq!(ignored.static_status, ExternalMcpStaticStatus::Ready); + assert!(ignored.timeouts.is_empty()); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "claude.mcp.execution_limit_ignored")); + + let prepared = provider + .prepare_server(&input, &docs.id, &docs.behavior_version) + .unwrap(); + assert_eq!(prepared.timeouts, docs.timeouts); + assert_eq!( + provider + .prepare_import(&input, &docs.id, &docs.behavior_version) + .unwrap_err() + .code, + "external_mcp.import_setup_required" + ); +} + +#[test] +fn claude_timeout_that_cannot_cross_product_surfaces_losslessly_is_unsupported() { + let fixture = Fixture::new(); + write( + fixture.project.join(".mcp.json"), + r#"{"mcpServers":{"docs":{"command":"docs-server","timeout":9007199254740992}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.input()).unwrap(); + assert!(matches!( + snapshot.servers[0].static_status, + ExternalMcpStaticStatus::Unsupported { .. } + )); +} + #[test] fn suppression_recomputes_native_winner_and_stale_prepare_fails_closed() { let fixture = Fixture::new(); diff --git a/src/crates/adapters/codex-adapter/src/mcp_source.rs b/src/crates/adapters/codex-adapter/src/mcp_source.rs index 7252d1f144..a900dbae27 100644 --- a/src/crates/adapters/codex-adapter/src/mcp_source.rs +++ b/src/crates/adapters/codex-adapter/src/mcp_source.rs @@ -1,11 +1,12 @@ use bitfun_product_domains::external_sources::{ EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, - ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalMcpStaticStatus, ExternalMcpTimeouts, ExternalMcpTransportKind, + ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, + MAX_EXTERNAL_MCP_TIMEOUT_MS, }; use bitfun_static_hook_support::{ read_bounded_text, redacted_executable_preview, resolve_bounded_regular_file, @@ -39,6 +40,9 @@ const SUPPORTED_FIELDS: &[&str] = &[ "enabled", "required", "auth", + "startup_timeout_sec", + "startup_timeout_ms", + "tool_timeout_sec", ]; #[derive(Debug, Clone)] @@ -322,9 +326,14 @@ impl ExternalMcpSourceProvider for CodexMcpProvider { server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, ) -> Result { - let (_, template) = + let (definition, template) = self.current_preparation(input, server_id, expected_behavior_version)?; - prepare_transport(template, server_id.clone(), expected_behavior_version) + prepare_transport( + template, + server_id.clone(), + expected_behavior_version, + definition.timeouts, + ) } fn prepare_import( @@ -620,6 +629,7 @@ fn materialize_local( } let environment = string_map(object.get("env"), "env", &mut reasons); let environment_refs = environment_refs(object.get("env_vars"), &mut reasons); + let timeouts = timeout_overrides(object, &mut reasons); let working_directory_explicit = object.contains_key("cwd"); let cwd = string_value_optional(object.get("cwd"), "cwd", &mut reasons).map(PathBuf::from); let cwd = cwd.or_else(|| context.workspace_root.clone()); @@ -659,6 +669,7 @@ fn materialize_local( environment_reference_names, remote_url_preview: None, header_names: Vec::new(), + timeouts, source_enabled: enabled, behavior_version, static_status: status, @@ -707,6 +718,7 @@ fn materialize_remote( "bearer_token_env_var", &mut reasons, ); + let timeouts = timeout_overrides(object, &mut reasons); if bearer_token_env_var .as_deref() .is_some_and(|name| !valid_environment_name(name)) @@ -769,6 +781,7 @@ fn materialize_remote( environment_reference_names, remote_url_preview: Some(preview), header_names: header_names.into_iter().collect(), + timeouts, source_enabled: enabled, behavior_version, static_status: status, @@ -804,6 +817,7 @@ fn unsupported_local( environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version, static_status: ExternalMcpStaticStatus::Unsupported { @@ -826,6 +840,7 @@ fn prepare_transport( template: PreparedTransportTemplate, id: SourceQualifiedMcpServerId, behavior_version: &str, + timeouts: ExternalMcpTimeouts, ) -> Result { let transport = match template { PreparedTransportTemplate::Local { @@ -895,6 +910,7 @@ fn prepare_transport( Ok(PreparedExternalMcpServer { id, behavior_version: behavior_version.to_string(), + timeouts, transport, }) } @@ -903,6 +919,13 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { + if !definition.timeouts.is_empty() { + return Err(ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP timeout overrides cannot be imported into native configuration", + false, + )); + } let transport = match template { PreparedTransportTemplate::Local { command, @@ -1058,6 +1081,64 @@ fn environment_refs(value: Option<&Value>, reasons: &mut Vec) -> BTreeMa result } +fn timeout_overrides( + object: &toml::map::Map, + reasons: &mut Vec, +) -> ExternalMcpTimeouts { + let startup_ms = if let Some(value) = object.get("startup_timeout_sec") { + duration_seconds_millis(value, "startup_timeout_sec", reasons) + } else { + match object.get("startup_timeout_ms") { + None => None, + Some(Value::Integer(value)) + if (1..=MAX_EXTERNAL_MCP_TIMEOUT_MS as i64).contains(value) => + { + Some(*value as u64) + } + Some(_) => { + reasons.push(format!( + "Codex MCP startup_timeout_ms must be an integer from 1 to {MAX_EXTERNAL_MCP_TIMEOUT_MS}" + )); + None + } + } + }; + let execution_ms = object + .get("tool_timeout_sec") + .and_then(|value| duration_seconds_millis(value, "tool_timeout_sec", reasons)); + ExternalMcpTimeouts { + startup_ms, + catalog_ms: startup_ms, + execution_ms, + } +} + +fn duration_seconds_millis(value: &Value, field: &str, reasons: &mut Vec) -> Option { + let seconds = match value { + Value::Integer(value) => *value as f64, + Value::Float(value) => *value, + _ => { + reasons.push(format!("Codex MCP {field} must be a positive number")); + return None; + } + }; + let millis = std::time::Duration::try_from_secs_f64(seconds) + .ok() + .and_then(|duration| { + let nanos = duration.as_nanos(); + u64::try_from(nanos.div_ceil(1_000_000)).ok() + }); + match millis.filter(|millis| (1..=MAX_EXTERNAL_MCP_TIMEOUT_MS).contains(millis)) { + Some(millis) => Some(millis), + None => { + reasons.push(format!( + "Codex MCP {field} must resolve to 1..={MAX_EXTERNAL_MCP_TIMEOUT_MS} milliseconds" + )); + None + } + } +} + fn static_status(enabled: bool, reasons: Vec) -> ExternalMcpStaticStatus { if !enabled { ExternalMcpStaticStatus::DisabledBySource diff --git a/src/crates/adapters/codex-adapter/tests/mcp_source.rs b/src/crates/adapters/codex-adapter/tests/mcp_source.rs index 698c1c58a9..5e0281ee11 100644 --- a/src/crates/adapters/codex-adapter/tests/mcp_source.rs +++ b/src/crates/adapters/codex-adapter/tests/mcp_source.rs @@ -390,6 +390,52 @@ enabled = false ); } +#[test] +fn codex_timeout_fields_map_to_their_native_mcp_phases() { + let fixture = Fixture::new(); + write( + fixture.codex_home.join("config.toml"), + r#"[mcp_servers.docs] +command = "docs-server" +startup_timeout_sec = 1.25 +startup_timeout_ms = 9000 +tool_timeout_sec = 2.5 +"#, + ); + let provider = fixture.provider(); + let input = fixture.input(); + + let snapshot = provider.discover(&input).unwrap(); + let server = &snapshot.servers[0]; + assert_eq!(server.static_status, ExternalMcpStaticStatus::Ready); + assert_eq!(server.timeouts.startup_ms, Some(1_250)); + assert_eq!(server.timeouts.catalog_ms, Some(1_250)); + assert_eq!(server.timeouts.execution_ms, Some(2_500)); + + let prepared = provider + .prepare_server(&input, &server.id, &server.behavior_version) + .unwrap(); + assert_eq!(prepared.timeouts, server.timeouts); +} + +#[test] +fn codex_timeout_that_cannot_cross_product_surfaces_losslessly_is_unsupported() { + let fixture = Fixture::new(); + write( + fixture.codex_home.join("config.toml"), + r#"[mcp_servers.docs] +command = "docs-server" +startup_timeout_ms = 9007199254740992 +"#, + ); + + let snapshot = fixture.provider().discover(&fixture.input()).unwrap(); + assert!(matches!( + snapshot.servers[0].static_status, + ExternalMcpStaticStatus::Unsupported { .. } + )); +} + #[test] fn diagnostic_only_required_does_not_change_behavior_version() { 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 9bb16ef568..66cddaa69f 100644 --- a/src/crates/adapters/opencode-adapter/src/mcp_source.rs +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -6,11 +6,12 @@ use crate::local_source_paths::{ use bitfun_product_domains::external_sources::{ EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, - ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalMcpStaticStatus, ExternalMcpTimeouts, ExternalMcpTransportKind, + ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, + MAX_EXTERNAL_MCP_TIMEOUT_MS, }; use bitfun_services_core::jsonc::strip_jsonc; use bitfun_static_hook_support::BoundedTextRead; @@ -294,9 +295,14 @@ impl ExternalMcpSourceProvider for OpenCodeMcpProvider { server_id: &SourceQualifiedMcpServerId, expected_behavior_version: &str, ) -> Result { - let (_, prepared) = + let (definition, prepared) = self.current_preparation(input, server_id, expected_behavior_version)?; - resolve_runtime_values(prepared, server_id.clone(), expected_behavior_version) + resolve_runtime_values( + prepared, + server_id.clone(), + expected_behavior_version, + definition.timeouts, + ) } fn prepare_import( @@ -415,6 +421,7 @@ fn materialize_server( environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled, behavior_version, static_status: ExternalMcpStaticStatus::Unsupported { reason }, @@ -443,8 +450,11 @@ fn materialize_local_server( let command_parts = string_array(object.get("command")); 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 timeouts = timeout_overrides(object).unwrap_or_else(|error| { + reason.get_or_insert(error); + ExternalMcpTimeouts::default() + }); let command_parts = command_parts.unwrap_or_default(); if command_parts.is_empty() { reason.get_or_insert_with(|| "Local MCP command must not be empty".to_string()); @@ -525,6 +535,7 @@ fn materialize_local_server( environment_reference_names, remote_url_preview: None, header_names: Vec::new(), + timeouts, source_enabled, behavior_version, static_status, @@ -553,8 +564,11 @@ fn materialize_remote_server( .unwrap_or_default() .to_string(); let mut reason = unsupported_field_reason(object, REMOTE_FIELDS) - .or_else(|| timeout_unsupported_reason(object)) .or_else(|| unsupported_variable_reason(object)); + let timeouts = timeout_overrides(object).unwrap_or_else(|error| { + reason.get_or_insert(error); + ExternalMcpTimeouts::default() + }); let preview_url = match sanitized_https_url(&raw_url) { Ok(url) => url, Err(error) => { @@ -620,6 +634,7 @@ fn materialize_remote_server( environment_reference_names, remote_url_preview: Some(preview_url), header_names: headers.keys().cloned().collect(), + timeouts, source_enabled, behavior_version, static_status, @@ -636,6 +651,7 @@ fn resolve_runtime_values( template: PreparedTransportTemplate, id: SourceQualifiedMcpServerId, behavior_version: &str, + timeouts: ExternalMcpTimeouts, ) -> Result { let transport = match template { PreparedTransportTemplate::Local { @@ -719,6 +735,7 @@ fn resolve_runtime_values( Ok(PreparedExternalMcpServer { id, behavior_version: behavior_version.to_string(), + timeouts, transport, }) } @@ -727,6 +744,13 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { + if !definition.timeouts.is_empty() { + return Err(ExternalSourceProviderError::new( + "external_mcp.import_setup_required", + "MCP timeout overrides cannot be imported into native configuration", + false, + )); + } let transport = match template { PreparedTransportTemplate::Local { command, @@ -834,13 +858,25 @@ fn collect_environment_reference_names<'a>( Ok(names.into_iter().collect()) } -fn timeout_unsupported_reason(object: &Map) -> Option { +fn timeout_overrides(object: &Map) -> Result { match object.get("timeout") { - None => None, - Some(Value::Number(number)) if number.as_u64() == Some(5000) => None, - Some(_) => { - Some("Custom OpenCode MCP initialization timeout is not supported yet".to_string()) - } + None => Ok(ExternalMcpTimeouts::default()), + Some(Value::Number(number)) => number + .as_u64() + .filter(|timeout| (1..=MAX_EXTERNAL_MCP_TIMEOUT_MS).contains(timeout)) + .map(|timeout| ExternalMcpTimeouts { + startup_ms: Some(timeout), + catalog_ms: Some(timeout), + execution_ms: Some(timeout), + }) + .ok_or_else(|| { + format!( + "OpenCode MCP timeout must be an integer from 1 to {MAX_EXTERNAL_MCP_TIMEOUT_MS} milliseconds" + ) + }), + Some(_) => Err(format!( + "OpenCode MCP timeout must be an integer from 1 to {MAX_EXTERNAL_MCP_TIMEOUT_MS} milliseconds" + )), } } 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 2c618b8ac7..1fd5645a7d 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs @@ -447,7 +447,8 @@ fn unsupported_or_source_disabled_servers_remain_visible_but_cannot_be_prepared( "mcp": { "disabled": {"type":"local","command":["node","server.js"],"enabled":false}, "insecure": {"type":"remote","url":"http://example.test/mcp"}, - "custom-timeout": {"type":"remote","url":"https://example.test/mcp","timeout":1000}, + "invalid-timeout": {"type":"remote","url":"https://example.test/mcp","timeout":0}, + "unsafe-timeout": {"type":"remote","url":"https://example.test/mcp","timeout":9007199254740992}, "client-secret": { "type":"remote", "url":"https://example.test/mcp", @@ -478,7 +479,8 @@ fn unsupported_or_source_disabled_servers_remain_visible_but_cannot_be_prepared( )); for name in [ "insecure", - "custom-timeout", + "invalid-timeout", + "unsafe-timeout", "client-secret", "mutable-command", "mutable-host", @@ -498,6 +500,52 @@ fn unsupported_or_source_disabled_servers_remain_visible_but_cannot_be_prepared( } } +#[test] +fn opencode_timeout_applies_to_all_mcp_lifecycle_phases() { + 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":{"docs":{"type":"remote","url":"https://example.test/mcp","timeout":1250}}}"#, + ) + .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 server = snapshot + .servers + .iter() + .find(|server| server.name == "docs") + .unwrap(); + assert!(matches!( + server.static_status, + ExternalMcpStaticStatus::Ready + )); + assert_eq!(server.timeouts.startup_ms, Some(1_250)); + assert_eq!(server.timeouts.catalog_ms, Some(1_250)); + assert_eq!(server.timeouts.execution_ms, Some(1_250)); + + let prepared = provider + .prepare_server(&input, &server.id, &server.behavior_version) + .unwrap(); + assert_eq!(prepared.timeouts, server.timeouts); + assert_eq!( + provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err() + .code, + "external_mcp.import_setup_required" + ); +} + #[test] fn external_opencode_config_dir_is_a_user_scoped_late_override_and_keeps_global_sources() { let temp = TempDir::new().unwrap(); diff --git a/src/crates/assembly/core/src/external_mcp.rs b/src/crates/assembly/core/src/external_mcp.rs index 7f70b88cfc..77a75dbc38 100644 --- a/src/crates/assembly/core/src/external_mcp.rs +++ b/src/crates/assembly/core/src/external_mcp.rs @@ -1,6 +1,6 @@ use crate::service::mcp::{ - get_global_mcp_service, ConfigLocation, MCPServerConfig, MCPServerStatus, MCPServerTransport, - MCPServerType, + get_global_mcp_service, ConfigLocation, MCPServerConfig, MCPServerStatus, MCPServerTimeouts, + MCPServerTransport, MCPServerType, }; use async_trait::async_trait; use bitfun_external_sources::ExternalMcpCoordinatorSnapshot; @@ -179,6 +179,11 @@ pub(super) fn prepared_mcp_config( candidate: &ActiveExternalMcpCandidate, prepared: PreparedExternalMcpServer, ) -> Result { + let timeouts = MCPServerTimeouts { + startup_ms: prepared.timeouts.startup_ms, + catalog_ms: prepared.timeouts.catalog_ms, + execution_ms: prepared.timeouts.execution_ms, + }; let ( server_type, transport, @@ -248,6 +253,7 @@ pub(super) fn prepared_mcp_config( oauth: None, oauth_enabled, xaa: None, + timeouts, }; config.validate().map_err(|_| { "The external MCP configuration is not valid for the BitFun runtime".to_string() @@ -261,7 +267,6 @@ struct CandidateGroup<'a> { external: Vec<&'a ExternalMcpServerDefinition>, } - /// Produces the source-neutral product decision for external MCP candidates. /// This function is pure: no provider preparation, process launch, credential /// access, or network request can occur while a user decision is pending. @@ -314,9 +319,7 @@ pub(super) fn reconcile_external_mcp_catalog( group .native .sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id)); - group - .external - .sort_by_key(|left| left.candidate_id()); + group.external.sort_by_key(|left| left.candidate_id()); let active_external = group .external .iter() diff --git a/src/crates/assembly/core/src/external_mcp_import.rs b/src/crates/assembly/core/src/external_mcp_import.rs index 6b15429531..c48c918887 100644 --- a/src/crates/assembly/core/src/external_mcp_import.rs +++ b/src/crates/assembly/core/src/external_mcp_import.rs @@ -511,6 +511,7 @@ mod tests { environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: Default::default(), source_enabled: true, behavior_version: "sha256:behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, diff --git a/src/crates/assembly/core/src/external_mcp_tests.rs b/src/crates/assembly/core/src/external_mcp_tests.rs index 63e80e2392..a47ebe0267 100644 --- a/src/crates/assembly/core/src/external_mcp_tests.rs +++ b/src/crates/assembly/core/src/external_mcp_tests.rs @@ -6,8 +6,8 @@ use bitfun_external_sources::ExternalMcpCoordinatorSnapshot; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, EcosystemId, ExecutionDomainId, ExternalMcpActivationState, ExternalMcpServerDefinition, ExternalMcpStaticStatus, - ExternalMcpTransportKind, ExternalSourceCatalogEntry, ExternalSourceHealth, - ExternalSourceLifecycleState, ExternalSourceRecord, ExternalSourceScope, + ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceCatalogEntry, + ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceRecord, ExternalSourceScope, PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, }; @@ -58,6 +58,7 @@ fn snapshot(behavior_version: &str) -> ExternalMcpCoordinatorSnapshot { environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version: behavior_version.to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -338,6 +339,11 @@ fn prepared_local_external_mcp_keeps_cwd_and_does_not_inherit_bitfun_secrets() { let prepared = PreparedExternalMcpServer { id: definition.id, behavior_version: definition.behavior_version, + timeouts: ExternalMcpTimeouts { + startup_ms: Some(1_000), + catalog_ms: Some(2_000), + execution_ms: Some(3_000), + }, transport: PreparedExternalMcpTransport::Local { command: "npx".to_string(), args: vec!["-y".to_string(), "example-mcp".to_string()], @@ -355,6 +361,9 @@ fn prepared_local_external_mcp_keeps_cwd_and_does_not_inherit_bitfun_secrets() { Some("D:/workspace/project") ); assert_eq!(config.inherit_parent_environment, Some(false)); + assert_eq!(config.timeouts.startup_ms, Some(1_000)); + assert_eq!(config.timeouts.catalog_ms, Some(2_000)); + assert_eq!(config.timeouts.execution_ms, Some(3_000)); assert_eq!( config.env.get("EXPLICIT_TOKEN").map(String::as_str), Some("secret") diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 7f3a69ea47..47f3cae499 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -6933,6 +6933,7 @@ mod tests { oauth: None, oauth_enabled: None, xaa: None, + timeouts: Default::default(), } } 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 626a4513bb..910b1e1b07 100644 --- a/src/crates/assembly/core/src/service/mcp/config/service.rs +++ b/src/crates/assembly/core/src/service/mcp/config/service.rs @@ -187,6 +187,7 @@ mod tests { oauth: None, oauth_enabled: None, xaa: None, + timeouts: Default::default(), } } diff --git a/src/crates/assembly/core/src/service/mcp/mod.rs b/src/crates/assembly/core/src/service/mcp/mod.rs index f2e2b28472..c6d42dec87 100644 --- a/src/crates/assembly/core/src/service/mcp/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/mod.rs @@ -28,7 +28,7 @@ pub use protocol::{ pub use server::{ MCPConnection, MCPConnectionPool, MCPServerConfig, MCPServerManager, MCPServerStatus, - MCPServerTransport, MCPServerType, + MCPServerTimeouts, MCPServerTransport, MCPServerType, }; pub use adapter::{ diff --git a/src/crates/assembly/core/src/service/mcp/server/config.rs b/src/crates/assembly/core/src/service/mcp/server/config.rs index ab74a0ca8f..fcf6c9974d 100644 --- a/src/crates/assembly/core/src/service/mcp/server/config.rs +++ b/src/crates/assembly/core/src/service/mcp/server/config.rs @@ -4,7 +4,8 @@ use crate::util::errors::BitFunError; use bitfun_services_integrations::mcp::server::MCPServerConfigValidationError; pub use bitfun_services_integrations::mcp::server::{ - MCPServerConfig, MCPServerOAuthConfig, MCPServerTransport, MCPServerXaaConfig, + MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, + MCPServerXaaConfig, }; impl From for BitFunError { diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/external_lifecycle.rs b/src/crates/assembly/core/src/service/mcp/server/manager/external_lifecycle.rs index 21fdd978db..b9584919cb 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/external_lifecycle.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/external_lifecycle.rs @@ -1,5 +1,17 @@ use super::*; +const EXTERNAL_START_GUARD_ALLOWANCE: Duration = Duration::from_secs(30); + +fn external_start_timeout(timeouts: super::super::MCPServerTimeouts) -> Duration { + // The outer guard also covers bounded orchestration that sits outside the + // per-request initialize and Tool catalog deadlines. + let explicit_budget_ms = timeouts + .startup_ms + .unwrap_or_default() + .saturating_add(timeouts.catalog_ms.unwrap_or_default()); + EXTERNAL_START_GUARD_ALLOWANCE.saturating_add(Duration::from_millis(explicit_budget_ms)) +} + impl MCPServerManager { /// Adds a runtime-only MCP server without saving it to user or project config. pub async fn add_ephemeral_server(&self, config: MCPServerConfig) -> BitFunResult<()> { @@ -97,9 +109,8 @@ impl MCPServerManager { let _ = self.remove_ephemeral_server(&server_id).await; return Err(error); } - self.start_connection_event_listener(&server_id, &config.name, connection.clone()) + self.start_connection_event_listener(&server_id, &config.name, connection) .await; - self.warm_catalog_caches(&server_id, connection).await; self.ephemeral_ready_servers .write() .await @@ -146,11 +157,11 @@ impl MCPServerManager { // for a third-party process or network handshake. Registration is // synchronous so status reads immediately see Loading; startup is // bounded in the background and cleans up only this runtime item. - const EXTERNAL_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + let start_timeout = external_start_timeout(config.timeouts); let manager = self.clone(); tokio::spawn(async move { let startup = tokio::time::timeout( - EXTERNAL_START_TIMEOUT, + start_timeout, manager.start_server_with_external_token( &server_id, Some(Arc::clone(&start_token)), @@ -354,3 +365,33 @@ impl MCPServerManager { Ok(()) } } + +#[cfg(test)] +mod timeout_tests { + use super::external_start_timeout; + use crate::service::mcp::MCPServerTimeouts; + use std::time::Duration; + + #[test] + fn external_start_guard_preserves_explicit_startup_and_catalog_budgets() { + assert_eq!( + external_start_timeout(MCPServerTimeouts::default()), + Duration::from_secs(30) + ); + assert_eq!( + external_start_timeout(MCPServerTimeouts { + startup_ms: Some(45_000), + catalog_ms: Some(20_000), + execution_ms: Some(90_000), + }), + Duration::from_secs(95) + ); + assert_eq!( + external_start_timeout(MCPServerTimeouts { + execution_ms: Some(90_000), + ..Default::default() + }), + Duration::from_secs(30) + ); + } +} diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs index 965a47608b..3d5a3b4d7b 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs @@ -292,7 +292,12 @@ impl MCPServerManager { self.start_connection_event_listener(server_id, &config.name, connection.clone()) .await; - self.warm_catalog_caches(server_id, connection).await; + // Runtime-only external MCP currently publishes workspace-routed Tools + // only. Resources and Prompts have no external ownership/routing path, + // so their best-effort warmup must not delay external tool readiness. + if external_workspace_scope.is_none() { + self.warm_catalog_caches(server_id, connection).await; + } if external_workspace_scope.is_some() { self.ephemeral_ready_servers .write() diff --git a/src/crates/assembly/core/src/service/mcp/server/mod.rs b/src/crates/assembly/core/src/service/mcp/server/mod.rs index 2ab1062c13..a4feb27cba 100644 --- a/src/crates/assembly/core/src/service/mcp/server/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/server/mod.rs @@ -9,7 +9,10 @@ mod process; mod registry; pub use bitfun_services_integrations::mcp::server::{MCPServerStatus, MCPServerType}; -pub use config::{MCPServerConfig, MCPServerOAuthConfig, MCPServerTransport, MCPServerXaaConfig}; +pub use config::{ + MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, + MCPServerXaaConfig, +}; pub use connection::{MCPConnection, MCPConnectionPool}; pub use manager::MCPServerManager; pub use process::MCPServerProcess; diff --git a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs index 07933f0681..c2b9b0eacb 100644 --- a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs +++ b/src/crates/assembly/external-sources/tests/mcp_coordinator.rs @@ -55,6 +55,7 @@ fn snapshot(source_id: &str, version: &str) -> ExternalMcpProviderSnapshot { environment_reference_names: Vec::new(), remote_url_preview: Some("https://example.test/mcp".to_string()), header_names: Vec::new(), + timeouts: Default::default(), source_enabled: true, behavior_version: version.to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -81,6 +82,7 @@ fn merged_snapshot() -> ExternalMcpProviderSnapshot { environment_reference_names: Vec::new(), remote_url_preview: Some("https://example.test/mcp".to_string()), header_names: Vec::new(), + timeouts: Default::default(), source_enabled: true, behavior_version: "behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -132,6 +134,7 @@ impl ExternalMcpSourceProvider for FakeProvider { Ok(PreparedExternalMcpServer { id: server_id.clone(), behavior_version: expected_behavior_version.to_string(), + timeouts: Default::default(), transport: PreparedExternalMcpTransport::Remote { url: "https://example.test/mcp".to_string(), headers: BTreeMap::new(), diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 620963dd62..14e0ccba74 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -542,6 +542,41 @@ pub enum ExternalMcpStaticStatus { Invalid { reason: String }, } +/// Largest timeout that remains exact across Rust, JSON, GUI, and TUI surfaces. +pub const MAX_EXTERNAL_MCP_TIMEOUT_MS: u64 = 9_007_199_254_740_991; + +/// Explicit lifecycle timeout overrides disclosed by an external MCP source. +/// Missing phases retain the product runtime's existing behavior. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalMcpTimeouts { + #[serde(skip_serializing_if = "Option::is_none")] + pub startup_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub catalog_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_ms: Option, +} + +impl ExternalMcpTimeouts { + pub fn is_empty(&self) -> bool { + self.startup_ms.is_none() && self.catalog_ms.is_none() && self.execution_ms.is_none() + } + + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + if [self.startup_ms, self.catalog_ms, self.execution_ms] + .into_iter() + .flatten() + .any(|timeout| timeout == 0 || timeout > MAX_EXTERNAL_MCP_TIMEOUT_MS) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "MCP timeout", + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalMcpServerDefinition { @@ -566,6 +601,8 @@ pub struct ExternalMcpServerDefinition { pub remote_url_preview: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub header_names: Vec, + #[serde(default, skip_serializing_if = "ExternalMcpTimeouts::is_empty")] + pub timeouts: ExternalMcpTimeouts, pub source_enabled: bool, pub behavior_version: String, pub static_status: ExternalMcpStaticStatus, @@ -606,6 +643,7 @@ impl ExternalMcpServerDefinition { if let Some(url) = &self.remote_url_preview { validate_text(url, "MCP remote URL preview")?; } + self.timeouts.validate()?; let mut environment_keys = BTreeSet::new(); for key in &self.environment_keys { validate_id(key, "MCP environment key")?; @@ -810,6 +848,7 @@ impl fmt::Debug for PreparedExternalMcpTransport { pub struct PreparedExternalMcpServer { pub id: SourceQualifiedMcpServerId, pub behavior_version: String, + pub timeouts: ExternalMcpTimeouts, pub transport: PreparedExternalMcpTransport, } 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 2fbcd93fcb..e9f9cc074b 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -18,12 +18,12 @@ use bitfun_product_domains::external_sources::{ 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, + ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceAssetKind, + ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, + ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, + ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, @@ -1158,6 +1158,7 @@ fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { environment_reference_names: Vec::new(), remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), header_names: vec!["Authorization".to_string()], + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version: "sha256:behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -1181,6 +1182,7 @@ fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { let prepared = PreparedExternalMcpServer { id: definition.id, behavior_version: definition.behavior_version, + timeouts: ExternalMcpTimeouts::default(), transport: PreparedExternalMcpTransport::Remote { url: "https://mcp.example.com/mcp?token=url-secret".to_string(), headers: [( @@ -1200,6 +1202,43 @@ fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { assert!(!format!("{prepared:?}").contains("url-secret")); } +#[test] +fn external_mcp_timeouts_are_positive_optional_millisecond_facts() { + let timeouts = ExternalMcpTimeouts { + startup_ms: Some(2_000), + catalog_ms: None, + execution_ms: Some(30_000), + }; + + timeouts.validate().expect("positive timeouts are valid"); + assert_eq!( + serde_json::to_value(&timeouts).unwrap(), + serde_json::json!({ + "startupMs": 2_000, + "executionMs": 30_000, + }) + ); + assert!(ExternalMcpTimeouts { + startup_ms: Some(0), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_991), + ..Default::default() + } + .validate() + .is_ok()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_992), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts::default().is_empty()); +} + #[test] fn external_mcp_revision_key_never_exposes_material_through_debug_output() { let key = ExternalMcpRevisionKey::new([0x5a; 32]); @@ -1250,6 +1289,7 @@ fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version: "sha256:behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -1350,6 +1390,7 @@ fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fiel environment_reference_names: Vec::new(), remote_url_preview: None, header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version: "sha256:behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, @@ -1872,6 +1913,7 @@ fn control_projection_does_not_infer_review_facts_from_runtime_activation() { environment_reference_names: Vec::new(), remote_url_preview: Some("https://mcp.example.com".to_string()), header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), source_enabled: true, behavior_version: "behavior-v1".to_string(), static_status: ExternalMcpStaticStatus::Ready, diff --git a/src/crates/interfaces/acp/src/runtime/mcp.rs b/src/crates/interfaces/acp/src/runtime/mcp.rs index f864a719c9..eaa370c171 100644 --- a/src/crates/interfaces/acp/src/runtime/mcp.rs +++ b/src/crates/interfaces/acp/src/runtime/mcp.rs @@ -163,6 +163,7 @@ fn stdio_server_config(acp_session_id: &str, server: McpServerStdio) -> Result Vec { .get("xaa") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + timeouts: Default::default(), }; servers.push(server_config); diff --git a/src/crates/services/services-integrations/src/mcp/server/connection.rs b/src/crates/services/services-integrations/src/mcp/server/connection.rs index fe0deb7f72..75dc50c58b 100644 --- a/src/crates/services/services-integrations/src/mcp/server/connection.rs +++ b/src/crates/services/services-integrations/src/mcp/server/connection.rs @@ -2,6 +2,7 @@ //! //! Handles communication connections to MCP servers and request/response management. +use super::MCPServerTimeouts; use crate::mcp::adapter::MCPToolCatalogClient; use crate::mcp::protocol::{ create_initialize_request, create_ping_request, create_prompts_get_request, @@ -15,6 +16,7 @@ use crate::mcp::{MCPRuntimeError, MCPRuntimeResult}; use log::{debug, warn}; use serde_json::Value; use std::collections::HashMap; +use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -50,14 +52,28 @@ pub struct MCPConnection { transport: TransportType, pending_requests: Arc>>, initialize_timeout: Option, + catalog_timeout: Option, + execution_timeout: Option, event_tx: broadcast::Sender, } const LOCAL_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(30); +fn timeout_duration(milliseconds: Option) -> Option { + milliseconds.map(Duration::from_millis) +} + impl MCPConnection { /// Creates a new local connection instance (stdin/stdout). pub fn new_local(stdin: ChildStdin, message_rx: mpsc::UnboundedReceiver) -> Self { + Self::new_local_with_timeouts(stdin, message_rx, MCPServerTimeouts::default()) + } + + pub(super) fn new_local_with_timeouts( + stdin: ChildStdin, + message_rx: mpsc::UnboundedReceiver, + timeouts: MCPServerTimeouts, + ) -> Self { let transport = Arc::new(MCPTransport::new(stdin)); let pending_requests = Arc::new(RwLock::new(HashMap::new())); let (event_tx, _) = broadcast::channel(64); @@ -71,7 +87,10 @@ impl MCPConnection { Self { transport: TransportType::Local(transport), pending_requests, - initialize_timeout: Some(LOCAL_INITIALIZE_TIMEOUT), + initialize_timeout: timeout_duration(timeouts.startup_ms) + .or(Some(LOCAL_INITIALIZE_TIMEOUT)), + catalog_timeout: timeout_duration(timeouts.catalog_ms), + execution_timeout: timeout_duration(timeouts.execution_ms), event_tx, } } @@ -101,7 +120,25 @@ impl MCPConnection { headers: HashMap, oauth_enabled: bool, ) -> MCPRuntimeResult { - let initialize_timeout = None; + Self::new_remote_with_data_dir_and_timeouts( + data_dir, + server_id, + url, + headers, + oauth_enabled, + MCPServerTimeouts::default(), + ) + .await + } + + pub(super) async fn new_remote_with_data_dir_and_timeouts( + data_dir: impl Into, + server_id: &str, + url: String, + headers: HashMap, + oauth_enabled: bool, + timeouts: MCPServerTimeouts, + ) -> MCPRuntimeResult { let transport = Arc::new( RemoteMCPTransport::new(data_dir, server_id, url, headers, None, oauth_enabled).await?, ); @@ -111,7 +148,9 @@ impl MCPConnection { Ok(Self { transport: TransportType::Remote(transport), pending_requests, - initialize_timeout, + initialize_timeout: timeout_duration(timeouts.startup_ms), + catalog_timeout: timeout_duration(timeouts.catalog_ms), + execution_timeout: timeout_duration(timeouts.execution_ms), event_tx, }) } @@ -258,6 +297,19 @@ impl MCPConnection { } } + async fn await_phase( + phase: &'static str, + timeout: Option, + future: impl Future>, + ) -> MCPRuntimeResult { + match timeout { + Some(timeout) => tokio::time::timeout(timeout, future) + .await + .map_err(|_| MCPRuntimeError::timeout(format!("MCP {phase} request timed out")))?, + None => future.await, + } + } + /// Initializes the connection. pub async fn initialize( &self, @@ -285,7 +337,12 @@ impl MCPConnection { Ok(result) } TransportType::Remote(transport) => { - transport.initialize(client_name, client_version).await + Self::await_phase( + "startup", + self.initialize_timeout, + transport.initialize(client_name, client_version), + ) + .await } } } @@ -299,11 +356,22 @@ impl MCPConnection { TransportType::Local(_) => { let request = create_resources_list_request(0, cursor); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.catalog_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.list_resources(cursor).await, + TransportType::Remote(transport) => { + Self::await_phase( + "catalog", + self.catalog_timeout, + transport.list_resources(cursor), + ) + .await + } } } @@ -313,11 +381,22 @@ impl MCPConnection { TransportType::Local(_) => { let request = create_resources_read_request(0, uri); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.execution_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.read_resource(uri).await, + TransportType::Remote(transport) => { + Self::await_phase( + "execution", + self.execution_timeout, + transport.read_resource(uri), + ) + .await + } } } @@ -330,11 +409,22 @@ impl MCPConnection { TransportType::Local(_) => { let request = create_prompts_list_request(0, cursor); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.catalog_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.list_prompts(cursor).await, + TransportType::Remote(transport) => { + Self::await_phase( + "catalog", + self.catalog_timeout, + transport.list_prompts(cursor), + ) + .await + } } } @@ -348,11 +438,22 @@ impl MCPConnection { TransportType::Local(_) => { let request = create_prompts_get_request(0, name, arguments); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.execution_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.get_prompt(name, arguments).await, + TransportType::Remote(transport) => { + Self::await_phase( + "execution", + self.execution_timeout, + transport.get_prompt(name, arguments), + ) + .await + } } } @@ -362,11 +463,22 @@ impl MCPConnection { TransportType::Local(_) => { let request = create_tools_list_request(0, cursor); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.catalog_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.list_tools(cursor).await, + TransportType::Remote(transport) => { + Self::await_phase( + "catalog", + self.catalog_timeout, + transport.list_tools(cursor), + ) + .await + } } } @@ -382,12 +494,23 @@ impl MCPConnection { let request = create_tools_call_request(0, name, arguments); let response = self - .send_request_and_wait(request.method.clone(), request.params) + .send_request_and_wait_with_timeout( + request.method.clone(), + request.params, + self.execution_timeout, + ) .await?; parse_response_result(&response) } - TransportType::Remote(transport) => transport.call_tool(name, arguments).await, + TransportType::Remote(transport) => { + Self::await_phase( + "execution", + self.execution_timeout, + transport.call_tool(name, arguments), + ) + .await + } } } @@ -428,13 +551,17 @@ impl MCPConnection { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use crate::mcp::protocol::MCPToolResultContent; + #[cfg(unix)] use serde_json::json; + #[cfg(unix)] use tokio::io::{AsyncBufReadExt, BufReader}; + #[cfg(unix)] #[tokio::test] async fn local_tool_calls_do_not_inherit_initialize_timeout() { let mut child = tokio::process::Command::new("sh") @@ -496,6 +623,7 @@ mod tests { let _ = child.kill().await; } + #[cfg(unix)] #[tokio::test] async fn local_initialize_uses_initialize_timeout() { let mut child = tokio::process::Command::new("sh") @@ -521,6 +649,60 @@ mod tests { drop(stdout); let _ = child.kill().await; } + + #[test] + fn mcp_connection_timeout_child() { + if std::env::var_os("BITFUN_MCP_CONNECTION_TIMEOUT_CHILD").is_some() { + std::thread::sleep(Duration::from_secs(30)); + } + } + + #[tokio::test] + async fn local_catalog_and_execution_timeouts_remove_pending_requests() { + let mut child = tokio::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("mcp::server::connection::tests::mcp_connection_timeout_child") + .arg("--nocapture") + .env("BITFUN_MCP_CONNECTION_TIMEOUT_CHILD", "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn silent test child"); + let stdin = child.stdin.take().expect("capture child stdin"); + let (_tx, rx) = mpsc::unbounded_channel(); + let connection = MCPConnection::new_local_with_timeouts( + stdin, + rx, + super::MCPServerTimeouts { + startup_ms: None, + catalog_ms: Some(10), + execution_ms: Some(10), + }, + ); + + let catalog_error = connection + .list_tools(None) + .await + .expect_err("catalog request should time out"); + assert_eq!( + catalog_error.kind(), + crate::mcp::MCPRuntimeErrorKind::Timeout + ); + assert!(connection.pending_requests.read().await.is_empty()); + + let execution_error = connection + .call_tool("slow_tool", None) + .await + .expect_err("execution request should time out"); + assert_eq!( + execution_error.kind(), + crate::mcp::MCPRuntimeErrorKind::Timeout + ); + assert!(connection.pending_requests.read().await.is_empty()); + + let _ = child.kill().await; + } } /// MCP connection pool. diff --git a/src/crates/services/services-integrations/src/mcp/server/mod.rs b/src/crates/services/services-integrations/src/mcp/server/mod.rs index 4faa26c45f..d09c028f63 100644 --- a/src/crates/services/services-integrations/src/mcp/server/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/server/mod.rs @@ -102,6 +102,40 @@ pub struct MCPServerXaaConfig { pub scopes: Vec, } +// Keep serialized values exact for JavaScript product surfaces. +const MAX_MCP_TIMEOUT_MS: u64 = 9_007_199_254_740_991; + +/// Optional phase-specific MCP timeout overrides in milliseconds. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MCPServerTimeouts { + #[serde(skip_serializing_if = "Option::is_none")] + pub startup_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub catalog_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_ms: Option, +} + +impl MCPServerTimeouts { + pub fn is_empty(&self) -> bool { + self.startup_ms.is_none() && self.catalog_ms.is_none() && self.execution_ms.is_none() + } + + pub fn validate(&self) -> Result<(), MCPServerConfigValidationError> { + if [self.startup_ms, self.catalog_ms, self.execution_ms] + .into_iter() + .flatten() + .any(|timeout| timeout == 0 || timeout > MAX_MCP_TIMEOUT_MS) + { + return Err(MCPServerConfigValidationError::new( + "MCP timeout must be a positive exactly representable JSON integer", + )); + } + Ok(()) + } +} + /// MCP server configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -147,6 +181,8 @@ pub struct MCPServerConfig { pub oauth_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub xaa: Option, + #[serde(default, skip_serializing_if = "MCPServerTimeouts::is_empty")] + pub timeouts: MCPServerTimeouts, } fn default_true() -> bool { @@ -202,6 +238,7 @@ impl MCPServerConfig { "MCP server name cannot be empty", )); } + self.timeouts.validate()?; let transport = self.resolved_transport(); match self.server_type { diff --git a/src/crates/services/services-integrations/src/mcp/server/process.rs b/src/crates/services/services-integrations/src/mcp/server/process.rs index 289d70700e..cf353fdd18 100644 --- a/src/crates/services/services-integrations/src/mcp/server/process.rs +++ b/src/crates/services/services-integrations/src/mcp/server/process.rs @@ -3,7 +3,9 @@ //! Handles starting, stopping, monitoring, and restarting MCP server processes. use super::connection::MCPConnection; -use super::{MCPServerConfig, MCPServerStatus, MCPServerTransport, MCPServerType}; +use super::{ + MCPServerConfig, MCPServerStatus, MCPServerTimeouts, MCPServerTransport, MCPServerType, +}; use crate::mcp::protocol::{InitializeResult, MCPMessage, MCPServerInfo, MCPTransport}; use crate::mcp::server::{is_mcp_auth_error_message, merge_mcp_remote_headers}; use crate::mcp::{MCPRuntimeError, MCPRuntimeResult}; @@ -94,6 +96,26 @@ impl MCPServerProcess { env: &std::collections::HashMap, working_directory: Option<&std::path::Path>, inherit_parent_environment: bool, + ) -> MCPRuntimeResult<()> { + self.start_with_environment_policy_and_timeouts( + command, + args, + env, + working_directory, + inherit_parent_environment, + MCPServerTimeouts::default(), + ) + .await + } + + pub(super) async fn start_with_environment_policy_and_timeouts( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, + timeouts: MCPServerTimeouts, ) -> MCPRuntimeResult<()> { info!("Starting MCP server: name={} id={}", self.name, self.id); self.set_status(MCPServerStatus::Starting).await; @@ -164,7 +186,7 @@ impl MCPServerProcess { let (tx, rx) = mpsc::unbounded_channel(); - let connection = Arc::new(MCPConnection::new(stdin, rx)); + let connection = Arc::new(MCPConnection::new_local_with_timeouts(stdin, rx, timeouts)); self.message_rx = None; // The connection already owns rx MCPTransport::start_receive_loop(stdout, tx); @@ -228,12 +250,13 @@ impl MCPServerProcess { let merged_headers = merge_mcp_remote_headers(&config.headers, &config.env); let connection = Arc::new( - MCPConnection::new_remote_with_data_dir( + MCPConnection::new_remote_with_data_dir_and_timeouts( data_dir, &self.id, url.to_string(), merged_headers, config.remote_oauth_enabled(), + config.timeouts, ) .await .map_err(|error| { @@ -244,6 +267,7 @@ impl MCPServerProcess { self.start_time = Some(Instant::now()); if let Err(e) = self.handshake().await { + let is_timeout = e.kind() == crate::mcp::MCPRuntimeErrorKind::Timeout; let redacted_error = redact_sensitive_value(&e.to_string(), Some(url)); error!( "Remote MCP server handshake failed: name={} id={} error={}", @@ -263,7 +287,11 @@ impl MCPServerProcess { self.set_status_with_error(MCPServerStatus::Failed, Some(redacted_error.clone())) .await; } - return Err(MCPRuntimeError::mcp(redacted_error)); + return if is_timeout { + Err(e) + } else { + Err(MCPRuntimeError::mcp(redacted_error)) + }; } self.set_status_with_error(MCPServerStatus::Connected, None) @@ -556,6 +584,10 @@ impl Drop for MCPServerProcess { #[cfg(test)] mod tests { use super::{redact_sensitive_value, safe_process_environment_keys}; + use crate::mcp::server::{MCPServerProcess, MCPServerTimeouts, MCPServerType}; + use crate::mcp::MCPRuntimeErrorKind; + use std::collections::HashMap; + use std::time::Duration; #[test] fn isolated_environment_excludes_common_secret_variables() { @@ -574,4 +606,50 @@ mod tests { assert!(!redacted.contains("secret")); assert!(redacted.contains("")); } + + #[test] + fn mcp_process_timeout_child() { + if std::env::var_os("BITFUN_MCP_PROCESS_TIMEOUT_CHILD").is_some() { + std::thread::sleep(Duration::from_secs(30)); + } + } + + #[tokio::test] + async fn local_startup_timeout_releases_child_and_connection() { + let executable = std::env::current_exe().unwrap(); + let args = vec![ + "--exact".to_string(), + "mcp::server::process::tests::mcp_process_timeout_child".to_string(), + "--nocapture".to_string(), + ]; + let environment = HashMap::from([( + "BITFUN_MCP_PROCESS_TIMEOUT_CHILD".to_string(), + "1".to_string(), + )]); + let mut process = MCPServerProcess::new( + "startup-timeout".to_string(), + "Startup timeout".to_string(), + MCPServerType::Local, + ); + + let error = process + .start_with_environment_policy_and_timeouts( + &executable.to_string_lossy(), + &args, + &environment, + None, + true, + MCPServerTimeouts { + startup_ms: Some(20), + ..Default::default() + }, + ) + .await + .expect_err("startup should time out"); + + assert_eq!(error.kind(), MCPRuntimeErrorKind::Timeout); + assert!(process.child.is_none()); + assert!(process.connection.is_none()); + assert_eq!(process.status().await, super::MCPServerStatus::Failed); + } } diff --git a/src/crates/services/services-integrations/src/mcp/server/registry.rs b/src/crates/services/services-integrations/src/mcp/server/registry.rs index 6aa8476ec0..922a39e5d7 100644 --- a/src/crates/services/services-integrations/src/mcp/server/registry.rs +++ b/src/crates/services/services-integrations/src/mcp/server/registry.rs @@ -218,6 +218,7 @@ mod tests { oauth: None, oauth_enabled: None, xaa: None, + timeouts: Default::default(), } } diff --git a/src/crates/services/services-integrations/src/mcp/server/runtime_state.rs b/src/crates/services/services-integrations/src/mcp/server/runtime_state.rs index ef5b0ada1a..14f943278f 100644 --- a/src/crates/services/services-integrations/src/mcp/server/runtime_state.rs +++ b/src/crates/services/services-integrations/src/mcp/server/runtime_state.rs @@ -147,12 +147,13 @@ impl MCPServerRuntimeState { resolved.command, resolved.source_label, config.id ); process - .start_with_environment_policy( + .start_with_environment_policy_and_timeouts( &resolved.command, &config.args, &config.env, config.working_directory.as_deref().map(Path::new), config.inherits_parent_environment(), + config.timeouts, ) .await?; } diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index 21a392983b..9f71e028e1 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -30,7 +30,8 @@ use bitfun_services_integrations::mcp::server::{ mcp_reconnect_runtime_decision, mcp_server_is_running, mcp_should_start_after_config_update, merge_mcp_remote_headers, MCPCatalogCache, MCPConnectionPool, MCPListChangedKind, MCPProcessStartContext, MCPReconnectRuntimeDecision, MCPRuntimeErrorKind, MCPRuntimeResult, - MCPServerConfig, MCPServerRuntimeState, MCPServerStatus, MCPServerTransport, MCPServerType, + MCPServerConfig, MCPServerRuntimeState, MCPServerStatus, MCPServerTimeouts, MCPServerTransport, + MCPServerType, }; use bitfun_services_integrations::mcp::{ build_mcp_tool_descriptor, build_mcp_tool_name, normalize_name_for_mcp, @@ -70,6 +71,7 @@ fn make_mcp_config( oauth: None, oauth_enabled: None, xaa: None, + timeouts: MCPServerTimeouts::default(), } } @@ -215,6 +217,42 @@ fn mcp_protocol_capability_contract_matches_existing_default() { ); } +#[test] +fn mcp_server_timeout_config_is_optional_positive_milliseconds() { + let timeouts = MCPServerTimeouts { + startup_ms: Some(250), + catalog_ms: Some(1_000), + execution_ms: Some(30_000), + }; + timeouts.validate().expect("positive timeouts are valid"); + assert_eq!( + serde_json::to_value(&timeouts).unwrap(), + serde_json::json!({ + "startupMs": 250, + "catalogMs": 1_000, + "executionMs": 30_000, + }) + ); + assert!(MCPServerTimeouts { + execution_ms: Some(0), + ..Default::default() + } + .validate() + .is_err()); + assert!(MCPServerTimeouts { + execution_ms: Some(9_007_199_254_740_991), + ..Default::default() + } + .validate() + .is_ok()); + assert!(MCPServerTimeouts { + execution_ms: Some(9_007_199_254_740_992), + ..Default::default() + } + .validate() + .is_err()); +} + #[test] fn mcp_remote_client_info_declares_supported_client_capabilities() { let info = create_mcp_client_info("BitFun", "1.0.0"); @@ -1755,6 +1793,7 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { oauth: None, oauth_enabled: None, xaa: None, + timeouts: MCPServerTimeouts::default(), }; assert_eq!(local.resolved_transport(), MCPServerTransport::Stdio); local.validate().expect("local stdio config is valid"); @@ -1956,6 +1995,7 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { oauth: None, oauth_enabled: None, xaa: None, + timeouts: MCPServerTimeouts::default(), }; assert_eq!( diff --git a/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs b/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs index 086be62fb0..9e80c44da4 100644 --- a/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs @@ -1,9 +1,13 @@ use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -use bitfun_services_integrations::mcp::server::MCPConnection; +use bitfun_services_integrations::mcp::config::ConfigLocation; +use bitfun_services_integrations::mcp::server::{ + MCPConnection, MCPProcessStartContext, MCPRuntimeErrorKind, MCPServerConfig, + MCPServerRuntimeState, MCPServerStatus, MCPServerTimeouts, MCPServerTransport, MCPServerType, +}; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -18,6 +22,7 @@ struct TestState { saw_roots_capability: Arc, saw_sampling_capability: Arc, saw_elicitation_capability: Arc, + initialize_delay_ms: Arc, } struct TestRequest { @@ -153,6 +158,10 @@ async fn handle_post( match method { "initialize" => { + let delay_ms = state.initialize_delay_ms.load(Ordering::SeqCst); + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } let capabilities = body .get("params") .and_then(|params| params.get("capabilities")) @@ -319,6 +328,70 @@ async fn raw_status_line(addr: std::net::SocketAddr, request: &str) -> String { .to_string() } +#[tokio::test] +async fn remote_startup_timeout_preserves_timeout_kind_and_cleans_connection() { + let state = TestState::default(); + state.initialize_delay_ms.store(100, Ordering::SeqCst); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_state = state.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let connection_state = server_state.clone(); + tokio::spawn(async move { + handle_connection(stream, connection_state) + .await + .expect("test MCP connection should complete"); + }); + } + }); + + let runtime = MCPServerRuntimeState::new(); + let config = MCPServerConfig { + id: "startup-timeout".to_string(), + name: "Startup timeout".to_string(), + server_type: MCPServerType::Remote, + transport: Some(MCPServerTransport::StreamableHttp), + command: None, + args: Vec::new(), + env: Default::default(), + working_directory: None, + inherit_parent_environment: None, + headers: Default::default(), + url: Some(format!("http://{addr}/mcp")), + auto_start: true, + enabled: true, + location: ConfigLocation::BuiltIn, + capabilities: Vec::new(), + settings: Default::default(), + oauth: None, + oauth_enabled: Some(false), + xaa: None, + timeouts: MCPServerTimeouts { + startup_ms: Some(20), + ..Default::default() + }, + }; + let data_dir = tempfile::tempdir().unwrap(); + + let error = runtime + .start_process( + &config, + MCPProcessStartContext::Remote { + data_dir: data_dir.path().to_path_buf(), + }, + ) + .await + .expect_err("remote startup should time out"); + + assert_eq!(error.kind(), MCPRuntimeErrorKind::Timeout); + assert_eq!( + runtime.process_status(&config.id).await.unwrap(), + MCPServerStatus::Failed + ); + assert!(runtime.process_connection(&config.id).await.is_none()); +} + #[tokio::test] async fn remote_mcp_streamable_http_accepts_202_and_delivers_response_via_sse() { let state = TestState::default(); 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 d963b02217..8275866a7b 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 @@ -687,6 +687,12 @@ describe('ExternalSourcesAPI', () => { name: 'docs', transport: 'streamable_http', argumentCount: 0, + timeouts: { + startupMs: 9007199254740992, + catalogMs: 'invalid', + executionMs: 3000, + futurePhaseMs: 4000, + }, sourceEnabled: true, behaviorVersion: '1', staticStatus: { state: 'ready' }, @@ -702,7 +708,9 @@ describe('ExternalSourcesAPI', () => { environmentKeys: [], environmentReferenceNames: [], headerNames: [], + timeouts: { executionMs: 3000 }, }); + expect(result.mcpServers?.[0].definition.timeouts).toEqual({ executionMs: 3000 }); expect(result.mcpApprovalRequests).toEqual([]); expect(result.toolConflicts).toEqual([]); expect(result.pendingSubagentApprovals).toEqual([]); 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 1237d3ef7a..0554ff026e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -397,6 +397,12 @@ export type ExternalMcpActivation = | { state: 'runtime_unavailable'; reason: string } | { state: 'removed' }; +export interface ExternalMcpTimeouts { + startupMs?: number; + catalogMs?: number; + executionMs?: number; +} + export interface ExternalMcpDefinition { id: { source: { providerId: string; sourceId: string }; @@ -412,6 +418,7 @@ export interface ExternalMcpDefinition { environmentReferenceNames?: string[]; remoteUrlPreview?: string; headerNames: string[]; + timeouts?: ExternalMcpTimeouts; sourceEnabled: boolean; behaviorVersion: string; staticStatus: @@ -912,12 +919,21 @@ function normalizePolicySnapshot(value: unknown): ExternalIntegrationPolicySnaps } function normalizeMcpDefinition(definition: ExternalMcpDefinition): ExternalMcpDefinition { + const rawTimeouts = definition.timeouts; + const timeouts = rawTimeouts && typeof rawTimeouts === 'object' + ? Object.fromEntries( + (['startupMs', 'catalogMs', 'executionMs'] as const) + .map((key) => [key, rawTimeouts[key]] as const) + .filter(([, value]) => Number.isSafeInteger(value) && (value ?? 0) > 0), + ) as ExternalMcpTimeouts + : undefined; return { ...definition, provenance: normalizeOptionalArray(definition.provenance), environmentKeys: normalizeOptionalArray(definition.environmentKeys), environmentReferenceNames: normalizeOptionalArray(definition.environmentReferenceNames), headerNames: normalizeOptionalArray(definition.headerNames), + timeouts: timeouts && Object.keys(timeouts).length > 0 ? timeouts : undefined, }; } diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 81e85c313c..252227cf61 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -760,6 +760,7 @@ describe('ExternalSourcesConfig', () => { environmentKeys: ['GITHUB_TOKEN'], environmentReferenceNames: ['OPENCODE_TOKEN'], headerNames: [], + timeouts: { startupMs: 1000, catalogMs: 2000, executionMs: 3000 }, sourceEnabled: true, behaviorVersion: 'behavior-v1', staticStatus: { state: 'ready' }, @@ -784,6 +785,7 @@ describe('ExternalSourcesConfig', () => { environmentKeys: ['GITHUB_TOKEN'], environmentReferenceNames: ['OPENCODE_TOKEN'], headerNames: [], + timeouts: { startupMs: 1000, catalogMs: 2000, executionMs: 3000 }, sourceEnabled: true, behaviorVersion: 'behavior-v1', staticStatus: { state: 'ready' }, @@ -853,6 +855,10 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('mcp.workingDirectory:{"location":""}'); expect(container.textContent).toContain('GITHUB_TOKEN'); expect(container.textContent).toContain('OPENCODE_TOKEN'); + expect(container.textContent).toContain('mcp.timeoutSummary'); + expect(container.textContent).toContain('mcp.timeoutStartup'); + expect(container.textContent).toContain('mcp.timeoutCatalog'); + expect(container.textContent).toContain('mcp.timeoutExecution'); const approvalCard = Array.from(container.querySelectorAll( '.bitfun-external-sources-config__tool-card', diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 2f7d011a83..179b7ec812 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -21,6 +21,7 @@ import { Tooltip, } from '@/component-library'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { i18nService } from '@/infrastructure/i18n'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { WorkspaceKind } from '@/shared/types'; import { createLogger } from '@/shared/utils/logger'; @@ -31,6 +32,7 @@ import { type ExternalIntegrationAccess, type ExternalIntegrationMode, type ExternalIntegrationPolicyMutation, + type ExternalMcpDefinition, type ExternalSourceCatalogSnapshot, type ExternalSourceRecoveryAction, type ExternalSubagentModelBindingGroup, @@ -84,6 +86,38 @@ const AGENT_DIAGNOSTIC_SETTING_KEYS: Record = { opencode_primary_facet_not_imported: 'primaryFacet', }; +function mcpTimeoutSummary(definition: ExternalMcpDefinition, t: TFunction): string | null { + const timeouts = definition.timeouts; + if (!timeouts) return null; + const values = [ + ['startupMs', 'mcp.timeoutStartup'], + ['catalogMs', 'mcp.timeoutCatalog'], + ['executionMs', 'mcp.timeoutExecution'], + ] as const; + const phases = values.flatMap(([field, label]) => { + const milliseconds = timeouts[field]; + return milliseconds == null + ? [] + : [t(label, { + duration: t('mcp.timeoutMilliseconds', { + value: i18nService.formatNumber(milliseconds), + }), + })]; + }); + return phases.length > 0 ? t('mcp.timeoutSummary', { values: phases.join(' · ') }) : null; +} + +function McpTimeoutSummary({ + definition, + t, +}: { + definition: ExternalMcpDefinition; + t: TFunction; +}) { + const summary = mcpTimeoutSummary(definition, t); + return summary ? {summary} : null; +} + type SnapshotLoadResult = | { status: 'accepted'; snapshot: ExternalSourceCatalogSnapshot } | { status: 'ignored' } @@ -1967,6 +2001,7 @@ const ExternalSourcesConfig: React.FC = () => { location: request.definition.workingDirectory, })} ) : null} + {(request.definition.environmentKeys?.length ?? 0) > 0 ? ( {t('mcp.environmentNames', { names: request.definition.environmentKeys.join(', '), @@ -2106,6 +2141,7 @@ const ExternalSourcesConfig: React.FC = () => { location: server.definition.workingDirectory, })} ) : null} + {t('mcp.argumentCount', { count: server.definition.argumentCount, })} @@ -2264,6 +2300,7 @@ const ExternalSourcesConfig: React.FC = () => { location: externalServer.definition.workingDirectory, })} ) : null} + {(externalServer.definition.environmentKeys?.length ?? 0) > 0 ? ( {t('mcp.environmentNames', { names: externalServer.definition.environmentKeys.join(', '), diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 197bb0bbfa..c261082c14 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -409,6 +409,11 @@ "argumentCount": "Command arguments: {{count}}", "headerCount": "{{count}} HTTP headers configured", "headerNames": "HTTP headers: {{names}}", + "timeoutSummary": "Timeout overrides: {{values}}", + "timeoutStartup": "startup {{duration}}", + "timeoutCatalog": "catalog {{duration}}", + "timeoutExecution": "execution {{duration}}", + "timeoutMilliseconds": "{{value}} ms", "changePolicy": "BitFun asks again before loading a changed server configuration.", "runtimeUnavailableGuidance": "Disable this server, fix the source configuration or authentication, then enable it again. Restarting BitFun is usually not required.", "unsupportedGuidance": "Change this setting in the source application, then refresh.", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 867a9aa9c5..b24c167d0f 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -409,6 +409,11 @@ "argumentCount": "命令参数数量:{{count}}", "headerCount": "已配置 {{count}} 个 HTTP 请求头", "headerNames": "HTTP 请求头:{{names}}", + "timeoutSummary": "超时覆盖:{{values}}", + "timeoutStartup": "启动 {{duration}}", + "timeoutCatalog": "目录读取 {{duration}}", + "timeoutExecution": "执行 {{duration}}", + "timeoutMilliseconds": "{{value}} 毫秒", "changePolicy": "服务器配置发生变化后,BitFun 会在再次加载前请你确认。", "runtimeUnavailableGuidance": "请先停用此服务器,修复来源配置或认证后再启用,通常无需重启 BitFun。", "unsupportedGuidance": "请在来源应用中修改此配置,然后刷新。", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 5427a14296..ad03dfb203 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -409,6 +409,11 @@ "argumentCount": "命令參數數量:{{count}}", "headerCount": "已設定 {{count}} 個 HTTP 標頭", "headerNames": "HTTP 標頭:{{names}}", + "timeoutSummary": "逾時覆寫:{{values}}", + "timeoutStartup": "啟動 {{duration}}", + "timeoutCatalog": "目錄讀取 {{duration}}", + "timeoutExecution": "執行 {{duration}}", + "timeoutMilliseconds": "{{value}} 毫秒", "changePolicy": "伺服器設定變更後,BitFun 會在再次載入前請你確認。", "runtimeUnavailableGuidance": "請先停用此伺服器,修正來源設定或驗證後再啟用,通常不需要重新啟動 BitFun。", "unsupportedGuidance": "請在來源應用程式中修改此設定,然後重新整理。",