diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 1ed5f3cc53..adfdd80cd3 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4207,6 +4207,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/static-hook-support/tests/parser.rs', 'src/crates/adapters/opencode-adapter/src/hook_source.rs', 'src/crates/adapters/opencode-adapter/src/command_source.rs', + 'src/crates/adapters/opencode-adapter/src/agent_source.rs', 'src/crates/adapters/opencode-adapter/src/mcp_source.rs', 'src/crates/adapters/claude-code-adapter/src/hook_source.rs', 'src/crates/adapters/claude-code-adapter/src/command_source.rs', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 1fe145be9a..56e7ba8eed 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -319,6 +319,23 @@ function staticSourceSupportEntry(symbol) { }; } +function commonExternalSubagentToolMappingEntry(symbol) { + return { + symbol, + owner: 'static-hook-support shared declarative source adapter utility owner', + consumer: 'reviewed OpenCode and Claude Code declarative subagent adapters', + verification: + 'shared mapping unit tests, ecosystem subagent adapter fixtures, and core-boundary public API budget checks', + p0: 'runtime-free common external Agent tool capability normalization', + contractSlice: contractSlices.externalSourceControlContract, + wireImpact: false, + rationale: + 'sibling declarative adapters need one static mapping while provider-specific aliases remain adapter-owned', + exit: + 'remove only if every reviewed consumer moves to an equivalent adapter-layer mapping owner', + }; +} + function declarativeSourceAdapterEntry( symbol, owner, @@ -477,7 +494,9 @@ export const staticHookSupportPublicApiEntries = [ 'BoundedDirectoryWalkLimit', 'BoundedDirectoryWalkError', 'collect_bounded_regular_files', -].map(staticSourceSupportEntry)); +].map(staticSourceSupportEntry)).concat([ + 'common_external_subagent_tool_capability', +].map(commonExternalSubagentToolMappingEntry)); function externalHookContractEntry(symbol, owner, consumer, wireImpact = false) { return { @@ -868,6 +887,7 @@ export const externalSubagentContractPublicApiEntries = [ 'ExternalSubagentModelBindingMethod', 'ExternalSubagentModelBindingOption', 'ExternalSubagentModelBindingGroup', + 'ExternalSubagentToolCapability', 'ExternalSubagentToolSelector', 'ExternalSubagentToolRequest', 'ExternalSubagentCompatibilityState', @@ -1102,6 +1122,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalToolConflictCandidateKind', 'ExternalToolRuntimeKind', 'set_external_tool_target_decision', + 'set_external_tool_targets_enabled', 'set_external_tool_conflict_choice', ].map((symbol) => externalToolEntry( @@ -1123,6 +1144,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSubagentModelRequest', 'ExternalSubagentSummary', 'set_external_subagent_activation', + 'set_external_subagents_enabled', 'set_external_subagent_model_binding', 'choose_external_subagent_conflict', ].map((symbol) => @@ -1140,6 +1162,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalMcpTransportKind', 'native_mcp_candidate_id', 'set_external_mcp_server_decision', + 'set_external_mcp_servers_enabled', 'choose_external_mcp_conflict', ].map((symbol) => externalMcpEntry( diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 07c9ff545a..0a335ad12b 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -6,12 +6,14 @@ use bitfun_core::external_sources::{ apply_external_source_control_action, choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - update_external_integration_policy, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + set_external_tool_targets_enabled, update_external_integration_policy, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSubagentModelBindingTarget, }; use serde_json::Value; @@ -60,6 +62,28 @@ fn required_u64(request: &Value, key: &str) -> ExternalSourceOperationResult ExternalSourceOperationResult> { + let decisions = request + .get("decisions") + .and_then(Value::as_array) + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Missing or invalid 'decisions'") + })?; + decisions + .iter() + .map(|decision| { + Ok(( + required_string(decision, first_key)?, + required_string(decision, second_key)?, + )) + }) + .collect() +} + fn model_binding_target_field( request: &Value, key: &str, @@ -221,6 +245,16 @@ async fn dispatch_inner( ) .await } + "set_external_tool_targets_enabled_command" => { + set_external_tool_targets_enabled( + workspace, + decision_pairs(request, "approvalKey", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedCatalogGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_tool_conflict_choice_command" => { set_external_tool_conflict_choice( workspace, @@ -241,6 +275,16 @@ async fn dispatch_inner( ) .await } + "set_external_subagents_enabled_command" => { + set_external_subagents_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_subagent_model_binding_command" => { set_external_subagent_model_binding( workspace, @@ -273,6 +317,16 @@ async fn dispatch_inner( ) .await } + "set_external_mcp_servers_enabled_command" => { + set_external_mcp_servers_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedMcpGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "choose_external_mcp_conflict_command" => { choose_external_mcp_conflict( workspace, @@ -395,4 +449,22 @@ mod tests { ExternalSourceOperationErrorCode::InvalidRequest ); } + + #[test] + fn peer_bulk_decisions_preserve_the_reviewed_identity_pairs() { + let request = serde_json::json!({ + "decisions": [ + { "candidateId": "agent-a", "decisionKey": "decision-a" }, + { "candidateId": "agent-b", "decisionKey": "decision-b" } + ] + }); + + assert_eq!( + decision_pairs(&request, "candidateId", "decisionKey").unwrap(), + vec![ + ("agent-a".to_string(), "decision-a".to_string()), + ("agent-b".to_string(), "decision-b".to_string()), + ] + ); + } } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 5f45ea6875..8ef9bdd754 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -46,11 +46,14 @@ pub(crate) async fn dispatch( | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" + | "set_external_tool_targets_enabled_command" | "set_external_tool_conflict_choice_command" | "set_external_subagent_activation_command" + | "set_external_subagents_enabled_command" | "set_external_subagent_model_binding_command" | "choose_external_subagent_conflict_command" | "set_external_mcp_server_decision_command" + | "set_external_mcp_servers_enabled_command" | "choose_external_mcp_conflict_command" | "update_external_integration_policy_command" => { external_sources::dispatch(command, args, state).await diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 0890159bac..9288eb1f25 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -7,17 +7,18 @@ use bitfun_core::external_sources::{ external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, unacknowledged_external_ecosystems, - update_external_integration_policy, workspace_reference_snapshot, - ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, - ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, - ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot, - NativePromptCommandDescriptor, PromptCommandInvocationOutcome, - PromptCommandShellReviewDecision, + set_external_tool_targets_enabled, set_native_prompt_command_conflict_choice, + unacknowledged_external_ecosystems, update_external_integration_policy, + workspace_reference_snapshot, ExternalIntegrationPolicyMutation, + ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget, + NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, + PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::{ @@ -154,6 +155,23 @@ pub struct SetExternalToolTargetDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolDecisionRef { + pub approval_key: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalToolTargetsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_catalog_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolConflictChoiceRequest { @@ -174,6 +192,23 @@ pub struct SetExternalSubagentActivationRequest { pub decision_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalCandidateDecisionRef { + pub candidate_id: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSubagentsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentModelBindingRequest { @@ -207,6 +242,16 @@ pub struct SetExternalMcpServerDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalMcpServersEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalMcpConflictRequest { @@ -553,6 +598,27 @@ pub async fn set_external_tool_target_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_tool_targets_enabled_command( + request: SetExternalToolTargetsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_tool_targets_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.approval_key, decision.decision_key)) + .collect(), + request.enabled, + request.expected_catalog_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_tool_conflict_choice_command( request: SetExternalToolConflictChoiceRequest, @@ -587,6 +653,27 @@ pub async fn set_external_subagent_activation_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_subagents_enabled_command( + request: SetExternalSubagentsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_subagents_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_subagent_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_subagent_model_binding_command( request: SetExternalSubagentModelBindingRequest, @@ -640,6 +727,27 @@ pub async fn set_external_mcp_server_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_mcp_servers_enabled_command( + request: SetExternalMcpServersEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_mcp_servers_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn choose_external_mcp_conflict_command( request: ChooseExternalMcpConflictRequest, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 75817f61e4..0715b1d8b3 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1745,6 +1745,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_mcp_server_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_mcp_servers_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_source_conflict_choice_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1765,10 +1769,18 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_tool_target_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_tool_targets_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_activation_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_subagents_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_model_binding_command", RemoteWorkspacePolicy::RemoteUnsupported, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index fc30fc1d71..71737837dc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1277,11 +1277,14 @@ pub async fn run() { set_native_prompt_command_conflict_choice_command, expand_external_prompt_command_command, set_external_tool_target_decision_command, + set_external_tool_targets_enabled_command, set_external_tool_conflict_choice_command, set_external_subagent_activation_command, + set_external_subagents_enabled_command, set_external_subagent_model_binding_command, choose_external_subagent_conflict_command, set_external_mcp_server_decision_command, + set_external_mcp_servers_enabled_command, choose_external_mcp_conflict_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 093801e18c..e7ed20a380 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -26,10 +26,13 @@ pub(crate) fn supports(method: &str) -> bool { | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" + | "set_external_tool_targets_enabled_command" | "set_external_tool_conflict_choice_command" | "set_external_subagent_activation_command" + | "set_external_subagents_enabled_command" | "choose_external_subagent_conflict_command" | "set_external_mcp_server_decision_command" + | "set_external_mcp_servers_enabled_command" | "choose_external_mcp_conflict_command" | "update_external_integration_policy_command" ) @@ -162,6 +165,7 @@ mod tests { assert!(supports("get_external_source_snapshot")); assert!(supports("get_external_source_control_snapshot")); assert!(supports("apply_external_source_control_action_command")); + assert!(supports("set_external_mcp_servers_enabled_command")); assert!(supports("update_external_integration_policy_command")); assert!(!supports("open_workspace")); } diff --git a/src/crates/adapters/claude-code-adapter/src/agent_source.rs b/src/crates/adapters/claude-code-adapter/src/agent_source.rs index db8778c581..e1ec75dac1 100644 --- a/src/crates/adapters/claude-code-adapter/src/agent_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/agent_source.rs @@ -14,8 +14,8 @@ use bitfun_product_domains::external_subagents::{ }; use bitfun_services_core::markdown::FrontMatterMarkdown; use bitfun_static_hook_support::{ - collect_bounded_regular_files, read_bounded_text, BoundedDirectoryWalkError, - BoundedDirectoryWalkLimits, BoundedTextRead, + collect_bounded_regular_files, common_external_subagent_tool_capability, read_bounded_text, + BoundedDirectoryWalkError, BoundedDirectoryWalkLimits, BoundedTextRead, }; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -27,7 +27,6 @@ const ECOSYSTEM_ID: &str = "claude-code"; const MAX_AGENT_FILES: usize = 2048; const MAX_AGENT_FILE_BYTES: usize = 256 * 1024; const MAX_TOTAL_PROMPT_BYTES: usize = 8 * 1024 * 1024; - const KNOWN_FIELDS: &[&str] = &[ "name", "description", @@ -630,7 +629,7 @@ fn tool_request( selectors: selectors .into_iter() .map(|(source_name, allowed)| ExternalSubagentToolSelector { - canonical_host_name: canonical_tool_name(&source_name).map(str::to_string), + canonical_capability: common_external_subagent_tool_capability(&source_name), source_name, allowed, }) @@ -657,16 +656,6 @@ fn string_list(value: &Value) -> Option> { } } -fn canonical_tool_name(name: &str) -> Option<&'static str> { - match name.to_ascii_lowercase().as_str() { - "ls" | "list" => Some("LS"), - "read" => Some("Read"), - "glob" => Some("Glob"), - "grep" => Some("Grep"), - _ => None, - } -} - fn source_record( file: &AgentFile, key: SourceKey, diff --git a/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs index e8b651cc45..1d9cabb642 100644 --- a/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs @@ -5,7 +5,7 @@ use bitfun_product_domains::external_sources::{ use bitfun_product_domains::external_subagents::{ ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, ExternalSubagentMode, ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, - ExternalSubagentSourceProvider, + ExternalSubagentSourceProvider, ExternalSubagentToolCapability, }; use std::collections::BTreeSet; use std::fs; @@ -70,6 +70,32 @@ fn write(path: impl AsRef, contents: &str) { fs::write(path, contents).unwrap(); } +#[test] +fn claude_code_builtin_tools_map_to_provider_neutral_capabilities() { + let fixture = Fixture::new(); + write( + fixture.user_claude.join("agents/worker.md"), + "---\nname: worker\ndescription: Worker\ntools: [Bash, Edit, Write]\n---\nMake the requested change", + ); + + let snapshot = fixture.discover(BTreeSet::new()); + let mappings = snapshot.definitions[0] + .requested_tools + .selectors + .iter() + .map(|selector| (selector.source_name.as_str(), selector.canonical_capability)) + .collect::>(); + + assert_eq!( + mappings, + vec![ + ("Bash", Some(ExternalSubagentToolCapability::ExecuteCommand)), + ("Edit", Some(ExternalSubagentToolCapability::EditFile)), + ("Write", Some(ExternalSubagentToolCapability::WriteFile)), + ] + ); +} + #[test] fn nearest_project_agent_overrides_user_agent_without_field_merge() { let fixture = Fixture::new(); diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index da383aa8cb..1cd51f343f 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -14,14 +14,16 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentContributionRole, ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, - ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, ExternalSubagentToolRequest, - ExternalSubagentToolSelector, SecretText, + ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, + ExternalSubagentToolCapability, ExternalSubagentToolRequest, ExternalSubagentToolSelector, + SecretText, }; use bitfun_product_domains::tool_permissions::{ wildcard_matches, PermissionConstraintLayer, PermissionEffect, PermissionResourceCaseSensitivity, PermissionRule, }; use bitfun_services_core::{jsonc::strip_jsonc, markdown::FrontMatterMarkdown}; +use bitfun_static_hook_support::common_external_subagent_tool_capability; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -956,17 +958,19 @@ fn tool_request( degraded.push("opencode_default_permission_semantics_not_imported".to_string()); return ExternalSubagentToolRequest { selectors: [ - ("list", "LS"), - ("read", "Read"), - ("glob", "Glob"), - ("grep", "Grep"), + ("list", ExternalSubagentToolCapability::DirectoryList), + ("read", ExternalSubagentToolCapability::ReadFile), + ("glob", ExternalSubagentToolCapability::GlobFiles), + ("grep", ExternalSubagentToolCapability::SearchText), ] .into_iter() - .map(|(source_name, canonical)| ExternalSubagentToolSelector { - source_name: source_name.to_string(), - canonical_host_name: Some(canonical.to_string()), - allowed: true, - }) + .map( + |(source_name, canonical_capability)| ExternalSubagentToolSelector { + source_name: source_name.to_string(), + canonical_capability: Some(canonical_capability), + allowed: true, + }, + ) .collect(), uses_conservative_default: true, }; @@ -999,16 +1003,10 @@ fn tool_request( } continue; } - let canonical = match name.to_ascii_lowercase().as_str() { - "list" => Some("LS"), - "read" => Some("Read"), - "glob" => Some("Glob"), - "grep" => Some("Grep"), - _ => None, - }; + let canonical_capability = common_external_subagent_tool_capability(name); selectors.push(ExternalSubagentToolSelector { source_name: name.clone(), - canonical_host_name: canonical.map(str::to_string), + canonical_capability, allowed, }); } @@ -1235,9 +1233,7 @@ fn permission_action_uses_workspace_paths( .selectors .iter() .filter(|selector| selector.allowed) - .filter_map(|selector| { - permission_action_for_host_tool(selector.canonical_host_name.as_deref()) - }) + .filter_map(|selector| permission_action_for_tool_capability(selector.canonical_capability)) .filter(|host_action| { wildcard_matches( host_action, @@ -1285,7 +1281,7 @@ fn validate_current_permission_action_enforcement( if !selector.allowed { return false; } - let host_action = permission_action_for_host_tool(selector.canonical_host_name.as_deref()); + let host_action = permission_action_for_tool_capability(selector.canonical_capability); let rule_reaches_tool = wildcard_matches( &selector.source_name, source_action, @@ -1324,11 +1320,10 @@ fn validate_permission_action_enforcement( && requested_tools .selectors .iter() - .any(|selector| selector.allowed && selector.canonical_host_name.is_none()); + .any(|selector| selector.allowed && selector.canonical_capability.is_none()); let selected_named_tool_is_unenforced = source_action != "*" && requested_tools.selectors.iter().any(|selector| { - let host_action = - permission_action_for_host_tool(selector.canonical_host_name.as_deref()); + let host_action = permission_action_for_tool_capability(selector.canonical_capability); let source_action_matches = wildcard_matches( &selector.source_name, source_action, @@ -1347,8 +1342,7 @@ fn validate_permission_action_enforcement( let wildcard_reaches_unenforced_tool = source_action == "*" && requested_tools.selectors.iter().any(|selector| { selector.allowed - && permission_action_for_host_tool(selector.canonical_host_name.as_deref()) - .is_none() + && permission_action_for_tool_capability(selector.canonical_capability).is_none() }); if effect != PermissionEffect::Allow @@ -1362,16 +1356,15 @@ fn validate_permission_action_enforcement( } } -fn permission_action_for_host_tool(host_tool: Option<&str>) -> Option<&'static str> { - match host_tool { - Some("Read") => Some("read"), - Some("Write" | "Edit" | "Delete") => Some("edit"), - Some("Bash" | "ExecCommand") => Some("bash"), - Some("Task") => Some("task"), - Some("Skill") => Some("skill"), - Some("WebFetch") => Some("webfetch"), - Some("WebSearch") => Some("websearch"), - Some("Git") => Some("git"), +fn permission_action_for_tool_capability( + capability: Option, +) -> Option<&'static str> { + match capability { + Some(ExternalSubagentToolCapability::ReadFile) => Some("read"), + Some( + ExternalSubagentToolCapability::WriteFile | ExternalSubagentToolCapability::EditFile, + ) => Some("edit"), + Some(ExternalSubagentToolCapability::ExecuteCommand) => Some("bash"), _ => None, } } diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs index bbd12953cb..344addbdb7 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs @@ -7,7 +7,7 @@ use bitfun_product_domains::external_sources::{ use bitfun_product_domains::external_subagents::{ ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, ExternalSubagentMode, ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, - ExternalSubagentSourceProvider, + ExternalSubagentSourceProvider, ExternalSubagentToolCapability, }; use bitfun_product_domains::tool_permissions::{ PermissionEffect, PermissionEvaluator, PermissionRule, @@ -51,6 +51,57 @@ fn discover( .expect("discover OpenCode agents") } +#[test] +fn opencode_builtin_tools_map_to_provider_neutral_capabilities() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "worker": { + "prompt": "Make the requested change", + "mode": "subagent", + "tools": { + "bash": true, + "edit": true, + "write": true + } + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == "worker") + .unwrap(); + let mappings = definition + .requested_tools + .selectors + .iter() + .map(|selector| (selector.source_name.as_str(), selector.canonical_capability)) + .collect::>(); + + assert_eq!( + mappings, + vec![ + ("bash", Some(ExternalSubagentToolCapability::ExecuteCommand)), + ("edit", Some(ExternalSubagentToolCapability::EditFile)), + ("write", Some(ExternalSubagentToolCapability::WriteFile)), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::Ready + ); +} + #[test] fn omo_oracle_flat_permissions_become_provider_neutral_constraints() { let temp = TempDir::new().unwrap(); @@ -1258,9 +1309,14 @@ fn safe_subset_is_fail_closed_and_default_tools_are_explicit() { .requested_tools .selectors .iter() - .map(|item| item.canonical_host_name.as_deref().unwrap()) + .map(|item| item.canonical_capability.unwrap()) .collect::>(), - vec!["LS", "Read", "Glob", "Grep"] + vec![ + ExternalSubagentToolCapability::DirectoryList, + ExternalSubagentToolCapability::ReadFile, + ExternalSubagentToolCapability::GlobFiles, + ExternalSubagentToolCapability::SearchText, + ] ); assert!(defaulted.requested_tools.uses_conservative_default); assert_eq!( diff --git a/src/crates/adapters/static-hook-support/src/lib.rs b/src/crates/adapters/static-hook-support/src/lib.rs index 0b465d8997..a3d07ad145 100644 --- a/src/crates/adapters/static-hook-support/src/lib.rs +++ b/src/crates/adapters/static-hook-support/src/lib.rs @@ -8,6 +8,7 @@ use bitfun_product_domains::external_hook_import::{ MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES, MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH, MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES, }; +use bitfun_product_domains::external_subagents::ExternalSubagentToolCapability; pub use bitfun_services_core::bounded_fs::{ collect_bounded_regular_files, read_bounded_file, read_bounded_text, BoundedDirectoryWalkError, BoundedDirectoryWalkLimit, BoundedDirectoryWalkLimits, BoundedFileRead, BoundedTextRead, @@ -20,6 +21,29 @@ use std::path::{Component, Path, PathBuf}; const MAX_MATCHER_BYTES: usize = 512; const MAX_EVENT_NAME_BYTES: usize = 160; +const COMMON_EXTERNAL_SUBAGENT_TOOL_CAPABILITIES: &[(&str, ExternalSubagentToolCapability)] = &[ + ("ls", ExternalSubagentToolCapability::DirectoryList), + ("list", ExternalSubagentToolCapability::DirectoryList), + ("read", ExternalSubagentToolCapability::ReadFile), + ("glob", ExternalSubagentToolCapability::GlobFiles), + ("grep", ExternalSubagentToolCapability::SearchText), + ("bash", ExternalSubagentToolCapability::ExecuteCommand), + ("edit", ExternalSubagentToolCapability::EditFile), + ("write", ExternalSubagentToolCapability::WriteFile), +]; + +/// Normalizes widely shared external Agent tool labels at the adapter boundary. +/// Provider-specific aliases remain owned by their ecosystem adapter. +pub fn common_external_subagent_tool_capability( + name: &str, +) -> Option { + COMMON_EXTERNAL_SUBAGENT_TOOL_CAPABILITIES + .iter() + .find_map(|(candidate, capability)| { + candidate.eq_ignore_ascii_case(name).then_some(*capability) + }) +} + /// Distinguishes an absent path from metadata failures. Static adapters may /// ignore `NotFound`, but permission and transient filesystem failures must be /// surfaced so the coordinator can retain the last valid snapshot as stale. @@ -648,6 +672,29 @@ fn matcher_summary(value: Option<&Value>) -> ExternalHookMatcherSummary { #[cfg(test)] mod tests { use super::*; + use bitfun_product_domains::external_subagents::ExternalSubagentToolCapability; + + #[test] + fn common_external_tool_names_map_without_source_specific_contract_behavior() { + use ExternalSubagentToolCapability::{EditFile, ExecuteCommand, WriteFile}; + + assert_eq!( + common_external_subagent_tool_capability("bash"), + Some(ExecuteCommand) + ); + assert_eq!( + common_external_subagent_tool_capability("EDIT"), + Some(EditFile) + ); + assert_eq!( + common_external_subagent_tool_capability("write"), + Some(WriteFile) + ); + assert_eq!( + common_external_subagent_tool_capability("provider-specific-tool"), + None + ); + } #[test] fn project_ancestors_are_bounded_and_returned_outer_to_inner() { diff --git a/src/crates/assembly/core/src/external_mcp.rs b/src/crates/assembly/core/src/external_mcp.rs index 77a75dbc38..4208e49aa4 100644 --- a/src/crates/assembly/core/src/external_mcp.rs +++ b/src/crates/assembly/core/src/external_mcp.rs @@ -10,6 +10,7 @@ use bitfun_product_domains::external_sources::{ ExternalMcpConflictCandidate, ExternalMcpServerDefinition, ExternalMcpStaticStatus, ExternalSourceDiagnostic, PreparedExternalMcpServer, PreparedExternalMcpTransport, }; +use bitfun_services_integrations::mcp::server::MCPServerStartFailure; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -61,6 +62,22 @@ pub(super) enum ExternalMcpRuntimeStatus { Unavailable(String), } +pub(super) const EXTERNAL_MCP_RUNTIME_CONFIGURATION_CHANGED: &str = + "external_mcp.runtime.configuration_changed"; +pub(super) const EXTERNAL_MCP_RUNTIME_CONFIGURATION_INVALID: &str = + "external_mcp.runtime.configuration_invalid"; +pub(super) const EXTERNAL_MCP_RUNTIME_HOST_UNAVAILABLE: &str = + "external_mcp.runtime.host_unavailable"; +pub(super) const EXTERNAL_MCP_RUNTIME_INSTALL_FAILED: &str = "external_mcp.runtime.install_failed"; +pub(super) const EXTERNAL_MCP_RUNTIME_PREPARATION_FAILED: &str = + "external_mcp.runtime.preparation_failed"; +pub(super) const EXTERNAL_MCP_RUNTIME_RETIRE_FAILED: &str = "external_mcp.runtime.retire_failed"; +pub(super) const EXTERNAL_MCP_RUNTIME_SERVER_MISSING: &str = "external_mcp.runtime.server_missing"; +pub(super) const EXTERNAL_MCP_RUNTIME_FAILED: &str = "external_mcp.runtime.failed"; +pub(super) const EXTERNAL_MCP_RUNTIME_STOPPED: &str = "external_mcp.runtime.stopped"; +pub(super) const EXTERNAL_MCP_RUNTIME_STATUS_UNAVAILABLE: &str = + "external_mcp.runtime.status_unavailable"; + /// Narrow product-to-runtime port. Product reconciliation works only with /// source-neutral prepared MCP data; the concrete BitFun MCP manager remains /// behind this implementation boundary. @@ -98,26 +115,32 @@ impl ExternalMcpRuntimePort for BitFunExternalMcpRuntime { if prepared.id != candidate.definition.id || prepared.behavior_version != candidate.definition.behavior_version { - return Err("The external MCP configuration changed before activation".to_string()); + return Err(EXTERNAL_MCP_RUNTIME_CONFIGURATION_CHANGED.to_string()); } - let config = prepared_mcp_config(candidate, prepared)?; + let config = prepared_mcp_config(candidate, prepared) + .map_err(|_| EXTERNAL_MCP_RUNTIME_CONFIGURATION_INVALID.to_string())?; mcp_manager()? .install_external_ephemeral_server(config, workspace_key.to_string()) .await // Runtime errors may contain a URL or command line. Keep the // product-facing error actionable without echoing sensitive data. - .map_err(|_| "The external MCP server could not be started".to_string()) + .map_err(|_| EXTERNAL_MCP_RUNTIME_INSTALL_FAILED.to_string()) } async fn retire(&self, runtime_id: &str) -> Result<(), String> { mcp_manager()? .retire_external_ephemeral_server(runtime_id) .await - .map_err(|_| "The external MCP server could not be stopped cleanly".to_string()) + .map_err(|_| EXTERNAL_MCP_RUNTIME_RETIRE_FAILED.to_string()) } async fn status(&self, runtime_id: &str) -> Result { let manager = mcp_manager()?; + if let Some(failure) = manager.external_server_start_failure(runtime_id).await { + return Ok(ExternalMcpRuntimeStatus::Unavailable( + external_mcp_start_failure_reason(failure).to_string(), + )); + } if manager.external_server_readiness(runtime_id).await == Some(false) { return Ok(ExternalMcpRuntimeStatus::Loading); } @@ -128,28 +151,10 @@ impl ExternalMcpRuntimePort for BitFunExternalMcpRuntime { .await { Ok(Ok(status)) => status, - Ok(Err(_)) => return Err("The external MCP server is no longer available".to_string()), + Ok(Err(_)) => return Err(EXTERNAL_MCP_RUNTIME_SERVER_MISSING.to_string()), Err(_) => return Ok(ExternalMcpRuntimeStatus::Loading), }; - Ok(match status { - MCPServerStatus::Connected | MCPServerStatus::Healthy => { - ExternalMcpRuntimeStatus::Active - } - MCPServerStatus::Uninitialized - | MCPServerStatus::Starting - | MCPServerStatus::Reconnecting => ExternalMcpRuntimeStatus::Loading, - MCPServerStatus::NeedsAuth => ExternalMcpRuntimeStatus::Unavailable( - "Authentication is required for this MCP server".to_string(), - ), - MCPServerStatus::Failed => ExternalMcpRuntimeStatus::Unavailable( - "The MCP server failed to start or stopped unexpectedly".to_string(), - ), - MCPServerStatus::Stopping | MCPServerStatus::Stopped => { - ExternalMcpRuntimeStatus::Unavailable( - "The MCP server is not currently running".to_string(), - ) - } - }) + Ok(external_mcp_runtime_status(status)) } async fn replace_workspace_route( @@ -169,10 +174,67 @@ impl ExternalMcpRuntimePort for BitFunExternalMcpRuntime { } } +fn external_mcp_runtime_status(status: MCPServerStatus) -> ExternalMcpRuntimeStatus { + match status { + MCPServerStatus::Connected | MCPServerStatus::Healthy => ExternalMcpRuntimeStatus::Active, + MCPServerStatus::Uninitialized + | MCPServerStatus::Starting + | MCPServerStatus::Reconnecting => ExternalMcpRuntimeStatus::Loading, + MCPServerStatus::NeedsAuth => ExternalMcpRuntimeStatus::Unavailable( + external_mcp_start_failure_reason(MCPServerStartFailure::Authentication).to_string(), + ), + MCPServerStatus::Failed => { + ExternalMcpRuntimeStatus::Unavailable(EXTERNAL_MCP_RUNTIME_FAILED.to_string()) + } + MCPServerStatus::Stopping | MCPServerStatus::Stopped => { + ExternalMcpRuntimeStatus::Unavailable(EXTERNAL_MCP_RUNTIME_STOPPED.to_string()) + } + } +} + +fn external_mcp_start_failure_reason(failure: MCPServerStartFailure) -> &'static str { + match failure { + MCPServerStartFailure::Authentication => "external_mcp.start.authentication", + MCPServerStartFailure::Timeout => "external_mcp.start.timeout", + MCPServerStartFailure::CommandUnavailable => "external_mcp.start.command_unavailable", + MCPServerStartFailure::WorkingDirectoryUnavailable => { + "external_mcp.start.working_directory_unavailable" + } + MCPServerStartFailure::ConnectionFailed => "external_mcp.start.connection_failed", + MCPServerStartFailure::ProtocolFailed => "external_mcp.start.protocol_failed", + MCPServerStartFailure::Other => "external_mcp.start.other", + } +} + fn mcp_manager() -> Result, String> { get_global_mcp_service() .map(|service| service.server_manager()) - .ok_or_else(|| "The BitFun MCP runtime is not available in this product host".to_string()) + .ok_or_else(|| EXTERNAL_MCP_RUNTIME_HOST_UNAVAILABLE.to_string()) +} + +#[cfg(test)] +mod runtime_reason_tests { + use super::*; + + #[test] + fn terminal_runtime_statuses_use_stable_product_reason_codes() { + let cases = [ + ( + MCPServerStatus::NeedsAuth, + "external_mcp.start.authentication", + ), + (MCPServerStatus::Failed, EXTERNAL_MCP_RUNTIME_FAILED), + (MCPServerStatus::Stopping, EXTERNAL_MCP_RUNTIME_STOPPED), + (MCPServerStatus::Stopped, EXTERNAL_MCP_RUNTIME_STOPPED), + ]; + + for (status, expected) in cases { + assert_eq!( + external_mcp_runtime_status(status), + ExternalMcpRuntimeStatus::Unavailable(expected.to_string()) + ); + } + } } pub(super) fn prepared_mcp_config( diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 0d1016cf4b..791ee9da04 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -44,7 +44,8 @@ use crate::agentic::workspace::workspace_route_key; use crate::external_mcp::{ reconcile_external_mcp_catalog, BitFunExternalMcpRuntime, ExternalMcpDecision, ExternalMcpDecisions, ExternalMcpProductState, ExternalMcpRuntimePort, - ExternalMcpRuntimeStatus, NativeMcpCandidate, + ExternalMcpRuntimeStatus, NativeMcpCandidate, EXTERNAL_MCP_RUNTIME_FAILED, + EXTERNAL_MCP_RUNTIME_PREPARATION_FAILED, EXTERNAL_MCP_RUNTIME_STATUS_UNAVAILABLE, }; use crate::external_subagents::{ project_external_subagents_read_only, reconcile_external_subagents, ExternalSubagentDecisions, @@ -1999,7 +2000,7 @@ impl WorkspaceExternalSourceService { conflict_choices: &preferences.mcp_conflict_choices, }, ), - Err(error) => { + Err(_) => { let mut state = reconcile_external_mcp_catalog( self.execution_domain_id.as_str(), &mcp_workspace_key, @@ -2014,7 +2015,7 @@ impl WorkspaceExternalSourceService { for entry in &mut state.entries { entry.runtime_id = None; entry.activation_state = ExternalMcpActivationState::RuntimeUnavailable { - reason: error.clone(), + reason: "external_mcp.native_configuration_unavailable".to_string(), }; } state.active.clear(); @@ -2253,7 +2254,7 @@ impl WorkspaceExternalSourceService { ExternalMcpActivationState::Active | ExternalMcpActivationState::Starting ) { entry.activation_state = ExternalMcpActivationState::RuntimeUnavailable { - reason: "This Host exposes discovery only; use Desktop or an authenticated Peer Host to run external MCP servers".to_string(), + reason: "external_mcp.runtime.host_read_only".to_string(), }; } } @@ -2390,9 +2391,7 @@ impl WorkspaceExternalSourceService { let status = managed_statuses .get(&candidate.runtime_id) .cloned() - .unwrap_or_else(|| { - Err("The external MCP server status is unavailable".to_string()) - }); + .unwrap_or_else(|| Err(EXTERNAL_MCP_RUNTIME_STATUS_UNAVAILABLE.to_string())); // Keep failed registrations managed until the user disables // them. Re-installing from a status error would turn a // persistent startup failure into an unbounded retry loop. @@ -2408,8 +2407,10 @@ impl WorkspaceExternalSourceService { .prepare_server_guarded(&server_id, &behavior_version) }) .await - .map_err(|_| "The external MCP configuration could not be prepared".to_string()) - .and_then(|result| result.map_err(|error| error.message)); + .map_err(|_| EXTERNAL_MCP_RUNTIME_PREPARATION_FAILED.to_string()) + .and_then(|result| { + result.map_err(|_| EXTERNAL_MCP_RUNTIME_PREPARATION_FAILED.to_string()) + }); let activation = match prepared { Ok(prepared) => { @@ -3348,6 +3349,149 @@ impl WorkspaceExternalSourceService { self.rebuild_product_snapshot(command_snapshot).await } + async fn set_tool_targets_enabled( + &self, + decisions: Vec<(String, String)>, + enabled: bool, + expected_catalog_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.generation != expected_catalog_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err(stale_operation_error( + "External tool catalog changed; refresh before retrying", + )); + } + for (approval_key, decision_key) in &decisions { + let source = snapshot + .tools + .iter() + .find(|tool| { + tool.approval_key == *approval_key && tool.decision_key == *decision_key + }) + .map(|tool| &tool.definition.id.target.source) + .ok_or_else(|| { + missing_candidate_error("External tool decision is no longer available") + })?; + if enabled { + ensure_source_capability_active(&snapshot, source, EXTERNAL_CAPABILITY_TOOL)?; + } + } + let preferences = persist_capability_bulk_decisions( + ExternalCapabilityBulkDecisionSet::Tool(decisions), + enabled, + expected_preference_revision, + ) + .await?; + propagate_tool_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn set_subagents_enabled( + &self, + decisions: Vec<(String, String)>, + enabled: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.subagent_generation != expected_subagent_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err(stale_operation_error( + "External subagent catalog changed; refresh before retrying", + )); + } + for (candidate_id, decision_key) in &decisions { + let summary = snapshot + .subagents + .iter() + .find(|summary| { + summary.candidate_id.as_str() == candidate_id + && summary.decision_key == *decision_key + }) + .ok_or_else(|| { + missing_candidate_error("External subagent decision is no longer available") + })?; + if enabled { + ensure_source_set_capability_active( + &snapshot, + &summary.source_keys, + EXTERNAL_CAPABILITY_SUBAGENT, + )?; + } + } + let preferences = persist_capability_bulk_decisions( + ExternalCapabilityBulkDecisionSet::Subagent( + decisions + .into_iter() + .map(|(_, decision_key)| decision_key) + .collect(), + ), + enabled, + expected_preference_revision, + ) + .await?; + propagate_subagent_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn set_mcp_servers_enabled( + &self, + decisions: Vec<(String, String)>, + enabled: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.mcp_generation != expected_mcp_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err(stale_operation_error( + "External MCP catalog changed; refresh before retrying", + )); + } + for (candidate_id, decision_key) in &decisions { + let server = snapshot + .mcp_servers + .iter() + .find(|server| { + server.candidate_id == *candidate_id && server.decision_key == *decision_key + }) + .ok_or_else(|| { + missing_candidate_error("External MCP decision is no longer available") + })?; + if enabled { + ensure_source_capability_active( + &snapshot, + &server.definition.id.source, + EXTERNAL_CAPABILITY_MCP, + )?; + } + } + let preferences = persist_capability_bulk_decisions( + ExternalCapabilityBulkDecisionSet::Mcp( + decisions + .into_iter() + .map(|(_, decision_key)| decision_key) + .collect(), + ), + enabled, + expected_preference_revision, + ) + .await?; + propagate_mcp_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + async fn set_tool_conflict_choice( &self, conflict_key: &str, @@ -4490,6 +4634,7 @@ fn mark_external_mcp_runtime_unavailable( candidate: &crate::external_mcp::ActiveExternalMcpCandidate, reason: String, ) { + let reason = stable_external_mcp_runtime_reason(&reason).to_string(); if let Some(entry) = state .entries .iter_mut() @@ -4509,6 +4654,31 @@ fn mark_external_mcp_runtime_unavailable( ); } +fn stable_external_mcp_runtime_reason(reason: &str) -> &str { + match reason { + "external_mcp.start.authentication" + | "external_mcp.start.timeout" + | "external_mcp.start.command_unavailable" + | "external_mcp.start.working_directory_unavailable" + | "external_mcp.start.connection_failed" + | "external_mcp.start.protocol_failed" + | "external_mcp.start.other" + | "external_mcp.native_configuration_unavailable" + | "external_mcp.runtime.host_read_only" + | "external_mcp.runtime.configuration_changed" + | "external_mcp.runtime.configuration_invalid" + | "external_mcp.runtime.host_unavailable" + | "external_mcp.runtime.install_failed" + | "external_mcp.runtime.preparation_failed" + | "external_mcp.runtime.retire_failed" + | "external_mcp.runtime.server_missing" + | "external_mcp.runtime.failed" + | "external_mcp.runtime.stopped" + | "external_mcp.runtime.status_unavailable" => reason, + _ => EXTERNAL_MCP_RUNTIME_FAILED, + } +} + fn merge_mcp_state( snapshot: &mut ExternalSourceCatalogSnapshot, coordinator_snapshot: &bitfun_external_sources::ExternalMcpCoordinatorSnapshot, @@ -5081,6 +5251,88 @@ async fn persist_tool_target_decision( }) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalCapabilityBulkDecisionSet { + Tool(Vec<(String, String)>), + Subagent(Vec), + Mcp(Vec), +} + +fn apply_capability_bulk_decisions( + config: &mut ExternalSourcesConfig, + decisions: ExternalCapabilityBulkDecisionSet, + enabled: bool, +) -> bool { + let changed = match decisions { + ExternalCapabilityBulkDecisionSet::Tool(decisions) => { + let previous_approved = config.approved_tool_targets.clone(); + let previous_declined = config.declined_tool_decisions.clone(); + for (approval_key, decision_key) in decisions { + reconcile_tool_target_decision(config, approval_key, decision_key, enabled); + } + config.approved_tool_targets != previous_approved + || config.declined_tool_decisions != previous_declined + } + ExternalCapabilityBulkDecisionSet::Subagent(decisions) => { + let previous_approved = config.approved_subagent_envelopes.clone(); + let previous_declined = config.declined_subagent_decisions.clone(); + for decision_key in decisions { + if enabled { + config + .approved_subagent_envelopes + .insert(decision_key.clone()); + config.declined_subagent_decisions.remove(&decision_key); + } else { + config.approved_subagent_envelopes.remove(&decision_key); + config + .declined_subagent_decisions + .insert(decision_key.clone(), decision_key); + } + } + config.approved_subagent_envelopes != previous_approved + || config.declined_subagent_decisions != previous_declined + } + ExternalCapabilityBulkDecisionSet::Mcp(decisions) => { + let previous = config.mcp_server_decisions.clone(); + for decision_key in decisions { + reconcile_versioned_mcp_server_decision( + &mut config.mcp_server_decisions, + decision_key, + enabled, + ); + } + config.mcp_server_decisions != previous + } + }; + if changed { + config.preference_revision = config.preference_revision.saturating_add(1); + } + changed +} + +async fn persist_capability_bulk_decisions( + decisions: ExternalCapabilityBulkDecisionSet, + enabled: bool, + expected_preference_revision: u64, +) -> Result { + ExternalSourcePreferenceStore::global()? + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + apply_capability_bulk_decisions(config, decisions, enabled); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + stale_operation_error( + "External capability preferences changed; refresh before retrying", + ) + }) + }) +} + fn reconcile_tool_target_decision( config: &mut ExternalSourcesConfig, approval_key: String, @@ -6472,6 +6724,24 @@ pub async fn set_external_tool_target_decision( .await } +pub async fn set_external_tool_targets_enabled( + workspace_root: Option<&Path>, + decisions: Vec<(String, String)>, + enabled: bool, + expected_catalog_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .set_tool_targets_enabled( + decisions, + enabled, + expected_catalog_generation, + expected_preference_revision, + ) + .await +} + pub async fn set_external_tool_conflict_choice( workspace_root: Option<&Path>, conflict_key: &str, @@ -6504,6 +6774,24 @@ pub async fn set_external_mcp_server_decision( .await } +pub async fn set_external_mcp_servers_enabled( + workspace_root: Option<&Path>, + decisions: Vec<(String, String)>, + enabled: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .set_mcp_servers_enabled( + decisions, + enabled, + expected_mcp_generation, + expected_preference_revision, + ) + .await +} + pub async fn choose_external_mcp_conflict( workspace_root: Option<&Path>, conflict_key: &str, @@ -6544,6 +6832,24 @@ pub async fn set_external_subagent_activation( .await } +pub async fn set_external_subagents_enabled( + workspace_root: Option<&Path>, + decisions: Vec<(String, String)>, + enabled: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .set_subagents_enabled( + decisions, + enabled, + expected_subagent_generation, + expected_preference_revision, + ) + .await +} + pub async fn set_external_subagent_model_binding( workspace_root: Option<&Path>, binding_key: &str, @@ -7086,6 +7392,24 @@ mod tests { use bitfun_product_domains::workspace_references::ExternalWorkspaceReferenceDefinition; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn runtime_reason_projection_preserves_known_codes_and_hides_raw_errors() { + for reason in [ + "external_mcp.start.authentication", + "external_mcp.start.timeout", + "external_mcp.runtime.host_unavailable", + "external_mcp.runtime.server_missing", + "external_mcp.runtime.failed", + "external_mcp.runtime.stopped", + ] { + assert_eq!(stable_external_mcp_runtime_reason(reason), reason); + } + assert_eq!( + stable_external_mcp_runtime_reason("failed to read D:/private/mcp.json: access denied"), + EXTERNAL_MCP_RUNTIME_FAILED + ); + } + fn native_mcp_config_with_pin(pin: &str) -> MCPServerConfig { MCPServerConfig { id: "native-secret-test".to_string(), @@ -9769,6 +10093,115 @@ mod tests { ); } + #[test] + fn capability_bulk_decisions_update_multiple_items_with_one_revision() { + let mut config = ExternalSourcesConfig { + preference_revision: 7, + ..Default::default() + }; + + assert!(apply_capability_bulk_decisions( + &mut config, + ExternalCapabilityBulkDecisionSet::Tool(vec![ + ("tool-approval-a".to_string(), "tool-decision-a".to_string()), + ("tool-approval-b".to_string(), "tool-decision-b".to_string()), + ]), + true, + )); + assert_eq!(config.preference_revision, 8); + assert_eq!( + config.approved_tool_targets, + BTreeSet::from(["tool-approval-a".to_string(), "tool-approval-b".to_string(),]) + ); + + assert!(!apply_capability_bulk_decisions( + &mut config, + ExternalCapabilityBulkDecisionSet::Tool(vec![ + ("tool-approval-a".to_string(), "tool-decision-a".to_string()), + ("tool-approval-b".to_string(), "tool-decision-b".to_string()), + ]), + true, + )); + assert_eq!(config.preference_revision, 8); + + assert!(apply_capability_bulk_decisions( + &mut config, + ExternalCapabilityBulkDecisionSet::Mcp(vec![ + "external_mcp_approval:local-user:workspace:docs:v1".to_string(), + "external_mcp_approval:local-user:workspace:search:v1".to_string(), + ]), + false, + )); + assert_eq!(config.preference_revision, 9); + assert!(config + .mcp_server_decisions + .values() + .all(|decision| !decision.approved)); + + assert!(apply_capability_bulk_decisions( + &mut config, + ExternalCapabilityBulkDecisionSet::Subagent(vec![ + "agent-decision-a".to_string(), + "agent-decision-b".to_string(), + ]), + true, + )); + assert_eq!(config.preference_revision, 10); + assert_eq!( + config.approved_subagent_envelopes, + BTreeSet::from([ + "agent-decision-a".to_string(), + "agent-decision-b".to_string(), + ]) + ); + assert!(apply_capability_bulk_decisions( + &mut config, + ExternalCapabilityBulkDecisionSet::Subagent(vec![ + "agent-decision-a".to_string(), + "agent-decision-b".to_string(), + ]), + false, + )); + assert_eq!(config.preference_revision, 11); + assert!(config.approved_subagent_envelopes.is_empty()); + } + + #[tokio::test] + async fn capability_bulk_actions_reject_catalog_drift_without_expanding_the_decision_set() { + let service = test_service(Vec::new()); + { + let mut snapshot = lock_snapshot(&service.snapshot); + snapshot.generation = 12; + snapshot.subagent_generation = 13; + snapshot.mcp_generation = 14; + snapshot.preference_revision = 7; + } + + let errors = [ + service + .set_tool_targets_enabled(Vec::new(), true, 11, 7) + .await + .expect_err("tool catalog drift must require a new review"), + service + .set_subagents_enabled(Vec::new(), true, 12, 7) + .await + .expect_err("subagent catalog drift must require a new review"), + service + .set_mcp_servers_enabled(Vec::new(), true, 13, 7) + .await + .expect_err("MCP catalog drift must require a new review"), + ]; + + for error in errors { + assert_eq!( + ExternalSourceOperationError::decode(&error) + .expect("bulk catalog drift uses the typed error contract") + .code, + ExternalSourceOperationErrorCode::StaleRevision + ); + } + } + #[tokio::test] async fn tool_approval_waits_for_refresh_and_rejects_a_changed_decision() { let service = test_service(Vec::new()); diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 5330526cd9..71953f3501 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -33,6 +33,7 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentDiagnosticSummary, ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, ExternalSubagentModelRequest, ExternalSubagentSummary, + ExternalSubagentToolCapability, }; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -248,12 +249,11 @@ async fn gather_product_facts( .iter() .flat_map(|definition| &definition.requested_tools.selectors) .filter(|selector| selector.allowed) - .map(|selector| { + .filter_map(|selector| { selector - .canonical_host_name - .as_deref() - .unwrap_or(&selector.source_name) - .to_string() + .canonical_capability + .map(host_tool_name) + .map(str::to_string) }) .collect::>(); let mut tools = BTreeMap::new(); @@ -334,6 +334,18 @@ async fn gather_product_facts( } } +fn host_tool_name(capability: ExternalSubagentToolCapability) -> &'static str { + match capability { + ExternalSubagentToolCapability::DirectoryList => "LS", + ExternalSubagentToolCapability::ReadFile => "Read", + ExternalSubagentToolCapability::GlobFiles => "Glob", + ExternalSubagentToolCapability::SearchText => "Grep", + ExternalSubagentToolCapability::ExecuteCommand => "ExecCommand", + ExternalSubagentToolCapability::EditFile => "Edit", + ExternalSubagentToolCapability::WriteFile => "Write", + } +} + fn log_model_config_unavailable(stage: &str, error: &BitFunError) { if claim_model_config_outage_log(&MODEL_CONFIG_UNAVAILABLE_LOGGED) { log::warn!( @@ -1031,10 +1043,18 @@ fn resolve_external_candidate( .iter() .filter(|selector| selector.allowed) { - let name = selector - .canonical_host_name - .as_deref() - .unwrap_or(&selector.source_name); + let Some(capability) = selector.canonical_capability else { + if matches!(compatibility, ExternalSubagentCompatibilityState::Ready) { + compatibility = ExternalSubagentCompatibilityState::ReadyWithDegradation; + } + unavailable_tool_labels.push(selector.source_name.clone()); + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: "external_subagent.tool_unavailable".to_string(), + blocks_activation: false, + }); + continue; + }; + let name = host_tool_name(capability); match facts.tools.get(name) { Some(tool) => tools.push(tool.clone()), None => { @@ -1482,8 +1502,8 @@ mod tests { ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingTarget, ExternalSubagentModelProfileRequest, - ExternalSubagentProvenanceRef, ExternalSubagentToolRequest, ExternalSubagentToolSelector, - SecretText, + ExternalSubagentProvenanceRef, ExternalSubagentToolCapability, ExternalSubagentToolRequest, + ExternalSubagentToolSelector, SecretText, }; use bitfun_product_domains::tool_permissions::{ PermissionConstraintLayer, PermissionEffect, PermissionRule, @@ -1517,7 +1537,7 @@ mod tests { requested_tools: ExternalSubagentToolRequest { selectors: vec![ExternalSubagentToolSelector { source_name: "read".to_string(), - canonical_host_name: Some("Read".to_string()), + canonical_capability: Some(ExternalSubagentToolCapability::ReadFile), allowed: true, }], uses_conservative_default: false, @@ -2285,18 +2305,16 @@ mod tests { } #[test] - fn unavailable_tool_labels_are_preserved_for_product_diagnostics() { + fn unknown_source_tool_degrades_without_borrowing_a_same_named_host_tool() { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let mut definition_snapshot = snapshot("behavior-v1", "catalog-v1"); - definition_snapshot.definitions[0] - .requested_tools - .selectors - .push(ExternalSubagentToolSelector { - source_name: "shell".to_string(), - canonical_host_name: Some("Shell".to_string()), + definition_snapshot.definitions[0].requested_tools.selectors[0] = + ExternalSubagentToolSelector { + source_name: "Read".to_string(), + canonical_capability: None, allowed: true, - }); + }; let state = reconcile_with_facts( Some(Path::new("C:/repo")), @@ -2313,12 +2331,33 @@ mod tests { &facts(), ); - assert_eq!(state.summaries[0].unavailable_tool_labels, ["Shell"]); + assert_eq!(state.summaries[0].unavailable_tool_labels, ["Read"]); + assert_eq!( + state.summaries[0].compatibility_state, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); + assert!(state.summaries[0].effective_tool_labels.is_empty()); assert!(state.summaries[0].diagnostics.iter().any(|diagnostic| { - diagnostic.code == "external_subagent.tool_unavailable" && diagnostic.blocks_activation + diagnostic.code == "external_subagent.tool_unavailable" && !diagnostic.blocks_activation })); } + #[test] + fn executable_and_file_mutation_capabilities_use_bitfun_builtin_tools() { + assert_eq!( + host_tool_name(ExternalSubagentToolCapability::ExecuteCommand), + "ExecCommand" + ); + assert_eq!( + host_tool_name(ExternalSubagentToolCapability::EditFile), + "Edit" + ); + assert_eq!( + host_tool_name(ExternalSubagentToolCapability::WriteFile), + "Write" + ); + } + #[test] fn model_config_outage_logging_is_deduplicated_and_does_not_expose_error_values() { let logged = AtomicBool::new(false); 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 bba62016fc..1775e53f6f 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 @@ -48,11 +48,22 @@ impl MCPServerManager { external_start_token_is_current(start_tokens.get(server_id), expected) } - async fn remove_ephemeral_server_for_start(&self, server_id: &str, expected: &Arc<()>) -> bool { + async fn remove_ephemeral_server_for_start( + &self, + server_id: &str, + expected: &Arc<()>, + failure: MCPServerStartFailure, + ) -> bool { let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; if !self.external_start_token_matches(server_id, expected).await { return false; } + // Publish the safe failure fact before cleanup. Cleanup may itself + // fail, but the product surface must still be able to explain why the + // server left Starting. + self.runtime + .set_start_failure(server_id.to_string(), failure) + .await; if let Err(error) = self.remove_ephemeral_server(server_id).await { warn!( "Could not clean up failed external MCP startup: id={} error={}", @@ -73,6 +84,7 @@ impl MCPServerManager { config.validate()?; let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; let server_id = config.id.clone(); + self.runtime.clear_start_failure(&server_id).await; let start_token = Arc::new(()); self.ephemeral_start_tokens .write() @@ -183,12 +195,13 @@ impl MCPServerManager { } } Ok(Err(error)) => { + let failure = MCPServerStartFailure::classify(&error.to_string()); warn!( "External ephemeral MCP server failed to start: id={} error={}", server_id, error ); if manager - .remove_ephemeral_server_for_start(&server_id, &start_token) + .remove_ephemeral_server_for_start(&server_id, &start_token, failure) .await { notify_external_tool_registry_changed(); @@ -200,7 +213,11 @@ impl MCPServerManager { server_id ); if manager - .remove_ephemeral_server_for_start(&server_id, &start_token) + .remove_ephemeral_server_for_start( + &server_id, + &start_token, + MCPServerStartFailure::Timeout, + ) .await { notify_external_tool_registry_changed(); @@ -220,6 +237,7 @@ impl MCPServerManager { const RETIREMENT_RECLAIM_ATTEMPTS: usize = 3; const RETIREMENT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(250); let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; + self.runtime.clear_start_failure(server_id).await; self.ephemeral_start_tokens.write().await.remove(server_id); if !self.runtime.contains(server_id).await { self.runtime.remove_runtime_config(server_id).await; diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs index 0127e2112a..d890ebc8af 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs @@ -24,7 +24,7 @@ use crate::service::mcp::protocol::{MCPError, MCPPrompt, MCPResource}; use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_services_integrations::mcp::server::MCPConnectionEvent; -use bitfun_services_integrations::mcp::server::MCPServerRuntimeState; +use bitfun_services_integrations::mcp::server::{MCPServerRuntimeState, MCPServerStartFailure}; use log::{debug, error, info, warn}; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; @@ -156,6 +156,13 @@ impl MCPServerManager { .contains(server_id), ) } + + pub async fn external_server_start_failure( + &self, + server_id: &str, + ) -> Option { + self.runtime.start_failure(server_id).await + } } fn should_finish_ephemeral_retirement( diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs b/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs index b8cc537e95..6b71ad30f4 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/tests.rs @@ -40,6 +40,75 @@ fn superseded_external_start_token_cannot_clean_up_current_instance() { assert!(!super::external_start_token_is_current(None, &first)); } +#[tokio::test] +async fn failed_external_start_publishes_a_reason_before_runtime_cleanup() { + use crate::service::mcp::{ConfigLocation, MCPServerConfig, MCPServerTimeouts, MCPServerType}; + use std::collections::HashMap; + + let root = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().join("config")), + ); + let config_service = Arc::new( + crate::service::config::ConfigService::with_settings( + crate::service::config::ConfigManagerSettings { + path_manager: Some(path_manager), + auto_save: false, + backup_count: 0, + }, + ) + .await + .expect("config service"), + ); + let mcp_config_service = Arc::new( + crate::service::mcp::config::MCPConfigService::new(config_service) + .expect("MCP config service"), + ); + let manager = super::MCPServerManager::assemble(mcp_config_service, None); + let server_id = "external-mcp-failed-start"; + + manager + .install_external_ephemeral_server( + MCPServerConfig { + id: server_id.to_string(), + name: "Unavailable external MCP".to_string(), + server_type: MCPServerType::Local, + transport: None, + command: Some("bitfun-command-that-does-not-exist".to_string()), + args: Vec::new(), + env: HashMap::new(), + working_directory: None, + inherit_parent_environment: Some(false), + headers: HashMap::new(), + url: None, + auto_start: true, + enabled: true, + location: ConfigLocation::User, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth: None, + oauth_enabled: None, + xaa: None, + timeouts: MCPServerTimeouts::default(), + }, + "workspace-a".to_string(), + ) + .await + .expect("registration is asynchronous"); + + let reason = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(failure) = manager.external_server_start_failure(server_id).await { + break failure; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("failed startup should publish a reason"); + assert_eq!(reason, super::MCPServerStartFailure::CommandUnavailable); +} + #[tokio::test] async fn oauth_credentials_follow_the_manager_injected_data_dir() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs index 3560cca140..0a35b284bd 100644 --- a/src/crates/contracts/product-domains/src/external_subagents.rs +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -417,12 +417,24 @@ pub fn external_subagent_model_binding_key( )) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSubagentToolCapability { + DirectoryList, + ReadFile, + GlobFiles, + SearchText, + ExecuteCommand, + EditFile, + WriteFile, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSubagentToolSelector { pub source_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub canonical_host_name: Option, + pub canonical_capability: Option, pub allowed: bool, } @@ -571,14 +583,8 @@ impl ExternalSubagentDefinition { && !value.chars().any(char::is_control) }; if !valid_name(&selector.source_name) - || selector - .canonical_host_name - .as_deref() - .is_some_and(|name| !valid_name(name)) - || !tool_selectors.insert(( - selector.source_name.as_str(), - selector.canonical_host_name.as_deref(), - )) + || !tool_selectors + .insert((selector.source_name.as_str(), selector.canonical_capability)) { return Err(ExternalSourceContractError::InvalidText( "external subagent tool selector", diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs index e9f9cc074b..205c9d0040 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs @@ -42,8 +42,8 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, - ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, - SecretText, + ExternalSubagentProviderSnapshot, ExternalSubagentToolCapability, ExternalSubagentToolRequest, + ExternalSubagentToolSelector, SecretText, }; use bitfun_product_domains::tool_permissions::{ PermissionConstraintLayer, PermissionEffect, PermissionRule, @@ -400,7 +400,7 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision requested_tools: ExternalSubagentToolRequest { selectors: vec![ExternalSubagentToolSelector { source_name: "read".to_string(), - canonical_host_name: Some("Read".to_string()), + canonical_capability: Some(ExternalSubagentToolCapability::ReadFile), allowed: true, }], uses_conservative_default: false, @@ -446,7 +446,7 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision excessive_tools.requested_tools.selectors = (0..257) .map(|index| ExternalSubagentToolSelector { source_name: format!("tool-{index}"), - canonical_host_name: None, + canonical_capability: None, allowed: true, }) .collect(); 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 d09c028f63..e677483bec 100644 --- a/src/crates/services/services-integrations/src/mcp/server/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/server/mod.rs @@ -30,7 +30,9 @@ pub use runtime_policy::{ mcp_server_is_running, mcp_server_is_starting_or_running, mcp_should_start_after_config_update, MCPListChangedKind, MCPReconnectRuntimeDecision, }; -pub use runtime_state::{MCPProcessStartContext, MCPProcessStartOutcome, MCPServerRuntimeState}; +pub use runtime_state::{ + MCPProcessStartContext, MCPProcessStartOutcome, MCPServerRuntimeState, MCPServerStartFailure, +}; /// MCP server type. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 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 14f943278f..819150654f 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 @@ -11,6 +11,7 @@ use super::{ }; use crate::mcp::protocol::{MCPPrompt, MCPResource}; use log::info; +use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -37,6 +38,84 @@ pub enum MCPProcessStartContext { Remote { data_dir: PathBuf }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MCPServerStartFailure { + Authentication, + Timeout, + CommandUnavailable, + WorkingDirectoryUnavailable, + ConnectionFailed, + ProtocolFailed, + Other, +} + +impl MCPServerStartFailure { + pub fn classify(error: &str) -> Self { + let error = error.to_ascii_lowercase(); + if [ + "unauthorized", + "forbidden", + "authentication", + "oauth", + "status 401", + "status 403", + "http 401", + "http 403", + ] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::Authentication + } else if ["timed out", "timeout", "deadline elapsed"] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::Timeout + } else if [ + "working directory", + "current directory", + "directory name is invalid", + ] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::WorkingDirectoryUnavailable + } else if [ + "connection refused", + "could not connect", + "failed to connect", + "dns", + "name resolution", + ] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::ConnectionFailed + } else if ["handshake", "initialize", "protocol", "json-rpc"] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::ProtocolFailed + } else if [ + "program not found", + "executable not found", + "command not found", + "failed to start mcp server", + "os error 2", + "process error", + "failed to spawn", + "create process", + ] + .iter() + .any(|pattern| error.contains(pattern)) + { + Self::CommandUnavailable + } else { + Self::Other + } + } +} + impl MCPProcessStartContext { fn server_type(&self) -> MCPServerType { match self { @@ -60,6 +139,7 @@ pub struct MCPServerRuntimeState { connection_pool: MCPConnectionPool, reconnect_tracker: MCPReconnectTracker, catalog_cache: MCPCatalogCache, + start_failures: RwLock>, } impl MCPServerRuntimeState { @@ -69,6 +149,7 @@ impl MCPServerRuntimeState { connection_pool: MCPConnectionPool::new(), reconnect_tracker: MCPReconnectTracker::default(), catalog_cache: MCPCatalogCache::new(), + start_failures: RwLock::new(HashMap::new()), } } @@ -80,6 +161,18 @@ impl MCPServerRuntimeState { self.registry.contains(server_id).await } + pub async fn set_start_failure(&self, server_id: String, failure: MCPServerStartFailure) { + self.start_failures.write().await.insert(server_id, failure); + } + + pub async fn clear_start_failure(&self, server_id: &str) { + self.start_failures.write().await.remove(server_id); + } + + pub async fn start_failure(&self, server_id: &str) -> Option { + self.start_failures.read().await.get(server_id).copied() + } + pub async fn register(&self, config: &MCPServerConfig) -> MCPRuntimeResult<()> { self.registry.register(config).await } @@ -93,7 +186,9 @@ impl MCPServerRuntimeState { } pub async fn clear_registry(&self) -> MCPRuntimeResult<()> { - self.registry.clear().await + self.registry.clear().await?; + self.start_failures.write().await.clear(); + Ok(()) } async fn get_process(&self, server_id: &str) -> Option>> { @@ -142,6 +237,15 @@ impl MCPServerRuntimeState { MCPRuntimeError::configuration("Missing command for local MCP server") })?; let resolved = resolve_mcp_local_command(command, managed_runtimes_dir)?; + if config + .working_directory + .as_deref() + .is_some_and(|directory| !Path::new(directory).is_dir()) + { + return Err(MCPRuntimeError::configuration( + "MCP working directory is unavailable", + )); + } info!( "Starting local MCP server: command={} source={} id={}", resolved.command, resolved.source_label, config.id @@ -339,3 +443,58 @@ impl Default for MCPServerRuntimeState { Self::new() } } + +#[cfg(test)] +mod tests { + use super::{MCPServerRuntimeState, MCPServerStartFailure}; + + #[test] + fn start_failures_are_actionable_without_exposing_runtime_details() { + assert_eq!( + MCPServerStartFailure::classify( + "Failed to start MCP server 'secret-command': program not found" + ), + MCPServerStartFailure::CommandUnavailable, + ); + assert_eq!( + MCPServerStartFailure::classify("401 Unauthorized: token=secret"), + MCPServerStartFailure::Authentication, + ); + assert_eq!( + MCPServerStartFailure::classify("initialize request timed out"), + MCPServerStartFailure::Timeout, + ); + assert_eq!( + MCPServerStartFailure::classify( + "Failed to start MCP server: working directory is unavailable" + ), + MCPServerStartFailure::WorkingDirectoryUnavailable, + ); + + for failure in [ + MCPServerStartFailure::CommandUnavailable, + MCPServerStartFailure::Authentication, + MCPServerStartFailure::Timeout, + MCPServerStartFailure::Other, + ] { + let debug = format!("{failure:?}"); + assert!(!debug.contains("secret")); + assert!(!debug.contains("token=")); + } + } + + #[tokio::test] + async fn clearing_registry_also_clears_start_failures() { + let state = MCPServerRuntimeState::new(); + state + .set_start_failure( + "external:mcp".to_string(), + MCPServerStartFailure::CommandUnavailable, + ) + .await; + + state.clear_registry().await.expect("clear registry"); + + assert_eq!(state.start_failure("external:mcp").await, None); + } +} 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 c80b1ea376..d1f9002e38 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 @@ -457,6 +457,50 @@ describe('ExternalSourcesAPI', () => { }); }); + it('sends exact owner-scoped decision sets for bulk resource changes', async () => { + const toolDecisions = [{ approvalKey: 'tool-approval-v1', decisionKey: 'tool-v1' }]; + const subagentDecisions = [{ candidateId: 'agent-review', decisionKey: 'agent-v1' }]; + const mcpDecisions = [{ candidateId: 'mcp-docs', decisionKey: 'mcp-v1' }]; + + await externalSourcesAPI.setToolTargetsEnabled( + 'D:/workspace/project', toolDecisions, true, 11, 7, + ); + await externalSourcesAPI.setSubagentsEnabled( + 'D:/workspace/project', subagentDecisions, false, 12, 8, + ); + await externalSourcesAPI.setMcpServersEnabled( + 'D:/workspace/project', mcpDecisions, true, 13, 9, + ); + + expect(invokeMock).toHaveBeenCalledWith('set_external_tool_targets_enabled_command', { + request: { + workspacePath: 'D:/workspace/project', + decisions: toolDecisions, + enabled: true, + expectedCatalogGeneration: 11, + expectedPreferenceRevision: 7, + }, + }); + expect(invokeMock).toHaveBeenCalledWith('set_external_subagents_enabled_command', { + request: { + workspacePath: 'D:/workspace/project', + decisions: subagentDecisions, + enabled: false, + expectedSubagentGeneration: 12, + expectedPreferenceRevision: 8, + }, + }); + expect(invokeMock).toHaveBeenCalledWith('set_external_mcp_servers_enabled_command', { + request: { + workspacePath: 'D:/workspace/project', + decisions: mcpDecisions, + enabled: true, + expectedMcpGeneration: 13, + expectedPreferenceRevision: 9, + }, + }); + }); + it('sends policy scope and optimistic revision as one atomic mutation', async () => { const catalogUpdated = vi.fn(); const unsubscribe = globalEventBus.on('mode:config:updated', catalogUpdated); 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 05e0dd3218..d6a354a9d5 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -1549,6 +1549,24 @@ export const externalSourcesAPI = { }); }, + setToolTargetsEnabled( + workspacePath: string | undefined, + decisions: Array<{ approvalKey: string; decisionKey: string }>, + enabled: boolean, + expectedCatalogGeneration: number, + expectedPreferenceRevision: number, + ) { + return invokeSnapshot('set_external_tool_targets_enabled_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + decisions, + enabled, + expectedCatalogGeneration, + expectedPreferenceRevision, + }, + }); + }, + setToolConflictChoice( workspacePath: string | undefined, conflictKey: string, @@ -1587,6 +1605,26 @@ export const externalSourcesAPI = { return catalog; }, + async setSubagentsEnabled( + workspacePath: string | undefined, + decisions: Array<{ candidateId: string; decisionKey: string }>, + enabled: boolean, + expectedSubagentGeneration: number, + expectedPreferenceRevision: number, + ) { + const catalog = await invokeSnapshot('set_external_subagents_enabled_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + decisions, + enabled, + expectedSubagentGeneration, + expectedPreferenceRevision, + }, + }); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; + }, + async setSubagentModelBinding( workspacePath: string | undefined, bindingKey: string, @@ -1649,6 +1687,24 @@ export const externalSourcesAPI = { }); }, + setMcpServersEnabled( + workspacePath: string | undefined, + decisions: Array<{ candidateId: string; decisionKey: string }>, + enabled: boolean, + expectedMcpGeneration: number, + expectedPreferenceRevision: number, + ) { + return invokeSnapshot('set_external_mcp_servers_enabled_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + decisions, + enabled, + expectedMcpGeneration, + expectedPreferenceRevision, + }, + }); + }, + chooseMcpConflict( workspacePath: string | undefined, conflictKey: string, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts index e404e1a9f9..b6810b3ce1 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.appearance.ts @@ -12,5 +12,6 @@ export const externalMcpOverviewAppearanceDescriptor: AppearanceSurfaceDescripto { id: 'pending', selector: { kind: 'self', suffix: '[data-bf-state~="pending"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, { id: 'muted', selector: { kind: 'self', suffix: '[data-bf-state~="muted"]' } }, + { id: 'stale', selector: { kind: 'self', suffix: '[data-bf-state~="stale"]' } }, ], }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx index dcf7b23e17..aea0ab9beb 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.test.tsx @@ -705,7 +705,7 @@ describe('ExternalMcpOverview', () => { } }); - it('surfaces stale source health ahead of a misleading active status', async () => { + it('shows a neutral stale badge when an active server uses the last working content', async () => { getSnapshotMock.mockResolvedValue({ ...snapshot, sources: [{ @@ -724,8 +724,10 @@ describe('ExternalMcpOverview', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('external.status.stale'); - expect(container.textContent).toContain('external.status.active'); + const stale = container.querySelector('[data-bf-state="stale"]'); + expect(stale?.textContent).toContain('external.status.stale'); + expect(stale?.classList.contains('is-error')).toBe(false); + expect(stale?.classList.contains('is-pending')).toBe(false); }); it('surfaces degraded source diagnostics ahead of a misleading active status', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx index a65a004e85..771e88cfe5 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx @@ -383,7 +383,7 @@ const ExternalMcpOverview: React.FC = () => { {scopeLabel(sourceRecord?.scope)} {sourceStatus ? ( - + {t(`external.status.${sourceStatus}`)} ) : null} diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index a42f0550c0..2c581ae805 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -569,7 +569,6 @@ color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; - &.is-using_last_valid_version, &.is-restricted, &.is-degraded, &.is-unavailable, 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 4514f39678..349f406454 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -13,11 +13,14 @@ const setSourceEnabledMock = vi.hoisted(() => vi.fn()); const setSafeModeMock = vi.hoisted(() => vi.fn()); const setConflictChoiceMock = vi.hoisted(() => vi.fn()); const setToolTargetDecisionMock = vi.hoisted(() => vi.fn()); +const setToolTargetsEnabledMock = vi.hoisted(() => vi.fn()); const setToolConflictChoiceMock = vi.hoisted(() => vi.fn()); const setSubagentActivationMock = vi.hoisted(() => vi.fn()); +const setSubagentsEnabledMock = vi.hoisted(() => vi.fn()); const setSubagentModelBindingMock = vi.hoisted(() => vi.fn()); const chooseSubagentConflictMock = vi.hoisted(() => vi.fn()); const setMcpServerDecisionMock = vi.hoisted(() => vi.fn()); +const setMcpServersEnabledMock = vi.hoisted(() => vi.fn()); const chooseMcpConflictMock = vi.hoisted(() => vi.fn()); const updateIntegrationPolicyMock = vi.hoisted(() => vi.fn()); const revealSourceLocationMock = vi.hoisted(() => vi.fn()); @@ -75,11 +78,14 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ setSafeMode: setSafeModeMock, setConflictChoice: setConflictChoiceMock, setToolTargetDecision: setToolTargetDecisionMock, + setToolTargetsEnabled: setToolTargetsEnabledMock, setToolConflictChoice: setToolConflictChoiceMock, setSubagentActivation: setSubagentActivationMock, + setSubagentsEnabled: setSubagentsEnabledMock, setSubagentModelBinding: setSubagentModelBindingMock, chooseSubagentConflict: chooseSubagentConflictMock, setMcpServerDecision: setMcpServerDecisionMock, + setMcpServersEnabled: setMcpServersEnabledMock, chooseMcpConflict: chooseMcpConflictMock, updateIntegrationPolicy: updateIntegrationPolicyMock, revealSourceLocation: revealSourceLocationMock, @@ -262,11 +268,14 @@ describe('ExternalSourcesConfig', () => { }], }); setToolTargetDecisionMock.mockResolvedValue(snapshot); + setToolTargetsEnabledMock.mockResolvedValue(snapshot); setToolConflictChoiceMock.mockResolvedValue(snapshot); setSubagentActivationMock.mockResolvedValue(snapshot); + setSubagentsEnabledMock.mockResolvedValue(snapshot); setSubagentModelBindingMock.mockResolvedValue(snapshot); chooseSubagentConflictMock.mockResolvedValue(snapshot); setMcpServerDecisionMock.mockResolvedValue(snapshot); + setMcpServersEnabledMock.mockResolvedValue(snapshot); chooseMcpConflictMock.mockResolvedValue(snapshot); updateIntegrationPolicyMock.mockResolvedValue(snapshot); revealSourceLocationMock.mockResolvedValue(undefined); @@ -1234,6 +1243,7 @@ describe('ExternalSourcesConfig', () => { selectedCandidateId: 'native-mcp-github', }], }); + setMcpServersEnabledMock.mockResolvedValue(mcpSnapshot); await act(async () => { root.render(); @@ -1252,6 +1262,19 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('mcp.timeoutCatalog'); expect(container.textContent).toContain('mcp.timeoutExecution'); + const mcpBulkEnable = container.querySelector( + '[data-bf-bulk-capability="mcp"] button', + ); + await act(async () => mcpBulkEnable?.click()); + expect(setMcpServersEnabledMock).toHaveBeenCalledWith( + 'D:/workspace/project', + [{ candidateId: 'external-mcp-github', decisionKey: 'mcp-decision-v1' }, + { candidateId: 'external-mcp-docs', decisionKey: 'mcp-decision-v2' }], + true, + 5, + 9, + ); + const approvalCard = Array.from(container.querySelectorAll( '.bitfun-external-sources-config__tool-card', )).find((candidate) => ( @@ -1332,6 +1355,74 @@ describe('ExternalSourcesConfig', () => { ); }); + it('keeps polling through a transient read failure and beyond the old startup limit', async () => { + const server = { + candidateId: 'external-mcp-docs', + decisionKey: 'mcp-decision-v1', + definition: { + id: { + source: { providerId: 'opencode.mcp', sourceId: 'project' }, + localId: 'docs', + }, + provenance: [{ providerId: 'opencode.mcp', sourceId: 'project' }], + name: 'docs', + transport: 'local_stdio', + commandPreview: 'missing-docs-command', + argumentCount: 0, + environmentKeys: [], + environmentReferenceNames: [], + headerNames: [], + sourceEnabled: true, + behaviorVersion: 'behavior-v1', + staticStatus: { state: 'ready' }, + }, + activationState: { state: 'starting' }, + }; + const startingSnapshot = { + ...snapshot, + commandConflicts: [], + preferenceRevision: 3, + mcpGeneration: 4, + mcpServers: [server], + mcpApprovalRequests: [], + mcpConflicts: [], + }; + let acceptedPolls = 0; + getSnapshotMock + .mockResolvedValueOnce(startingSnapshot) + .mockRejectedValueOnce(new Error('temporary snapshot read failure')) + .mockImplementation(async () => { + acceptedPolls += 1; + if (acceptedPolls <= 11) return startingSnapshot; + return { + ...startingSnapshot, + mcpServers: [{ + ...server, + activationState: { + state: 'runtime_unavailable', + reason: 'provider.internal D:/private/config.json', + }, + }], + }; + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(container.textContent).toContain('mcpState.starting'); + + await act(async () => vi.advanceTimersByTimeAsync(55_250)); + + expect(getSnapshotMock).toHaveBeenCalledTimes(14); + expect(container.textContent).toContain('mcp.failureReasons.other'); + expect(container.textContent).not.toContain('D:/private/config.json'); + const details = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('common.details')); + await act(async () => details?.click()); + expect(container.textContent).toContain('mcp.runtimeUnavailableGuidance'); + }); + it('keeps remembered command, tool, and agent choices visible and changeable', async () => { const resolvedSnapshot = { ...snapshot, @@ -1850,8 +1941,10 @@ describe('ExternalSourcesConfig', () => { pendingSubagentApprovals: [], }; getSnapshotMock + .mockResolvedValueOnce(agentSnapshot) .mockResolvedValueOnce(agentSnapshot) .mockResolvedValue(activatedAgentSnapshot); + setSubagentsEnabledMock.mockResolvedValue(agentSnapshot); setSubagentActivationMock.mockResolvedValue(activatedAgentSnapshot); chooseSubagentConflictMock.mockResolvedValue({ ...agentSnapshot, @@ -1866,6 +1959,18 @@ describe('ExternalSourcesConfig', () => { await Promise.resolve(); }); + const agentBulkEnable = container.querySelector( + '[data-bf-bulk-capability="subagent"] button', + ); + await act(async () => agentBulkEnable?.click()); + expect(setSubagentsEnabledMock).toHaveBeenCalledWith( + 'D:/workspace/project', + [{ candidateId: 'external-agent-review-v1', decisionKey: 'agent-decision-v1' }], + true, + 4, + 7, + ); + const details = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('common.details')); await act(async () => details?.click()); @@ -2257,6 +2362,7 @@ describe('ExternalSourcesConfig', () => { ...approvalSnapshot, toolApprovalRequests: [], }); + setToolTargetsEnabledMock.mockResolvedValue(approvalSnapshot); await act(async () => { root.render(); @@ -2271,6 +2377,17 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('toolApprovals.workingDirectory'); expect(container.textContent).toContain('capability.file_system'); expect(container.textContent).toContain('capability.environment'); + const toolBulkEnable = container.querySelector( + '[data-bf-bulk-capability="tool"] button', + ); + await act(async () => toolBulkEnable?.click()); + expect(setToolTargetsEnabledMock).toHaveBeenCalledWith( + 'D:/workspace/project', + [{ approvalKey: 'approval-1', decisionKey: 'decision-1' }], + true, + 1, + 0, + ); const enable = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('toolApprovals.enable')); enable?.focus(); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 250ff624b7..a7750d535c 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -63,6 +63,7 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, + sourceDiagnosticCategory, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -189,29 +190,29 @@ function agentDiagnosticParams( : undefined; } -function sourceDiagnosticCategory(code: string): string { - if (code.includes('preference_read_failed')) return 'confirmationStateUnavailable'; - if (code.includes('conflict_history_write_failed')) return 'conflictHistoryUnavailable'; - if (code.includes('discovery_in_progress')) return 'checkInProgress'; - if (code.includes('timeout')) return 'checkTimedOut'; - if (code.includes('trust_required')) return 'confirmationRequired'; - if (code.includes('too_large') || code.includes('file_limit') || code.includes('bytes_limit')) { - return 'sourceTooLarge'; - } - if (code.includes('invalid') || code.includes('parse') || code.includes('definition') - || code.includes('export_missing') || code.includes('name_unsupported')) { - return 'invalidSettings'; - } - if (code.includes('unreadable') || code.includes('read_failed') - || code.includes('metadata_failed') || code.includes('directory_')) { - return 'unreadableSource'; - } - if (code.includes('projection_only') || code.includes('unsupported') - || code.includes('restricted')) { - return 'notSupported'; - } - if (code.includes('failed')) return 'checkFailed'; - return 'sourceIssue'; +const MCP_START_FAILURE_TRANSLATIONS: Record = { + 'external_mcp.start.authentication': 'mcp.failureReasons.authentication', + 'external_mcp.start.timeout': 'mcp.failureReasons.timeout', + 'external_mcp.start.command_unavailable': 'mcp.failureReasons.commandUnavailable', + 'external_mcp.start.working_directory_unavailable': 'mcp.failureReasons.workingDirectoryUnavailable', + 'external_mcp.start.connection_failed': 'mcp.failureReasons.connectionFailed', + 'external_mcp.start.protocol_failed': 'mcp.failureReasons.protocolFailed', + 'external_mcp.start.other': 'mcp.failureReasons.other', + 'external_mcp.native_configuration_unavailable': 'mcp.failureReasons.nativeConfigurationUnavailable', + 'external_mcp.runtime.host_read_only': 'mcp.failureReasons.hostReadOnly', + 'external_mcp.runtime.configuration_changed': 'mcp.failureReasons.configurationChanged', + 'external_mcp.runtime.configuration_invalid': 'mcp.failureReasons.configurationInvalid', + 'external_mcp.runtime.host_unavailable': 'mcp.failureReasons.hostUnavailable', + 'external_mcp.runtime.install_failed': 'mcp.failureReasons.installFailed', + 'external_mcp.runtime.preparation_failed': 'mcp.failureReasons.preparationFailed', + 'external_mcp.runtime.server_missing': 'mcp.failureReasons.serverMissing', + 'external_mcp.runtime.failed': 'mcp.failureReasons.runtimeFailed', + 'external_mcp.runtime.stopped': 'mcp.failureReasons.stopped', + 'external_mcp.runtime.status_unavailable': 'mcp.failureReasons.statusUnavailable', +}; + +function localizedMcpFailureReason(reason: string, t: TFunction): string { + return t(MCP_START_FAILURE_TRANSLATIONS[reason] ?? 'mcp.failureReasons.other'); } function sourceScopeLabel(scope: string, t: TFunction): string { @@ -640,6 +641,40 @@ const ExternalSourcesConfig: React.FC = ({ }; }, [loadSnapshot, snapshot?.discoveryPending]); + const startingMcpSignature = (snapshot?.mcpServers ?? []) + .filter((server) => server.activationState.state === 'starting') + .map((server) => server.candidateId) + .sort() + .join('\u001f'); + + useEffect(() => { + if (!startingMcpSignature) return undefined; + let cancelled = false; + let timer: number | undefined; + let attempt = 0; + const schedulePoll = () => { + const delay = DISCOVERY_POLL_DELAYS_MS[ + Math.min(attempt, DISCOVERY_POLL_DELAYS_MS.length - 1) + ]; + timer = window.setTimeout(async () => { + const result = await loadSnapshot(false, false); + if (cancelled) return; + const finished = result.status === 'accepted' + && !(result.snapshot?.mcpServers ?? []).some( + (server) => server.activationState.state === 'starting', + ); + if (finished) return; + attempt += 1; + schedulePoll(); + }, delay); + }; + schedulePoll(); + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [loadSnapshot, startingMcpSignature]); + const sourceGroups = useMemo( () => snapshot ? buildExternalSourcePresentationGroups(snapshot) : [], [snapshot], @@ -1008,6 +1043,83 @@ const ExternalSourcesConfig: React.FC = ({ return accepted; }, [loadSnapshot, runMutation, snapshot, t, workspacePath]); + const setToolTargetsEnabled = useCallback(async (enabled: boolean) => { + const current = snapshotRef.current; + if (!current) return; + const decisions = enabled + ? (current.toolApprovalRequests ?? []).map((request) => ({ + approvalKey: request.approvalKey, + decisionKey: request.decisionKey, + })) + : Array.from(new Map((current.tools ?? []).map((tool) => [ + tool.approvalKey, + { approvalKey: tool.approvalKey, decisionKey: tool.decisionKey }, + ])).values()); + await runMutation( + 'bulk-tools', + () => externalSourcesAPI.setToolTargetsEnabled( + workspacePath, + decisions, + enabled, + current.generation, + current.preferenceRevision ?? 0, + ), + true, + 'all', + t(enabled ? 'bulkActions.enabled' : 'bulkActions.disabled'), + 'canApproveRuntime', + ); + }, [runMutation, t, workspacePath]); + + const setSubagentsEnabled = useCallback(async (enabled: boolean) => { + const current = snapshotRef.current; + if (!current) return; + const decisions = (current.subagents ?? []) + .filter((agent) => !enabled || agent.activationState.state === 'approval_required') + .map((agent) => ({ candidateId: agent.candidateId, decisionKey: agent.decisionKey })); + const accepted = await runMutation( + 'bulk-subagents', + () => externalSourcesAPI.setSubagentsEnabled( + workspacePath, + decisions, + enabled, + current.subagentGeneration ?? 0, + current.preferenceRevision ?? 0, + ), + true, + 'subagents', + t(enabled ? 'bulkActions.enabled' : 'bulkActions.disabled'), + 'canApproveRuntime', + ); + if (accepted) await loadSnapshot(true, false); + }, [loadSnapshot, runMutation, t, workspacePath]); + + const setMcpServersEnabled = useCallback(async (enabled: boolean) => { + const current = snapshotRef.current; + if (!current) return; + const decisions = (enabled + ? current.mcpApprovalRequests ?? [] + : current.mcpServers ?? []).map((server) => ({ + candidateId: server.candidateId, + decisionKey: server.decisionKey, + })); + const accepted = await runMutation( + 'bulk-mcp', + () => externalSourcesAPI.setMcpServersEnabled( + workspacePath, + decisions, + enabled, + current.mcpGeneration ?? 0, + current.preferenceRevision ?? 0, + ), + true, + 'all', + t(enabled ? 'bulkActions.enabled' : 'bulkActions.disabled'), + 'canApproveRuntime', + ); + if (accepted) await loadSnapshot(true, false); + }, [loadSnapshot, runMutation, t, workspacePath]); + const isRemote = workspace?.workspaceKind === WorkspaceKind.Remote || Boolean(workspace?.connectionId); const policy = snapshot?.integrationPolicy; @@ -2247,12 +2359,35 @@ const ExternalSourcesConfig: React.FC = ({ ) : null} {(snapshot?.mcpServers?.length ?? 0) > 0 ? ( - + + {(snapshot?.mcpApprovalRequests?.length ?? 0) > 0 ? ( + + ) : null} + + + )} + > + {(snapshot?.mcpApprovalRequests?.length ?? 0) > 0 ? ( +
+ {t('bulkActions.enableRisk', { count: snapshot?.mcpApprovalRequests?.length ?? 0 })} +
+ ) : null} {snapshot?.mcpServers?.map((server) => { const state = server.activationState.state; const reviewing = reviewingMcpKey === server.candidateId; const canEnable = state === 'declined' || state === 'configuration_changed'; const canDisable = ['starting', 'active', 'runtime_unavailable'].includes(state); + const runtimeFailureReason = state === 'runtime_unavailable' + && 'reason' in server.activationState + ? localizedMcpFailureReason(server.activationState.reason, t) + : undefined; const source = snapshot.sources.find((candidate) => ( candidate.record.key.providerId === server.definition.id.source.providerId && candidate.record.key.sourceId === server.definition.id.source.sourceId @@ -2261,7 +2396,11 @@ const ExternalSourcesConfig: React.FC = ({
@@ -2309,7 +2448,7 @@ const ExternalSourcesConfig: React.FC = ({ })} {source ? ( <> - {t('mcp.sourceLocation')}: {renderPathLink( + {t('mcp.sourceLocationLabel')}: {renderPathLink( source.record.location, source.stableKey, )} @@ -2339,9 +2478,17 @@ const ExternalSourcesConfig: React.FC = ({ })} ) : null} {'reason' in server.activationState ? ( - {t(server.activationState.state === 'runtime_unavailable' - ? 'mcp.runtimeUnavailableGuidance' - : 'mcp.unsupportedGuidance')} + <> + {t('mcp.failureReason', { + reason: localizedMcpFailureReason( + server.activationState.reason, + t, + ), + })} + {t(server.activationState.state === 'runtime_unavailable' + ? 'mcp.runtimeUnavailableGuidance' + : 'mcp.unsupportedGuidance')} + ) : null} {t('mcp.changePolicy')}
@@ -2661,7 +2808,26 @@ const ExternalSourcesConfig: React.FC = ({ ) : null} {(snapshot?.subagents?.length ?? 0) > 0 ? ( - + + {(snapshot?.pendingSubagentApprovals?.length ?? 0) > 0 ? ( + + ) : null} + + + )} + > + {(snapshot?.pendingSubagentApprovals?.length ?? 0) > 0 ? ( +
+ {t('bulkActions.enableRisk', { count: snapshot?.pendingSubagentApprovals?.length ?? 0 })} +
+ ) : null} {snapshot?.subagents?.map((agent) => { const reviewing = reviewingAgentKey === agent.candidateId; const state = agent.activationState.state; @@ -3075,7 +3241,26 @@ const ExternalSourcesConfig: React.FC = ({ /> {(snapshot?.tools?.length ?? 0) > 0 ? ( - + + {(snapshot?.toolApprovalRequests?.length ?? 0) > 0 ? ( + + ) : null} + + + )} + > + {(snapshot?.toolApprovalRequests?.length ?? 0) > 0 ? ( +
+ {t('bulkActions.enableRisk', { count: snapshot?.toolApprovalRequests?.length ?? 0 })} +
+ ) : null} {snapshot?.tools?.map((tool) => { const toolKey = `${tool.definition.id.target.source.providerId}:${tool.definition.id.target.source.sourceId}:${tool.definition.id.target.localId}:${tool.definition.id.exportId}`; const source = snapshot.sources.find((candidate) => matchesToolSource(candidate, tool)); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.test.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.test.tsx new file mode 100644 index 0000000000..5c36c96716 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import enUS from '@/locales/en-US/settings/external-sources.json'; +import type { ExternalSourcePresentationGroup } from '../../externalSourcePresentation'; +import { ExternalSourceSection } from './ExternalSourceSection'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function translate(key: string): string { + const value = key.split('.').reduce((current, segment) => ( + current && typeof current === 'object' + ? (current as Record)[segment] + : undefined + ), enUS); + return typeof value === 'string' ? value : key; +} + +describe('ExternalSourceSection', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the localized category for an unavailable MCP runtime', async () => { + const group: ExternalSourcePresentationGroup = { + key: 'opencode:mcp', + ecosystemId: 'opencode', + scopes: ['user_global'], + displayName: 'OpenCode MCP', + location: 'opencode.json', + lifecycle: 'degraded', + members: [], + counts: { commands: 0, tools: 0, agents: 0, mcps: 1 }, + diagnostics: [{ + severity: 'warning', + code: 'external_mcp.runtime_unavailable', + message: 'external_mcp.runtime.failed', + }], + }; + + await act(async () => { + root.render( + null} + />, + ); + }); + + expect(container.textContent).toContain(enUS.diagnostics.category.runtimeUnavailable); + expect(container.textContent).not.toContain('diagnostics.category.runtimeUnavailable'); + expect(container.textContent).not.toContain('external_mcp.runtime.failed'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx index b0ada96993..d396416ca7 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx @@ -4,7 +4,12 @@ import { externalSourceDiagnosticKey, type ExternalSourcePresentationGroup, } from '../../externalSourcePresentation'; -import { SOURCE_COUNT_LABELS, sourceDiagnosticCategory, sourceScopeLabel } from './presentation'; +import { + SOURCE_COUNT_LABELS, + isActionableSourceDiagnostic, + sourceDiagnosticCategory, + sourceScopeLabel, +} from './presentation'; import type { ExternalSectionCommonProps } from './types'; export interface ExternalSourceSectionProps @@ -28,6 +33,7 @@ export const ExternalSourceSection: React.FC = ({ return ( {groups.map((group) => { + const userDiagnostics = group.diagnostics.filter(isActionableSourceDiagnostic); return ( = ({ > {renderSourceMembers(group)} - {group.diagnostics.length > 0 ? ( + {userDiagnostics.length > 0 ? (
= ({ {t('diagnostics.sourceSummary', { name: group.displayName, - count: group.diagnostics.length, + count: userDiagnostics.length, })}
    - {group.diagnostics.map((diagnostic) => ( + {userDiagnostics.map((diagnostic) => (
  • {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)}
  • diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts index 23314ea833..11fd59902a 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -2,6 +2,7 @@ export type { ExternalHostCapabilities, ExternalSectionCommonProps } from './typ export { SOURCE_COUNT_LABELS, abbreviatedLocation, + isActionableSourceDiagnostic, sourceDiagnosticCategory, sourceScopeLabel, } from './presentation'; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/presentation.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.test.ts new file mode 100644 index 0000000000..d4f6bbf835 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + isActionableSourceDiagnostic, + sourceDiagnosticCategory, +} from './presentation'; + +describe('external source diagnostic presentation', () => { + it('keeps unknown warnings visible through the safe generic category', () => { + expect(isActionableSourceDiagnostic({ + severity: 'warning', + code: 'future_provider.unknown_condition', + message: 'Opaque provider detail.', + })).toBe(true); + expect(sourceDiagnosticCategory('future_provider.unknown_condition')) + .toBe('sourceIssue'); + }); + + it('hides informational diagnostics that do not require user action', () => { + expect(isActionableSourceDiagnostic({ + severity: 'info', + code: 'future_provider.discovered', + message: 'Discovery completed.', + })).toBe(false); + }); + + it('gives an unavailable MCP runtime a concrete user category', () => { + expect(sourceDiagnosticCategory('external_mcp.runtime_unavailable')) + .toBe('runtimeUnavailable'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts index f9a80f033c..3feeddedbe 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts @@ -39,11 +39,16 @@ export const SOURCE_COUNT_LABELS = [ ['mcps', 'sources.mcpCount'], ] as const; +type SourceDiagnostic = NonNullable< + ExternalSourceCatalogSnapshot['diagnostics'] +>[number]; + /** * Maps a raw diagnostic code to a user-facing category. Codes stay in the * collapsed technical details; the category is what the user reads first. */ export function sourceDiagnosticCategory(code: string): string { + if (code.includes('external_mcp.runtime_unavailable')) return 'runtimeUnavailable'; if (code.includes('preference_read_failed')) return 'confirmationStateUnavailable'; if (code.includes('conflict_history_write_failed')) return 'conflictHistoryUnavailable'; if (code.includes('discovery_in_progress')) return 'checkInProgress'; @@ -67,3 +72,7 @@ export function sourceDiagnosticCategory(code: string): string { if (code.includes('failed')) return 'checkFailed'; return 'sourceIssue'; } + +export function isActionableSourceDiagnostic(diagnostic: SourceDiagnostic): boolean { + return diagnostic.severity.toLowerCase() !== 'info'; +} 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 671d0bea54..48d7a64b0a 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 @@ -121,6 +121,13 @@ "modelBindingUpdated": "Saved the agent model binding and refreshed affected agents.", "mcpUpdated": "Saved the MCP server choice and refreshed its status." }, + "bulkActions": { + "enablePending": "Enable all pending ({{count}})", + "disableAll": "Disable all", + "enableRisk": "This approves exactly the {{count}} items currently shown. If discovery changes, BitFun will ask you to review again.", + "enabled": "Enabled the selected pending items.", + "disabled": "Disabled the selected items." + }, "recoveryActions": { "refresh": "Refresh status", "retry": "Retry", @@ -238,7 +245,8 @@ "unreadableSource": "BitFun could not read part of a source. Check file access, then refresh.", "notSupported": "This type of external content is not supported yet, so BitFun did not load or run it.", "checkFailed": "BitFun could not check one source. Other sources remain available; refresh to retry.", - "sourceIssue": "BitFun found an issue in one source. The affected content was not enabled. Check the source configuration, then refresh." + "runtimeUnavailable": "An MCP server from this source is unavailable. Disable it, fix its configuration or authentication, then enable it again.", + "sourceIssue": "BitFun could not finish checking one source. Existing usable content remains available; refresh or review the technical details." } }, "sources": { @@ -414,6 +422,7 @@ "externalSource": "External AI application", "source": "Source: {{source}}", "sourceLocation": "Configuration: {{location}}", + "sourceLocationLabel": "Configuration", "scope": "Applies to: {{scope}}", "command": "Command: {{command}}", "url": "Address: {{url}}", @@ -430,6 +439,27 @@ "timeoutExecution": "execution {{duration}}", "timeoutMilliseconds": "{{value}} ms", "changePolicy": "BitFun asks again before loading a changed server configuration.", + "failureReason": "Reason: {{reason}}", + "failureReasons": { + "authentication": "Authentication is required for this MCP server.", + "timeout": "The MCP server did not finish starting before its timeout.", + "commandUnavailable": "The configured MCP command could not be started.", + "workingDirectoryUnavailable": "The configured MCP working directory is unavailable.", + "connectionFailed": "BitFun could not connect to the MCP server.", + "protocolFailed": "The MCP server did not complete the MCP startup handshake.", + "nativeConfigurationUnavailable": "BitFun could not read the current MCP configuration.", + "hostReadOnly": "This host can discover MCP servers but cannot run them.", + "configurationChanged": "The MCP configuration changed before activation. Refresh and confirm it again.", + "configurationInvalid": "The MCP configuration cannot be used by the BitFun runtime.", + "hostUnavailable": "The MCP runtime is not available in this host.", + "installFailed": "BitFun could not register this MCP server with the runtime.", + "preparationFailed": "BitFun could not prepare this MCP configuration for activation.", + "serverMissing": "The MCP server is no longer registered with the runtime.", + "runtimeFailed": "The MCP server failed or stopped unexpectedly.", + "stopped": "The MCP server is not currently running.", + "statusUnavailable": "BitFun could not read the current MCP server status.", + "other": "The MCP server failed to start. Check its source configuration and BitFun logs." + }, "runtimeUnavailableGuidance": "Disable this server, fix the source configuration or authentication, then enable it again. Restarting BitFun is usually not required.", "unsupportedGuidance": "Change this setting in the source application, then refresh.", "enable": "Enable server", 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 8a120a879e..d7bc1ddfb6 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 @@ -121,6 +121,13 @@ "modelBindingUpdated": "已保存 Agent 模型绑定并刷新受影响的 Agent。", "mcpUpdated": "已保存 MCP 服务器选择并刷新状态。" }, + "bulkActions": { + "enablePending": "启用全部待确认项({{count}})", + "disableAll": "全部停用", + "enableRisk": "仅批准当前显示的 {{count}} 项;如果发现结果发生变化,BitFun 会要求重新确认。", + "enabled": "已启用选中的待确认项。", + "disabled": "已停用选中的项目。" + }, "recoveryActions": { "refresh": "刷新状态", "retry": "重试", @@ -238,7 +245,8 @@ "unreadableSource": "BitFun 无法读取来源中的部分内容。请检查文件访问权限后刷新。", "notSupported": "BitFun 暂不支持此类外部内容,因此没有加载或运行它。", "checkFailed": "BitFun 未能检查一个来源,其他来源仍可使用。请刷新重试。", - "sourceIssue": "BitFun 在一个来源中发现问题,受影响的内容未启用。请检查来源配置后刷新。" + "runtimeUnavailable": "此来源中的一个 MCP 服务器不可用。请先停用,修复配置或认证后再启用。", + "sourceIssue": "BitFun 未能完成一个来源的检查;现有可用内容保持不变。你可以刷新或查看技术详情。" } }, "sources": { @@ -414,6 +422,7 @@ "externalSource": "外部 AI 应用", "source": "来源:{{source}}", "sourceLocation": "配置位置:{{location}}", + "sourceLocationLabel": "配置位置", "scope": "作用范围:{{scope}}", "command": "命令:{{command}}", "url": "地址:{{url}}", @@ -430,6 +439,27 @@ "timeoutExecution": "执行 {{duration}}", "timeoutMilliseconds": "{{value}} 毫秒", "changePolicy": "服务器配置发生变化后,BitFun 会在再次加载前请你确认。", + "failureReason": "原因:{{reason}}", + "failureReasons": { + "authentication": "此 MCP 服务器需要完成身份认证。", + "timeout": "MCP 服务器未能在启动超时前就绪。", + "commandUnavailable": "无法启动配置的 MCP 命令,请检查命令或运行环境。", + "workingDirectoryUnavailable": "配置的 MCP 工作目录不可用。", + "connectionFailed": "BitFun 无法连接到此 MCP 服务器。", + "protocolFailed": "MCP 服务器未能完成启动握手。", + "nativeConfigurationUnavailable": "BitFun 无法读取当前 MCP 配置。", + "hostReadOnly": "当前主机只能发现 MCP 服务器,不能运行它们。", + "configurationChanged": "MCP 配置在启用前发生了变化,请刷新后重新确认。", + "configurationInvalid": "当前 MCP 配置无法用于 BitFun 运行时。", + "hostUnavailable": "当前主机没有可用的 MCP 运行时。", + "installFailed": "BitFun 无法将此 MCP 服务器注册到运行时。", + "preparationFailed": "BitFun 无法准备此 MCP 配置以供启用。", + "serverMissing": "此 MCP 服务器已不在运行时中。", + "runtimeFailed": "MCP 服务器运行失败或意外停止。", + "stopped": "MCP 服务器当前未运行。", + "statusUnavailable": "BitFun 无法读取 MCP 服务器的当前状态。", + "other": "MCP 服务器启动失败,请检查来源配置和 BitFun 日志。" + }, "runtimeUnavailableGuidance": "请先停用此服务器,修复来源配置或认证后再启用,通常无需重启 BitFun。", "unsupportedGuidance": "请在来源应用中修改此配置,然后刷新。", "enable": "启用服务器", 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 86725b02d1..7a386fbba8 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 @@ -121,6 +121,13 @@ "modelBindingUpdated": "已儲存 Agent 模型綁定並重新整理受影響的 Agent。", "mcpUpdated": "已儲存 MCP 伺服器選擇並重新整理狀態。" }, + "bulkActions": { + "enablePending": "啟用全部待確認項({{count}})", + "disableAll": "全部停用", + "enableRisk": "僅核准目前顯示的 {{count}} 項;如果探索結果發生變化,BitFun 會要求重新確認。", + "enabled": "已啟用選取的待確認項。", + "disabled": "已停用選取的項目。" + }, "recoveryActions": { "refresh": "重新整理狀態", "retry": "重試", @@ -238,7 +245,8 @@ "unreadableSource": "BitFun 無法讀取來源中的部分內容。請檢查檔案存取權限後重新整理。", "notSupported": "BitFun 暫不支援此類外部內容,因此沒有載入或執行它。", "checkFailed": "BitFun 未能檢查一個來源,其他來源仍可使用。請重新整理後重試。", - "sourceIssue": "BitFun 在一個來源中發現問題,受影響的內容未啟用。請檢查來源設定後重新整理。" + "runtimeUnavailable": "此來源中的一個 MCP 伺服器無法使用。請先停用,修正設定或驗證後再啟用。", + "sourceIssue": "BitFun 未能完成一個來源的檢查;現有可用內容保持不變。你可以重新整理或查看技術詳細資料。" } }, "sources": { @@ -414,6 +422,7 @@ "externalSource": "外部 AI 應用程式", "source": "來源:{{source}}", "sourceLocation": "設定位置:{{location}}", + "sourceLocationLabel": "設定位置", "scope": "適用範圍:{{scope}}", "command": "命令:{{command}}", "url": "位址:{{url}}", @@ -430,6 +439,27 @@ "timeoutExecution": "執行 {{duration}}", "timeoutMilliseconds": "{{value}} 毫秒", "changePolicy": "伺服器設定變更後,BitFun 會在再次載入前請你確認。", + "failureReason": "原因:{{reason}}", + "failureReasons": { + "authentication": "此 MCP 伺服器需要完成身分驗證。", + "timeout": "MCP 伺服器未能在啟動逾時前就緒。", + "commandUnavailable": "無法啟動設定的 MCP 命令,請檢查命令或執行環境。", + "workingDirectoryUnavailable": "設定的 MCP 工作目錄無法使用。", + "connectionFailed": "BitFun 無法連線至此 MCP 伺服器。", + "protocolFailed": "MCP 伺服器未能完成啟動交握。", + "nativeConfigurationUnavailable": "BitFun 無法讀取目前的 MCP 設定。", + "hostReadOnly": "目前的主機只能發現 MCP 伺服器,無法執行它們。", + "configurationChanged": "MCP 設定在啟用前發生變更,請重新整理後再次確認。", + "configurationInvalid": "目前的 MCP 設定無法用於 BitFun 執行階段。", + "hostUnavailable": "目前的主機沒有可用的 MCP 執行階段。", + "installFailed": "BitFun 無法將此 MCP 伺服器註冊至執行階段。", + "preparationFailed": "BitFun 無法準備此 MCP 設定以供啟用。", + "serverMissing": "此 MCP 伺服器已不在執行階段中。", + "runtimeFailed": "MCP 伺服器執行失敗或意外停止。", + "stopped": "MCP 伺服器目前未執行。", + "statusUnavailable": "BitFun 無法讀取 MCP 伺服器的目前狀態。", + "other": "MCP 伺服器啟動失敗,請檢查來源設定和 BitFun 日誌。" + }, "runtimeUnavailableGuidance": "請先停用此伺服器,修正來源設定或驗證後再啟用,通常不需要重新啟動 BitFun。", "unsupportedGuidance": "請在來源應用程式中修改此設定,然後重新整理。", "enable": "啟用伺服器",