diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index ea433bd65..84c3f726c 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -61,6 +61,7 @@ pub struct Host { pub mcp_reports: mcp::Reports, pub mcp_requests: mcp::Requests, pub trust: trust::TrustStore, + pub(crate) mcp_approvals: Arc, pub config_dir: PathBuf, /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, @@ -141,6 +142,7 @@ impl Host { mcp_reports: Default::default(), mcp_requests: Default::default(), trust: trust::TrustStore::load(&config_dir, &data_dir), + mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)), proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), proxy_session_password: Mutex::default(), agent_secrets: Arc::new(agent_secrets::AgentSecrets::new(&data_dir)), diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index da42e0dff..04eece66c 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -381,7 +381,7 @@ fn persist_mcp_map( Ok(()) } -fn update_mcp_maps(host: &Host, window: &str, f: F) -> IdeResult> +fn update_mcp_maps(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult> where F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>, { @@ -390,43 +390,83 @@ where let prev_workspace = workspace.clone(); let project_root = project.as_ref().map(|p| p.root.as_path()); f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?; + if let (Some(name), Some(p)) = (approve, &project) + && let Some(server) = workspace.get(name) + { + host.mcp_approvals.approve(&p.root, name, server)?; + } if workspace != prev_workspace { persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?; } if user != prev_user { persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?; } - Ok(ide_agent::mcp::servers_from_settings(&user, &workspace)) + Ok(listed(host, project.as_ref(), &user, workspace)) +} + +/// Workspace servers the user hasn't turned on in Settings are listed as off: agents don't start them. +fn listed( + host: &Host, + project: Option<&std::sync::Arc>, + user: &McpServerMap, + workspace: McpServerMap, +) -> Vec { + let workspace = match project { + Some(p) => host.mcp_approvals.apply(&p.root, workspace), + None => workspace, + }; + ide_agent::mcp::servers_from_settings(user, &workspace) +} + +/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the +/// user turned on. +pub(crate) fn session_mcp_servers( + settings: &ide_workspace::SettingsStore, + approvals: &crate::trust::McpServerApprovals, + root: &std::path::Path, +) -> Vec { + use ide_workspace::settings::SettingsScope; + let user = settings.layer(SettingsScope::Global).agents.mcp_servers; + let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers); + ide_agent::mcp::servers_from_settings(&user, &workspace) } /// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes. pub fn mcp_list(host: &Host, window: &str) -> IdeResult> { - let (user, workspace, _) = mcp_maps(host, window); - Ok(ide_agent::mcp::servers_from_settings(&user, &workspace)) + let (user, workspace, project) = mcp_maps(host, window); + Ok(listed(host, project.as_ref(), &user, workspace)) } -/// Creates or updates a User or Workspace MCP server and returns the updated list. +/// Creates or updates a User or Workspace MCP server and returns the updated list. A Workspace +/// server saved here is one the user turned on. pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, project_root| { + let approve = (input.scope == McpScope::Workspace).then(|| input.name.trim().to_owned()); + update_mcp_maps(host, window, approve.as_deref(), |user, workspace, project_root| { ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input) }) } /// Enables or disables an MCP server and returns the updated list. pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, _| { + let approve = ide_agent::mcp::parse_server_id(&id) + .filter(|(scope, _)| enabled && *scope == McpScope::Workspace) + .map(|(_, name)| name.to_owned()); + update_mcp_maps(host, window, approve.as_deref(), |user, workspace, _| { ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ()) }) } /// Removes a User or Workspace MCP server and returns the updated list. pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ())) + update_mcp_maps(host, window, None, |user, workspace, _| { + ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()) + }) } -/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once. +/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once; +/// they stay off until the user turns them on. pub fn mcp_import(host: &Host, window: &str) -> IdeResult> { - update_mcp_maps(host, window, |_, workspace, project_root| { + update_mcp_maps(host, window, None, |_, workspace, project_root| { let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?; ide_agent::mcp::apply_import(workspace, root); Ok(()) @@ -550,4 +590,43 @@ mod tests { assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}"); } } + + #[test] + fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() { + let tmp = tempfile::tempdir().unwrap(); + let (config, root) = (tmp.path().join("config"), tmp.path().join("project")); + std::fs::create_dir_all(&config).unwrap(); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap(); + std::fs::write( + root.join(".openidea/settings.toml"), + "[agents.mcpServers.shared]\ncommand = \"project-cmd\"\n\n[agents.mcpServers.extra]\ncommand = \"extra\"\n", + ) + .unwrap(); + let settings = ide_workspace::SettingsStore::load(&config, Some(&root)); + let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data")); + let on = |servers: Vec| -> Vec<(McpScope, String, String)> { + servers + .into_iter() + .filter(|s| s.enabled) + .map(|s| (s.scope, s.name, s.command.unwrap_or_default())) + .collect() + }; + assert_eq!( + on(session_mcp_servers(&settings, &approvals, &root)), + [(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())] + ); + + let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone(); + approvals.approve(&root, "extra", &extra).unwrap(); + assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&( + McpScope::Workspace, + "extra".to_owned(), + "extra".to_owned() + ))); + // Remembered across restarts, and only for this project. + let reloaded = crate::trust::McpServerApprovals::load(&tmp.path().join("data")); + assert!(reloaded.approved(&root, "extra", &extra)); + assert!(!reloaded.approved(&tmp.path().join("other"), "extra", &extra)); + } } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index fa89d0579..c57b0646d 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -670,8 +670,10 @@ impl Host { self.runtime.clone(), )); let settings = settings.clone(); + let approvals = self.mcp_approvals.clone(); + let root = root.to_path_buf(); agents.set_mcp_servers_hook(Box::new(move || { - ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers) + crate::ops::agent::session_mcp_servers(&settings, &approvals, &root) })); (db, http_history, agents) } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 0017160e3..1bc4699d3 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -142,6 +142,61 @@ fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> { Ok(()) } +type McpServers = std::collections::BTreeMap; + +/// Workspace MCP servers (a project's `agents.mcpServers`) the user turned on in Settings, by a +/// fingerprint of the project and the server's definition. Agents start a workspace server only +/// while its definition is one the user turned on, so a server added or changed in the project's +/// file stays off until then. +pub struct McpServerApprovals { + file: PathBuf, + approved: Mutex>, +} + +impl McpServerApprovals { + pub fn load(data: &Path) -> Self { + let file = data.join("mcp-server-approvals.json"); + let approved = std::fs::read(&file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + Self { file, approved: Mutex::new(approved) } + } + + fn fingerprint(root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> String { + // Turning a server off and on again doesn't change what it runs. + let server = ide_core::settings::AgentMcpServer { enabled: true, ..server.clone() }; + let bytes = serde_json::to_vec(&(root, name, server)).expect("MCP server serializes"); + blake3::hash(&bytes).to_hex().to_string() + } + + pub fn approved(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> bool { + self.approved.lock().contains(&Self::fingerprint(root, name, server)) + } + + pub fn approve(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> IdeResult<()> { + let fingerprint = Self::fingerprint(root, name, server); + let mut approved = self.approved.lock(); + if approved.contains(&fingerprint) { + return Ok(()); + } + let mut next = approved.clone(); + next.insert(fingerprint); + if let Some(parent) = self.file.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(&next).map_err(|e| IdeError::internal(e.to_string()))?; + ide_vfs::write_atomic(&self.file, &bytes)?; + *approved = next; + Ok(()) + } + + /// `workspace` with the servers the user hasn't turned on switched off. + pub fn apply(&self, root: &Path, mut workspace: McpServers) -> McpServers { + for (name, server) in &mut workspace { + server.enabled &= self.approved(root, name, server); + } + workspace + } +} + impl crate::Host { pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { self.trust.decide(path, trusted, folder)?; diff --git a/crates/ide-host/tests/agent_mcp.rs b/crates/ide-host/tests/agent_mcp.rs new file mode 100644 index 000000000..30adfcda9 --- /dev/null +++ b/crates/ide-host/tests/agent_mcp.rs @@ -0,0 +1,77 @@ +//! External MCP servers in Settings › Agents, as the host stores and lists them. + +use ide_agent::{McpScope, McpServer, McpServerInput}; +use ide_core::NullSink; +use ide_core::settings::McpTransport; +use ide_host::ops::agent::{mcp_import, mcp_list, mcp_save, mcp_set_enabled}; +use ide_host::{Host, HostConfig}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +fn open(base: &Path) -> (Arc, PathBuf) { + let root = dunce::canonicalize(base).unwrap().join("project"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + (host, root) +} + +fn enabled(list: &[McpServer], id: &str) -> bool { + list.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} not listed: {list:?}")).enabled +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_servers_from_the_project_file_stay_off_until_the_user_turns_them_on() { + let tmp = tempfile::tempdir().unwrap(); + let (host, root) = open(tmp.path()); + let settings_file = root.join(".openidea/settings.toml"); + std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"helper.js\"]\n").unwrap(); + host.project("main").unwrap().settings.reload(); + + assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + assert!(enabled(&mcp_set_enabled(&host, "main", "workspace::helper".into(), true).unwrap(), "workspace::helper")); + assert!(enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + + // A changed definition is off again until turned back on. + std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"other.js\"]\n").unwrap(); + host.project("main").unwrap().settings.reload(); + assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + + // What the user adds in Settings is on. + let saved = mcp_save( + &host, + "main", + McpServerInput { + id: None, + name: "mine".into(), + scope: McpScope::Workspace, + transport: McpTransport::Stdio, + enabled: true, + command: Some("uvx".into()), + args: vec!["mcp-server-git".into()], + env: vec![], + url: None, + headers: vec![], + agents: vec![], + }, + ) + .unwrap(); + assert!(enabled(&saved, "workspace::mine")); + + // Imported servers come from the project's own files: off until turned on. + std::fs::write(root.join(".mcp.json"), r#"{"mcpServers":{"imported":{"command":"uvx","args":["x"]}}}"#).unwrap(); + let imported = mcp_import(&host, "main").unwrap(); + assert!(!enabled(&imported, "workspace::imported")); + assert!(enabled(&imported, "workspace::mine")); +} diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 531ac1c6f..7d1168328 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -27,12 +27,44 @@ pub enum SettingsScope { Project, } -fn remove_global_settings(layer: &mut Json) { - if let Some(layer) = layer.as_object_mut() { - layer.remove("mcpServer"); +/// Settings a project's own file can't hold: the IDE's MCP server, imported color schemes, and +/// what agents may do without asking (commands run without asking, the modes new tasks start in). +const USER_ONLY: [&[&str]; 4] = + [&["mcpServer"], &["editor", "customSchemes"], &["agentSecurity", "allowedCommands"], &["agents", "defaults"]]; + +/// The [`USER_ONLY`] setting that writing `value` at `key` would set, as a dotted key. +fn user_only_setting(key: &str, value: &Json) -> Option { + let segments: Vec<&str> = key.split('.').collect(); + USER_ONLY + .iter() + .find(|path| match path.split_at_checked(segments.len()) { + Some((head, rest)) => head == segments && rest.iter().try_fold(value, |v, seg| v.get(seg)).is_some(), + None => segments.starts_with(path), + }) + .map(|path| path.join(".")) +} + +/// Applies a project layer's limits before it merges onto `below`: [`USER_ONLY`] settings are +/// dropped, and its blocked agent commands add to the ones below instead of replacing them. +fn restrict_project_layer(below: &Json, layer: &mut Json) { + for path in USER_ONLY { + let (last, parents) = path.split_last().expect("non-empty path"); + if let Some(table) = parents.iter().try_fold(&mut *layer, |v, seg| v.get_mut(seg)).and_then(Json::as_object_mut) + { + table.remove(*last); + } } - if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) { - editor.remove("customSchemes"); + if let (Some(inherited), Some(blocked)) = ( + below.pointer("/agentSecurity/blockedCommands").and_then(Json::as_array), + layer.pointer_mut("/agentSecurity/blockedCommands").and_then(Json::as_array_mut), + ) { + let mut all = inherited.clone(); + for pattern in blocked.drain(..) { + if !all.contains(&pattern) { + all.push(pattern); + } + } + *blocked = all; } } @@ -224,7 +256,7 @@ impl SettingsStore { for (scope, file) in self.layers() { let Some(mut layer) = read_layer(&file) else { continue }; if scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); @@ -254,7 +286,7 @@ impl SettingsStore { && let Some(mut layer) = read_layer(file) { if scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); @@ -268,14 +300,9 @@ impl SettingsStore { /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { if scope == SettingsScope::Project - && (key == "editor.customSchemes" - || key.starts_with("editor.customSchemes.") - || (key == "editor" && value.get("customSchemes").is_some())) + && let Some(setting) = user_only_setting(key, &value) { - return Err(IdeError::invalid("custom color schemes belong to user settings")); - } - if scope == SettingsScope::Project && (key == "mcpServer" || key.starts_with("mcpServer.")) { - return Err(IdeError::invalid("MCP server belongs to user settings")); + return Err(IdeError::invalid(format!("{setting} belongs to user settings"))); } let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf(); let segments: Vec<&str> = key.split('.').collect(); @@ -316,7 +343,7 @@ impl SettingsStore { let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) }; let Some(mut layer) = layer else { continue }; if layer_scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) && is_target @@ -1030,4 +1057,63 @@ mod tests { .enabled ); } + + #[test] + fn a_project_file_can_only_add_to_the_agent_policy() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + let project = tmp.path().join("project"); + std::fs::create_dir_all(project.join(".openidea")).unwrap(); + std::fs::create_dir_all(&config).unwrap(); + std::fs::write( + config.join("settings.toml"), + "[agentSecurity]\nallowedCommands = [\"git status\"]\nblockedCommands = [\"git push*\"]\n\n\ + [agents.defaults.claude]\nmodel = \"opus\"\n", + ) + .unwrap(); + let project_file = project.join(".openidea/settings.toml"); + std::fs::write( + &project_file, + "[agentSecurity]\nallowedCommands = [\"node\"]\nblockedCommands = [\"npm publish*\"]\n\n\ + [agents.defaults.claude]\nmode = \"bypassPermissions\"\n\n\ + [agents.defaults.codex]\nmode = \"full-access\"\n", + ) + .unwrap(); + let store = SettingsStore::load(&config, Some(&project)); + let s = store.effective(); + assert_eq!(s.agent_security.allowed_commands, ["git status"]); + assert_eq!(s.agent_security.blocked_commands, ["git push*", "npm publish*"]); + assert_eq!( + s.agents.defaults, + BTreeMap::from([("claude".to_owned(), BTreeMap::from([("model".to_owned(), "opus".to_owned())]))]) + ); + let layer = store.layer(SettingsScope::Project); + assert!(layer.agent_security.allowed_commands.is_empty()); + assert!(layer.agents.defaults.is_empty()); + + // An empty project list doesn't clear the user's, nor the built-in one. + std::fs::write(&project_file, "[agentSecurity]\nblockedCommands = []\n").unwrap(); + assert_eq!(store.reload().agent_security.blocked_commands, ["git push*"]); + std::fs::remove_file(config.join("settings.toml")).unwrap(); + assert_eq!( + store.reload().agent_security.blocked_commands, + ide_core::settings::AgentSecuritySettings::default().blocked_commands + ); + + for (key, value) in [ + ("agentSecurity.allowedCommands", json!(["node"])), + ("agentSecurity", json!({"allowedCommands": ["node"], "blockedCommands": []})), + ("agents.defaults", json!({"claude": {"mode": "bypassPermissions"}})), + ("agents.defaults.claude", json!({"mode": "bypassPermissions"})), + ("agents.defaults.claude.mode", json!("bypassPermissions")), + ("agents", json!({"defaults": {"codex": {"mode": "full-access"}}})), + ] { + assert!(store.set(SettingsScope::Project, key, value).is_err(), "{key}"); + } + let s = store.set(SettingsScope::Project, "agentSecurity.blockedCommands", json!(["curl*"])).unwrap(); + assert!(s.agent_security.blocked_commands.ends_with(&["curl*".to_owned()])); + assert!(s.agent_security.blocked_commands.contains(&"rm -rf /".to_owned())); + let s = store.set(SettingsScope::Project, "agents.showCost", json!(true)).unwrap(); + assert!(s.agents.show_cost); + } } diff --git a/docs/en/reference/settings.mdx b/docs/en/reference/settings.mdx index 86a00cc90..bd42c9abb 100644 --- a/docs/en/reference/settings.mdx +++ b/docs/en/reference/settings.mdx @@ -20,6 +20,8 @@ A file holds only the values that differ from the layer below. Tables merge key Some settings are ignored in the project file: `mcpServer` and imported color schemes (`editor.customSchemes`) are global only. Project trust is never read from settings. +A project file can't loosen what coding agents may do: `agentSecurity.allowedCommands` and `agents.defaults` are global only, and `agentSecurity.blockedCommands` in a project adds to the global list rather than replacing it. An MCP server in the project's `agents.mcpServers` stays off until you turn it on in **Settings › Agents › External MCP Servers**, and again after its definition changes. + ## File format The files are TOML. Keys use the camelCase names shown below; sections group them: diff --git a/docs/zh/reference/settings.mdx b/docs/zh/reference/settings.mdx index e605ca1c5..3105de81a 100644 --- a/docs/zh/reference/settings.mdx +++ b/docs/zh/reference/settings.mdx @@ -20,6 +20,8 @@ description: "OpenIDEA 的设置保存在哪里、全局文件与项目文件如 有些设置在项目文件中会被忽略:`mcpServer` 和导入的配色方案(`editor.customSchemes`)只能全局配置。项目信任状态永远不会从设置文件中读取。 +项目文件不能放宽编码智能体的权限:`agentSecurity.allowedCommands` 和 `agents.defaults` 只能全局配置;项目中的 `agentSecurity.blockedCommands` 会追加到全局列表中,而不是替换它。项目 `agents.mcpServers` 中的 MCP 服务器会保持关闭,直到你在 **设置 › 智能体 › 外部 MCP 服务器** 中将其开启;服务器定义改变后也需要重新开启。 + ## 文件格式 文件为 TOML 格式。键名使用下文列出的 camelCase 名称,并按节(section)分组: diff --git a/src/features/agents/McpServers.module.css b/src/features/agents/McpServers.module.css index 1ad3a7eec..837403618 100644 --- a/src/features/agents/McpServers.module.css +++ b/src/features/agents/McpServers.module.css @@ -149,6 +149,14 @@ font-size: var(--fs-small); } +.sectionNote { + margin: 0; + padding: 2px 24px 6px; + color: var(--fg-muted); + font-size: var(--fs-small); + line-height: 1.4; +} + .row { display: flex; align-items: center; diff --git a/src/features/agents/McpServersPage.tsx b/src/features/agents/McpServersPage.tsx index aea46d10f..f2bc31a4f 100644 --- a/src/features/agents/McpServersPage.tsx +++ b/src/features/agents/McpServersPage.tsx @@ -324,6 +324,14 @@ export function McpServersPage({ backend = defaultMcpBackend }: { backend?: McpB /> + {!isCollapsed && scope === "workspace" && ( +

+ + Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here. + +

+ )} + {!isCollapsed && items.length > 0 && (
{items.map((server) => { diff --git a/src/features/agents/agentPolicy.test.tsx b/src/features/agents/agentPolicy.test.tsx index ef15f738a..5961467f4 100644 --- a/src/features/agents/agentPolicy.test.tsx +++ b/src/features/agents/agentPolicy.test.tsx @@ -41,7 +41,8 @@ describe("Command Execution Policy sub-page", () => { expect(agentsGroup).toBeDefined(); const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE); expect(policyNode?.title).toBe("Command Execution Policy"); - expect(policyNode?.page?.projectScope).toBe(true); + // A project's settings file can't decide what agents run without asking. + expect(policyNode?.page?.projectScope).not.toBe(true); } finally { dispose(); } diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 2edc6d359..6a9abf113 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -63,7 +63,6 @@ export function activateSettingsPages(): () => void { group: AGENTS_GROUP, order: 490, keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"], - projectScope: true, component: lazyComponent(() => import("./AgentPolicyPage").then((m) => m.AgentPolicyPage)), }), ]; diff --git a/src/features/agents/mcpServers.test.tsx b/src/features/agents/mcpServers.test.tsx index 2988ec17c..3001360e9 100644 --- a/src/features/agents/mcpServers.test.tsx +++ b/src/features/agents/mcpServers.test.tsx @@ -96,6 +96,8 @@ describe("McpServersPage", () => { expect(screen.getByText("HTTP · https://example.com/mcp (claude)")).toBeInTheDocument(); expect(screen.getByText("azure")).toBeInTheDocument(); expect(screen.getByText("npx -y @azure/mcp@latest server start")).toBeInTheDocument(); + const workspace = screen.getByRole("region", { name: "Workspace" }); + expect(within(workspace).getByText(/stay off until you turn them on here/)).toBeInTheDocument(); }); it("filters servers across sections by search query", async () => { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 99dad8311..cd9a08927 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1157,6 +1157,7 @@ "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.", "Imported 1 MCP server from the project.": "Imported 1 MCP server from the project.", "Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.", + "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.": "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.", "Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view", "Up to date with {upstream}": "Up to date with {upstream}", "Highlight occurrences of selected text": "Highlight occurrences of selected text", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 17fbd1272..1ffb8a7df 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7536,6 +7536,7 @@ "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "项目的 .mcp.json 和 .vscode/mcp.json 中没有新的 MCP 服务器。", "Imported 1 MCP server from the project.": "已从项目导入 1 个 MCP 服务器。", "Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。", + "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.": "在 OpenIDEA 之外添加到项目设置文件中的服务器会保持关闭,直到你在这里将其开启。", "Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交", "Up to date with {upstream}": "已与 {upstream} 同步", "Highlight occurrences of selected text": "高亮所选文本的匹配项",