From 936639457980b00ee2fee609005d953d6b70a6c1 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 18:58:55 +0530 Subject: [PATCH 01/44] feat(policies): add permission support for MCP server connections --- crates/forge_domain/src/policies/engine.rs | 40 +++- crates/forge_domain/src/policies/operation.rs | 9 + crates/forge_domain/src/policies/rule.rs | 127 +++++++++++- crates/forge_main/src/ui.rs | 11 +- crates/forge_services/src/forge_services.rs | 15 +- crates/forge_services/src/mcp/service.rs | 190 +++++++++++++++--- .../src/permissions.default.yaml | 4 + crates/forge_services/src/policy.rs | 47 ++++- 8 files changed, 402 insertions(+), 41 deletions(-) diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index b89747a906..5d2bc17c97 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -91,7 +91,9 @@ mod tests { use pretty_assertions::assert_eq; use super::*; - use crate::{ExecuteRule, Fetch, Permission, Policy, PolicyConfig, ReadRule, Rule, WriteRule}; + use crate::{ + ExecuteRule, Fetch, McpRule, Permission, Policy, PolicyConfig, ReadRule, Rule, WriteRule, + }; fn fixture_workflow_with_read_policy() -> PolicyConfig { PolicyConfig::new().add_policy(Policy::Simple { @@ -201,4 +203,40 @@ mod tests { assert_eq!(actual, Permission::Allow); } + + #[test] + fn test_policy_engine_mcp_unmatched_defaults_to_confirm() { + let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { mcp: "github".to_string(), dir: None }), + }); + let fixture = PolicyEngine::new(&fixture_workflow); + let operation = PermissionOperation::Mcp { + server: "slack".to_string(), + cwd: std::path::PathBuf::from("/test/cwd"), + message: "Execute MCP tool: mcp_slack_tool_send".to_string(), + }; + + let actual = fixture.can_perform(&operation); + + assert_eq!(actual, Permission::Confirm); + } + + #[test] + fn test_policy_engine_mcp_matching_glob_allows() { + let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), dir: None }), + }); + let fixture = PolicyEngine::new(&fixture_workflow); + let operation = PermissionOperation::Mcp { + server: "github".to_string(), + cwd: std::path::PathBuf::from("/test/cwd"), + message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + }; + + let actual = fixture.can_perform(&operation); + + assert_eq!(actual, Permission::Allow); + } } diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index 3a99e383dc..8d541d258b 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -23,4 +23,13 @@ pub enum PermissionOperation { cwd: PathBuf, message: String, }, + /// MCP server connection authorization, identified by the server name as + /// it appears in `.mcp.json`. Evaluated once per server when the MCP + /// service brings up connections; the decision then gates every tool + /// call routed through that server. + Mcp { + server: String, + cwd: PathBuf, + message: String, + }, } diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index 652dbab8a9..fc5c13e37d 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -1,5 +1,5 @@ use std::fmt::{Display, Formatter}; -use std::path::Path; +use std::path::{Path, PathBuf}; use glob::Pattern; use schemars::JsonSchema; @@ -39,6 +39,18 @@ pub struct Fetch { pub dir: Option, } +/// Rule for MCP tool invocations matched by server-name glob +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct McpRule { + /// Glob over the MCP server name as it appears in `.mcp.json`. + pub mcp: String, + /// Optional directory scope. The literal value `"."` is interpreted as + /// "the operation's current working directory"; any other value is + /// matched as a glob pattern against the operation's cwd. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir: Option, +} + /// Rules that define what operations are covered by a policy #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] @@ -51,6 +63,8 @@ pub enum Rule { Execute(ExecuteRule), /// Rule for network fetch operations with a URL pattern Fetch(Fetch), + /// Rule for MCP tool invocations with a tool-name glob pattern + Mcp(McpRule), } impl Rule { @@ -94,6 +108,19 @@ impl Rule { }; url_matches && dir_matches } + (Rule::Mcp(rule), PermissionOperation::Mcp { server, cwd, message: _ }) => { + let server_matches = match_pattern(&rule.mcp, server); + // MCP rules treat a `dir` of `"."` as "the operation's current + // working directory", letting the shipped default rule + // (`server: "*"`, `dir: "."`) prompt the user once per + // project root. Any other pattern is matched literally. + let dir_matches = match rule.dir.as_deref() { + None => true, + Some(p) if p.display().to_string() == cwd.display().to_string() => true, + Some(p) => match_pattern(&p.to_string_lossy(), cwd), + }; + server_matches && dir_matches + } _ => false, } } @@ -150,6 +177,16 @@ impl Display for Fetch { } } +impl Display for McpRule { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(wd) = &self.dir { + write!(f, "mcp server '{}' in '{}'", self.mcp, wd.display()) + } else { + write!(f, "mcp server '{}'", self.mcp) + } + } +} + impl Display for Rule { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -157,6 +194,7 @@ impl Display for Rule { Rule::Read(rule) => write!(f, "{rule}"), Rule::Execute(rule) => write!(f, "{rule}"), Rule::Fetch(rule) => write!(f, "{rule}"), + Rule::Mcp(rule) => write!(f, "{rule}"), } } } @@ -208,6 +246,14 @@ mod tests { } } + fn fixture_mcp_operation() -> PermissionOperation { + PermissionOperation::Mcp { + server: "github".to_string(), + cwd: PathBuf::from("/home/user/project"), + message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + } + } + #[test] fn test_rule_matches_write_operation() { let fixture = Rule::Write(WriteRule { write: "src/**/*.rs".to_string(), dir: None }); @@ -325,4 +371,83 @@ mod tests { assert_eq!(actual, true); } + + #[test] + fn test_mcp_rule_exact_match() { + let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), dir: None }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_glob_wildcard() { + let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), dir: None }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_does_not_match_other_server() { + let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), dir: None }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_rule_does_not_match_non_mcp_operation() { + let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), dir: None }); + let operation = fixture_execute_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_rule_dir_pattern_match() { + let fixture = Rule::Mcp(McpRule { + mcp: "github".to_string(), + dir: Some(PathBuf::from("/home/user/*")), + }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_dir_pattern_no_match() { + let fixture = Rule::Mcp(McpRule { + mcp: "github".to_string(), + dir: Some(PathBuf::from("/other/path/*")), + }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_rule_dir_dot_resolves_to_cwd() { + // `dir: "."` should match every cwd because it expands to the + // operation's own cwd before glob matching. + let fixture = + Rule::Mcp(McpRule { mcp: "*".to_string(), dir: Some(PathBuf::from(".")) }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9ecd50fc41..15d3a8cbe4 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -223,6 +223,9 @@ impl A + Send + Sync> UI self.display_banner()?; self.trace_user(); self.hydrate_caches(); + // Resolve MCP trust prompts up front — see the note on + // `hydrate_caches` for why `get_tools` must not be spawned. + let _ = self.api.get_tools().await; Ok(()) } @@ -368,6 +371,12 @@ impl A + Send + Sync> UI self.hydrate_caches(); self.init_conversation().await?; + // Resolve any pending MCP trust prompts before the REPL takes over + // stdin. `get_tools` is the entry point that triggers MCP server + // authorisation; awaiting it here ensures every `Permission::Confirm` + // dialog is answered while the main task still owns the terminal. + let _ = self.api.get_tools().await; + // Check for dispatch flag first if let Some(dispatch_json) = self.cli.event.clone() { return self.handle_dispatch(dispatch_json).await; @@ -448,8 +457,6 @@ impl A + Send + Sync> UI let api = self.api.clone(); tokio::spawn(async move { api.get_models().await }); let api = self.api.clone(); - tokio::spawn(async move { api.get_tools().await }); - let api = self.api.clone(); tokio::spawn(async move { api.get_agent_infos().await }); let api = self.api.clone(); tokio::spawn(async move { diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index cd2f775899..b3b120dcfa 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -30,7 +30,12 @@ use crate::tool_services::{ ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, }; -type McpService = ForgeMcpService, F, ::Client>; +type McpService = ForgeMcpService< + ForgeMcpManager, + F, + ::Client, + ForgePolicyService, +>; type AuthService = ForgeAuthService; /// ForgeApp is the main application container that implements the App trait. @@ -110,7 +115,12 @@ impl< { pub fn new(infra: Arc) -> Self { let mcp_manager = Arc::new(ForgeMcpManager::new(infra.clone())); - let mcp_service = Arc::new(ForgeMcpService::new(mcp_manager.clone(), infra.clone())); + let policy_service = ForgePolicyService::new(infra.clone()); + let mcp_service = Arc::new(ForgeMcpService::new( + mcp_manager.clone(), + infra.clone(), + Arc::new(policy_service.clone()), + )); let template_service = Arc::new(ForgeTemplateService::new(infra.clone())); let attachment_service = Arc::new(ForgeChatRequest::new(infra.clone())); let suggestion_service = Arc::new(ForgeDiscoveryService::new(infra.clone())); @@ -133,7 +143,6 @@ impl< Arc::new(ForgeCustomInstructionsService::new(infra.clone())); let agent_registry_service = Arc::new(ForgeAgentRegistryService::new(infra.clone())); let command_loader_service = Arc::new(ForgeCommandLoaderService::new(infra.clone())); - let policy_service = ForgePolicyService::new(infra.clone()); let provider_auth_service = ForgeProviderAuthService::new(infra.clone()); let discovery = Arc::new(FdDefault::new(infra.clone())); let workspace_service = Arc::new(crate::context_engine::ForgeWorkspaceService::new( diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index a96692de62..0570f160f8 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -1,14 +1,17 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; use forge_app::domain::{ - McpConfig, McpServerConfig, McpServers, ServerName, ToolCallFull, ToolDefinition, ToolName, - ToolOutput, + McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, + ToolDefinition, ToolName, ToolOutput, }; use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, + PolicyService, }; +use merge::Merge; use tokio::sync::{Mutex, RwLock}; use crate::mcp::tool::McpExecutor; @@ -22,14 +25,25 @@ fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> Too )) } +/// Directory used to scope trust decisions for the servers loaded from a +/// given MCP config file. Falls back to `.` if the config path has no parent +/// (which should not happen in practice for either user or local configs). +fn config_scope_dir(config_path: &Path) -> PathBuf { + config_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + #[derive(Clone)] -pub struct ForgeMcpService { +pub struct ForgeMcpService { tools: Arc>>>>, failed_servers: Arc>>, previous_config_hash: Arc>, init_lock: Arc>, manager: Arc, infra: Arc, + policy: Arc

, } #[derive(Clone)] @@ -39,14 +53,15 @@ struct ToolHolder { server_name: String, } -impl ForgeMcpService +impl ForgeMcpService where M: McpConfigManager, I: McpServerInfra + KVStore + EnvironmentInfra, C: McpClientInfra + Clone, C: From<::Client>, + P: PolicyService, { - pub fn new(manager: Arc, infra: Arc) -> Self { + pub fn new(manager: Arc, infra: Arc, policy: Arc

) -> Self { Self { tools: Default::default(), failed_servers: Default::default(), @@ -54,6 +69,7 @@ where init_lock: Arc::new(Mutex::new(())), manager, infra, + policy, } } @@ -101,11 +117,11 @@ where } async fn init_mcp(&self) -> anyhow::Result<()> { - let mcp = self.manager.read_mcp_config(None).await?; + let (merged, user_cfg, local_cfg) = self.load_scoped_configs().await?; // Fast path: if config is unchanged, skip reinitialization without acquiring // the lock - if !self.is_config_modified(&mcp).await { + if !self.is_config_modified(&merged).await { return Ok(()); } @@ -114,27 +130,62 @@ where let _guard = self.init_lock.lock().await; // Double-check under the lock: a concurrent caller may have already updated - if !self.is_config_modified(&mcp).await { + if !self.is_config_modified(&merged).await { return Ok(()); } - self.update_mcp(mcp).await + self.update_mcp(merged, user_cfg, local_cfg).await + } + + /// Read user- and local-scope configs and return them alongside a merged + /// view. The merged view follows `read_mcp_config`'s precedence rules + /// (local overrides user) and is what gets exposed to callers; the + /// individual scope configs are retained so trust decisions can be + /// attributed back to the file that declared each server. + async fn load_scoped_configs(&self) -> anyhow::Result<(McpConfig, McpConfig, McpConfig)> { + let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; + let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; + let mut merged = user_cfg.clone(); + merged.merge(local_cfg.clone()); + Ok((merged, user_cfg, local_cfg)) } - async fn update_mcp(&self, mcp: McpConfig) -> Result<(), anyhow::Error> { + async fn update_mcp( + &self, + merged: McpConfig, + user_cfg: McpConfig, + local_cfg: McpConfig, + ) -> Result<(), anyhow::Error> { // Compute the hash early before mcp is consumed, but write it only after // all connections are established so waiters on init_lock see a consistent // state. - let new_hash = mcp.cache_key(); + let new_hash = merged.cache_key(); self.clear_tools().await; // Clear failed servers map before attempting new connections self.failed_servers.write().await.clear(); - let connections: Vec<_> = mcp + // Resolve a permission decision for every enabled server *before* any + // network/process work begins. Trust is scoped to the directory of the + // config file that declared each server: user-scope persists across + // projects, local-scope stays project-local. Local entries shadow + // user entries of the same name, so they're authorized first. + let env = self.infra.get_environment(); + let user_dir = config_scope_dir(&env.mcp_user_config()); + let local_dir = config_scope_dir(&env.mcp_local_config()); + let authorized = self + .authorize_servers([ + (&local_cfg, local_dir.as_path()), + (&user_cfg, user_dir.as_path()), + ]) + .await?; + + // Disabled servers are dropped inside `authorize_servers`, so a single + // membership check is enough to filter the connection set here. + let connections: Vec<_> = merged .mcp_servers .into_iter() - .filter(|v| !v.1.is_disabled()) + .filter(|(name, _)| authorized.contains(name)) .map(|(name, server)| async move { let conn = self .connect(&name, server) @@ -148,17 +199,13 @@ where let results = futures::future::join_all(connections).await; for (server_name, result) in results { - match result { - Ok(_) => {} - Err(error) => { - // Format error with full chain for detailed diagnostics - // Using Debug formatting with alternate flag shows the full error chain - let error_string = format!("{error:?}"); - self.failed_servers - .write() - .await - .insert(server_name.clone(), error_string.clone()); - } + if let Err(error) = result { + // Format error with full chain for detailed diagnostics + let error_string = format!("{error:?}"); + self.failed_servers + .write() + .await + .insert(server_name, error_string); } } @@ -170,6 +217,62 @@ where Ok(()) } + /// Asks the policy engine which configured MCP servers may be connected. + /// + /// Each `(config, cwd)` pair represents a scope: every server declared + /// in `config` is checked with that `cwd` as its trust scope. Scopes are + /// processed in order, and servers already seen in an earlier scope are + /// skipped so duplicate names (e.g. local overriding user) trigger only + /// the authoritative scope's prompt. Servers that the user denies + /// (either by an existing `Deny` policy or interactively in response to + /// a `Confirm` policy) are recorded in `failed_servers` with a + /// human-readable reason and excluded from the returned set. + async fn authorize_servers<'a>( + &self, + scoped: impl IntoIterator, + ) -> anyhow::Result> { + let mut authorized = HashSet::new(); + let mut visited: HashSet = HashSet::new(); + let mut denied: Vec<(ServerName, String)> = Vec::new(); + + // Evaluate policy for each server sequentially — prompts require user + // input and must not hold any lock while awaiting a response. + for (cfg, cwd) in scoped { + for (name, server) in &cfg.mcp_servers { + if server.is_disabled() || !visited.insert(name.clone()) { + continue; + } + let operation = PermissionOperation::Mcp { + server: name.to_string(), + cwd: cwd.to_path_buf(), + message: format!("Connect to MCP server: {name}"), + }; + match self.policy.check_operation_permission(&operation).await { + Ok(decision) if decision.allowed => { + authorized.insert(name.clone()); + } + Ok(_) => { + denied + .push((name.clone(), "Connection denied by user policy".to_string())); + } + Err(err) => { + denied.push((name.clone(), format!("Policy check failed: {err:?}"))); + } + } + } + } + + // Write all denials in one lock acquisition after prompting is done. + if !denied.is_empty() { + let mut failures = self.failed_servers.write().await; + for (name, reason) in denied { + failures.insert(name, reason); + } + } + + Ok(authorized) + } + async fn list(&self) -> anyhow::Result { self.init_mcp().await?; @@ -226,11 +329,13 @@ where } #[async_trait::async_trait] -impl McpService - for ForgeMcpService +impl McpService for ForgeMcpService where + M: McpConfigManager, + I: McpServerInfra + KVStore + EnvironmentInfra, C: McpClientInfra + Clone, C: From<::Client>, + P: PolicyService, { async fn get_mcp_servers(&self) -> anyhow::Result { // Read current configs to compute merged hash @@ -266,11 +371,12 @@ mod tests { use fake::{Fake, Faker}; use forge_app::domain::{ - ConfigOperation, Environment, McpConfig, McpServerConfig, Scope, ServerName, ToolCallFull, - ToolDefinition, ToolName, ToolOutput, + ConfigOperation, Environment, McpConfig, McpServerConfig, PermissionOperation, Scope, + ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, }; use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, + PolicyDecision, PolicyService, }; use forge_config::ForgeConfig; use pretty_assertions::assert_eq; @@ -388,10 +494,32 @@ mod tests { } } + // ── Mock policy service ────────────────────────────────────────────────── + + /// Permits every operation. Tests need MCP connections to go through + /// without prompting; production behaviour (Confirm by default) is covered + /// by `forge_services::policy` and `forge_domain::policies::engine` tests. + struct AlwaysAllowPolicy; + + #[async_trait::async_trait] + impl PolicyService for AlwaysAllowPolicy { + async fn check_operation_permission( + &self, + _operation: &PermissionOperation, + ) -> anyhow::Result { + Ok(PolicyDecision { allowed: true, path: None }) + } + } + // ── Fixture ────────────────────────────────────────────────────────────── - fn fixture() -> ForgeMcpService { - ForgeMcpService::new(Arc::new(MockMcpManager), Arc::new(MockInfra)) + fn fixture() + -> ForgeMcpService { + ForgeMcpService::new( + Arc::new(MockMcpManager), + Arc::new(MockInfra), + Arc::new(AlwaysAllowPolicy), + ) } #[test] diff --git a/crates/forge_services/src/permissions.default.yaml b/crates/forge_services/src/permissions.default.yaml index aeca231ebd..10b374d24e 100644 --- a/crates/forge_services/src/permissions.default.yaml +++ b/crates/forge_services/src/permissions.default.yaml @@ -11,3 +11,7 @@ policies: - permission: allow rule: url: "*" + - permission: confirm + rule: + mcp: "*" + dir: "." diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 97621bc04b..f7ecaddbbc 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, LazyLock}; use anyhow::Context; use bytes::Bytes; use forge_app::domain::{ - ExecuteRule, Fetch, Permission, PermissionOperation, Policy, PolicyConfig, PolicyEngine, - ReadRule, Rule, WriteRule, + ExecuteRule, Fetch, McpRule, Permission, PermissionOperation, Policy, PolicyConfig, + PolicyEngine, ReadRule, Rule, WriteRule, }; use forge_app::{ DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, @@ -27,10 +27,17 @@ pub enum PolicyPermission { AcceptAndRemember, } -#[derive(Clone)] pub struct ForgePolicyService { infra: Arc, } + +impl Clone for ForgePolicyService { + // Manual impl so callers don't need `I: Clone`; we only ever clone the + // `Arc` which is always cheap. + fn clone(&self) -> Self { + Self { infra: self.infra.clone() } + } +} /// Default policies loaded once at startup from the embedded YAML file static DEFAULT_POLICIES: LazyLock = LazyLock::new(|| { let yaml_content = include_str!("./permissions.default.yaml"); @@ -185,6 +192,9 @@ where PermissionOperation::Fetch { message, .. } => { format!("{message}. How would you like to proceed?") } + PermissionOperation::Mcp { message, .. } => { + format!("{message}. How would you like to proceed?") + } }; match self @@ -262,6 +272,16 @@ fn create_policy_for_operation( }), } } + PermissionOperation::Mcp { server, cwd, .. } => Some(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { + mcp: server.clone(), + // Scope the remembered decision to the directory the user was in + // when they confirmed the prompt: trusting an MCP server is a + // per-project decision, not a global one. + dir: Some(cwd.clone()), + }), + }), } } @@ -443,4 +463,25 @@ mod tests { assert_eq!(actual, expected); } + + #[test] + fn test_create_policy_for_mcp_operation_scopes_dir_to_cwd() { + let operation = PermissionOperation::Mcp { + server: "github".to_string(), + cwd: PathBuf::from("/home/user/project"), + message: "Connect to MCP server: github".to_string(), + }; + + let actual = create_policy_for_operation(&operation, None); + + let expected = Some(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { + mcp: "github".to_string(), + dir: Some(PathBuf::from("/home/user/project")), + }), + }); + + assert_eq!(actual, expected); + } } From 1eeae1e6c26941d2f034da6470c8879a3f6fdea2 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 19:22:10 +0530 Subject: [PATCH 02/44] refactor(policies): remove directory scoping from MCP permission rules --- crates/forge_domain/src/policies/engine.rs | 6 +- crates/forge_domain/src/policies/operation.rs | 6 +- crates/forge_domain/src/policies/rule.rs | 81 ++-------- crates/forge_services/src/mcp/service.rs | 140 ++++++------------ .../src/permissions.default.yaml | 1 - crates/forge_services/src/policy.rs | 18 +-- 6 files changed, 69 insertions(+), 183 deletions(-) diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index 5d2bc17c97..4d2f4bd0b5 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -208,12 +208,11 @@ mod tests { fn test_policy_engine_mcp_unmatched_defaults_to_confirm() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "github".to_string(), dir: None }), + rule: Rule::Mcp(McpRule { mcp: "github".to_string() }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "slack".to_string(), - cwd: std::path::PathBuf::from("/test/cwd"), message: "Execute MCP tool: mcp_slack_tool_send".to_string(), }; @@ -226,12 +225,11 @@ mod tests { fn test_policy_engine_mcp_matching_glob_allows() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), dir: None }), + rule: Rule::Mcp(McpRule { mcp: "git*".to_string() }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "github".to_string(), - cwd: std::path::PathBuf::from("/test/cwd"), message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), }; diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index 8d541d258b..fd6176272b 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -27,9 +27,5 @@ pub enum PermissionOperation { /// it appears in `.mcp.json`. Evaluated once per server when the MCP /// service brings up connections; the decision then gates every tool /// call routed through that server. - Mcp { - server: String, - cwd: PathBuf, - message: String, - }, + Mcp { server: String, message: String }, } diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index fc5c13e37d..bb8a54730e 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -1,5 +1,5 @@ use std::fmt::{Display, Formatter}; -use std::path::{Path, PathBuf}; +use std::path::Path; use glob::Pattern; use schemars::JsonSchema; @@ -39,16 +39,15 @@ pub struct Fetch { pub dir: Option, } -/// Rule for MCP tool invocations matched by server-name glob +/// Rule for MCP server connection authorization matched by server-name glob. +/// +/// MCP rules are intentionally scope-free: trust for a server is per-config +/// (project's `.mcp.json` vs. the user-level one), and that distinction is +/// resolved at the service layer before the policy engine ever runs. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] pub struct McpRule { /// Glob over the MCP server name as it appears in `.mcp.json`. pub mcp: String, - /// Optional directory scope. The literal value `"."` is interpreted as - /// "the operation's current working directory"; any other value is - /// matched as a glob pattern against the operation's cwd. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dir: Option, } /// Rules that define what operations are covered by a policy @@ -108,18 +107,8 @@ impl Rule { }; url_matches && dir_matches } - (Rule::Mcp(rule), PermissionOperation::Mcp { server, cwd, message: _ }) => { - let server_matches = match_pattern(&rule.mcp, server); - // MCP rules treat a `dir` of `"."` as "the operation's current - // working directory", letting the shipped default rule - // (`server: "*"`, `dir: "."`) prompt the user once per - // project root. Any other pattern is matched literally. - let dir_matches = match rule.dir.as_deref() { - None => true, - Some(p) if p.display().to_string() == cwd.display().to_string() => true, - Some(p) => match_pattern(&p.to_string_lossy(), cwd), - }; - server_matches && dir_matches + (Rule::Mcp(rule), PermissionOperation::Mcp { server, message: _ }) => { + match_pattern(&rule.mcp, server) } _ => false, } @@ -179,11 +168,7 @@ impl Display for Fetch { impl Display for McpRule { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if let Some(wd) = &self.dir { - write!(f, "mcp server '{}' in '{}'", self.mcp, wd.display()) - } else { - write!(f, "mcp server '{}'", self.mcp) - } + write!(f, "mcp server '{}'", self.mcp) } } @@ -249,7 +234,6 @@ mod tests { fn fixture_mcp_operation() -> PermissionOperation { PermissionOperation::Mcp { server: "github".to_string(), - cwd: PathBuf::from("/home/user/project"), message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), } } @@ -374,7 +358,7 @@ mod tests { #[test] fn test_mcp_rule_exact_match() { - let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), dir: None }); + let fixture = Rule::Mcp(McpRule { mcp: "github".to_string() }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -384,7 +368,7 @@ mod tests { #[test] fn test_mcp_rule_glob_wildcard() { - let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), dir: None }); + let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string() }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -394,7 +378,7 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_other_server() { - let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), dir: None }); + let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string() }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -404,50 +388,11 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_non_mcp_operation() { - let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), dir: None }); + let fixture = Rule::Mcp(McpRule { mcp: "*".to_string() }); let operation = fixture_execute_operation(); let actual = fixture.matches(&operation); assert_eq!(actual, false); } - - #[test] - fn test_mcp_rule_dir_pattern_match() { - let fixture = Rule::Mcp(McpRule { - mcp: "github".to_string(), - dir: Some(PathBuf::from("/home/user/*")), - }); - let operation = fixture_mcp_operation(); - - let actual = fixture.matches(&operation); - - assert_eq!(actual, true); - } - - #[test] - fn test_mcp_rule_dir_pattern_no_match() { - let fixture = Rule::Mcp(McpRule { - mcp: "github".to_string(), - dir: Some(PathBuf::from("/other/path/*")), - }); - let operation = fixture_mcp_operation(); - - let actual = fixture.matches(&operation); - - assert_eq!(actual, false); - } - - #[test] - fn test_mcp_rule_dir_dot_resolves_to_cwd() { - // `dir: "."` should match every cwd because it expands to the - // operation's own cwd before glob matching. - let fixture = - Rule::Mcp(McpRule { mcp: "*".to_string(), dir: Some(PathBuf::from(".")) }); - let operation = fixture_mcp_operation(); - - let actual = fixture.matches(&operation); - - assert_eq!(actual, true); - } } diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 0570f160f8..c403b09823 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; @@ -25,16 +24,6 @@ fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> Too )) } -/// Directory used to scope trust decisions for the servers loaded from a -/// given MCP config file. Falls back to `.` if the config path has no parent -/// (which should not happen in practice for either user or local configs). -fn config_scope_dir(config_path: &Path) -> PathBuf { - config_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")) -} - #[derive(Clone)] pub struct ForgeMcpService { tools: Arc>>>>, @@ -117,7 +106,10 @@ where } async fn init_mcp(&self) -> anyhow::Result<()> { - let (merged, user_cfg, local_cfg) = self.load_scoped_configs().await?; + let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; + let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; + let mut merged = user_cfg; + merged.merge(local_cfg.clone()); // Fast path: if config is unchanged, skip reinitialization without acquiring // the lock @@ -134,58 +126,37 @@ where return Ok(()); } - self.update_mcp(merged, user_cfg, local_cfg).await - } - - /// Read user- and local-scope configs and return them alongside a merged - /// view. The merged view follows `read_mcp_config`'s precedence rules - /// (local overrides user) and is what gets exposed to callers; the - /// individual scope configs are retained so trust decisions can be - /// attributed back to the file that declared each server. - async fn load_scoped_configs(&self) -> anyhow::Result<(McpConfig, McpConfig, McpConfig)> { - let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; - let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; - let mut merged = user_cfg.clone(); - merged.merge(local_cfg.clone()); - Ok((merged, user_cfg, local_cfg)) + self.update_mcp(local_cfg, merged).await } async fn update_mcp( &self, - merged: McpConfig, - user_cfg: McpConfig, local_cfg: McpConfig, - ) -> Result<(), anyhow::Error> { - // Compute the hash early before mcp is consumed, but write it only after - // all connections are established so waiters on init_lock see a consistent - // state. + merged: McpConfig, + ) -> anyhow::Result<()> { + // Compute the hash early before `merged` is consumed, but write it only + // after all connections are established so waiters on init_lock see a + // consistent state. let new_hash = merged.cache_key(); self.clear_tools().await; - - // Clear failed servers map before attempting new connections self.failed_servers.write().await.clear(); - // Resolve a permission decision for every enabled server *before* any - // network/process work begins. Trust is scoped to the directory of the - // config file that declared each server: user-scope persists across - // projects, local-scope stays project-local. Local entries shadow - // user entries of the same name, so they're authorized first. - let env = self.infra.get_environment(); - let user_dir = config_scope_dir(&env.mcp_user_config()); - let local_dir = config_scope_dir(&env.mcp_local_config()); - let authorized = self - .authorize_servers([ - (&local_cfg, local_dir.as_path()), - (&user_cfg, user_dir.as_path()), - ]) - .await?; - - // Disabled servers are dropped inside `authorize_servers`, so a single - // membership check is enough to filter the connection set here. + // Trust model: + // - User-scope servers (~/.forge/.mcp.json) are auto-allowed: editing + // that file is itself a deliberate, global opt-in. + // - Local-scope servers (the project's .mcp.json) go through the + // policy engine. Since local entries shadow user entries of the + // same name, any name present in local must clear the prompt. + let local_authorized = self.authorize_servers(&local_cfg).await?; + let connections: Vec<_> = merged .mcp_servers .into_iter() - .filter(|(name, _)| authorized.contains(name)) + .filter(|(name, server)| { + !server.is_disabled() + && (!local_cfg.mcp_servers.contains_key(name) + || local_authorized.contains(name)) + }) .map(|(name, server)| async move { let conn = self .connect(&name, server) @@ -200,12 +171,11 @@ where for (server_name, result) in results { if let Err(error) = result { - // Format error with full chain for detailed diagnostics - let error_string = format!("{error:?}"); + // Debug formatting preserves the full error chain for diagnostics. self.failed_servers .write() .await - .insert(server_name, error_string); + .insert(server_name, format!("{error:?}")); } } @@ -217,52 +187,40 @@ where Ok(()) } - /// Asks the policy engine which configured MCP servers may be connected. - /// - /// Each `(config, cwd)` pair represents a scope: every server declared - /// in `config` is checked with that `cwd` as its trust scope. Scopes are - /// processed in order, and servers already seen in an earlier scope are - /// skipped so duplicate names (e.g. local overriding user) trigger only - /// the authoritative scope's prompt. Servers that the user denies - /// (either by an existing `Deny` policy or interactively in response to - /// a `Confirm` policy) are recorded in `failed_servers` with a - /// human-readable reason and excluded from the returned set. - async fn authorize_servers<'a>( + /// Runs the permission policy against every enabled server in `config`, + /// returning the set of names the user authorised. Denials (whether from + /// an existing `Deny` policy or an interactive `Confirm` rejection) are + /// recorded in `failed_servers` with a human-readable reason. + async fn authorize_servers( &self, - scoped: impl IntoIterator, + config: &McpConfig, ) -> anyhow::Result> { let mut authorized = HashSet::new(); - let mut visited: HashSet = HashSet::new(); let mut denied: Vec<(ServerName, String)> = Vec::new(); - // Evaluate policy for each server sequentially — prompts require user - // input and must not hold any lock while awaiting a response. - for (cfg, cwd) in scoped { - for (name, server) in &cfg.mcp_servers { - if server.is_disabled() || !visited.insert(name.clone()) { - continue; + // Sequential: prompts require user input and must not hold any lock + // while awaiting a response. + for (name, server) in &config.mcp_servers { + if server.is_disabled() { + continue; + } + let operation = PermissionOperation::Mcp { + server: name.to_string(), + message: format!("Connect to MCP server: {name}"), + }; + match self.policy.check_operation_permission(&operation).await { + Ok(decision) if decision.allowed => { + authorized.insert(name.clone()); + } + Ok(_) => { + denied.push((name.clone(), "Connection denied by user policy".to_string())); } - let operation = PermissionOperation::Mcp { - server: name.to_string(), - cwd: cwd.to_path_buf(), - message: format!("Connect to MCP server: {name}"), - }; - match self.policy.check_operation_permission(&operation).await { - Ok(decision) if decision.allowed => { - authorized.insert(name.clone()); - } - Ok(_) => { - denied - .push((name.clone(), "Connection denied by user policy".to_string())); - } - Err(err) => { - denied.push((name.clone(), format!("Policy check failed: {err:?}"))); - } + Err(err) => { + denied.push((name.clone(), format!("Policy check failed: {err:?}"))); } } } - // Write all denials in one lock acquisition after prompting is done. if !denied.is_empty() { let mut failures = self.failed_servers.write().await; for (name, reason) in denied { diff --git a/crates/forge_services/src/permissions.default.yaml b/crates/forge_services/src/permissions.default.yaml index 10b374d24e..ee5ad96df4 100644 --- a/crates/forge_services/src/permissions.default.yaml +++ b/crates/forge_services/src/permissions.default.yaml @@ -14,4 +14,3 @@ policies: - permission: confirm rule: mcp: "*" - dir: "." diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index f7ecaddbbc..f581d3a6c5 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -272,15 +272,9 @@ fn create_policy_for_operation( }), } } - PermissionOperation::Mcp { server, cwd, .. } => Some(Policy::Simple { + PermissionOperation::Mcp { server, .. } => Some(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { - mcp: server.clone(), - // Scope the remembered decision to the directory the user was in - // when they confirmed the prompt: trusting an MCP server is a - // per-project decision, not a global one. - dir: Some(cwd.clone()), - }), + rule: Rule::Mcp(McpRule { mcp: server.clone() }), }), } } @@ -465,10 +459,9 @@ mod tests { } #[test] - fn test_create_policy_for_mcp_operation_scopes_dir_to_cwd() { + fn test_create_policy_for_mcp_operation() { let operation = PermissionOperation::Mcp { server: "github".to_string(), - cwd: PathBuf::from("/home/user/project"), message: "Connect to MCP server: github".to_string(), }; @@ -476,10 +469,7 @@ mod tests { let expected = Some(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { - mcp: "github".to_string(), - dir: Some(PathBuf::from("/home/user/project")), - }), + rule: Rule::Mcp(McpRule { mcp: "github".to_string() }), }); assert_eq!(actual, expected); From 98f4a7caf7cda4b57b36fd3ac85c87387496b010 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 19:38:16 +0530 Subject: [PATCH 03/44] feat(policies): differentiate user and local MCP server permissions --- crates/forge_domain/src/mcp.rs | 18 ++++- crates/forge_domain/src/policies/engine.rs | 29 ++++++- crates/forge_domain/src/policies/operation.rs | 11 ++- crates/forge_domain/src/policies/rule.rs | 55 ++++++++++--- crates/forge_services/src/mcp/service.rs | 79 ++++++++++--------- .../src/permissions.default.yaml | 9 +++ crates/forge_services/src/policy.rs | 14 +++- 7 files changed, 161 insertions(+), 54 deletions(-) diff --git a/crates/forge_domain/src/mcp.rs b/crates/forge_domain/src/mcp.rs index 53afe53725..f9e1b1e964 100644 --- a/crates/forge_domain/src/mcp.rs +++ b/crates/forge_domain/src/mcp.rs @@ -7,9 +7,25 @@ use std::ops::Deref; use derive_more::{Deref, Display, From}; use derive_setters::Setters; use merge::Merge; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// Which `.mcp.json` declared a server: the user-level file (global to the +/// machine) or the project-local one. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "lowercase")] pub enum Scope { Local, User, diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index 4d2f4bd0b5..4b1678adfc 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -91,6 +91,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::mcp::Scope; use crate::{ ExecuteRule, Fetch, McpRule, Permission, Policy, PolicyConfig, ReadRule, Rule, WriteRule, }; @@ -208,11 +209,12 @@ mod tests { fn test_policy_engine_mcp_unmatched_defaults_to_confirm() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "github".to_string() }), + rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "slack".to_string(), + scope: Scope::Local, message: "Execute MCP tool: mcp_slack_tool_send".to_string(), }; @@ -225,11 +227,12 @@ mod tests { fn test_policy_engine_mcp_matching_glob_allows() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "git*".to_string() }), + rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "github".to_string(), + scope: Scope::Local, message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), }; @@ -237,4 +240,26 @@ mod tests { assert_eq!(actual, Permission::Allow); } + + #[test] + fn test_policy_engine_mcp_scope_filter_skips_non_matching_scope() { + // A `scope: user` rule must not affect a Local-scope operation. + let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { + mcp: "*".to_string(), + scope: Some(Scope::User), + }), + }); + let fixture = PolicyEngine::new(&fixture_workflow); + let operation = PermissionOperation::Mcp { + server: "github".to_string(), + scope: Scope::Local, + message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + }; + + let actual = fixture.can_perform(&operation); + + assert_eq!(actual, Permission::Confirm); + } } diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index fd6176272b..c0e7e231b4 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use crate::mcp::Scope; + /// Operations that can be performed and need policy checking #[derive(Debug, Clone, PartialEq, Eq)] pub enum PermissionOperation { @@ -27,5 +29,12 @@ pub enum PermissionOperation { /// it appears in `.mcp.json`. Evaluated once per server when the MCP /// service brings up connections; the decision then gates every tool /// call routed through that server. - Mcp { server: String, message: String }, + Mcp { + server: String, + /// Which config file declared the server. Lets policy rules + /// differentiate user-level (global) trust from project-local + /// trust. + scope: Scope, + message: String, + }, } diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index bb8a54730e..1e12676b5d 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -6,6 +6,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::operation::PermissionOperation; +use crate::mcp::Scope; /// Rule for write operations with a glob pattern #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] @@ -39,15 +40,19 @@ pub struct Fetch { pub dir: Option, } -/// Rule for MCP server connection authorization matched by server-name glob. +/// Rule for MCP server connection authorization matched by server-name glob, +/// optionally narrowed to one config scope. /// -/// MCP rules are intentionally scope-free: trust for a server is per-config -/// (project's `.mcp.json` vs. the user-level one), and that distinction is -/// resolved at the service layer before the policy engine ever runs. +/// When `scope` is omitted the rule applies to servers from either the +/// user-level or local `.mcp.json`; specifying `user` or `local` restricts +/// the rule to that scope only. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] pub struct McpRule { /// Glob over the MCP server name as it appears in `.mcp.json`. pub mcp: String, + /// Optional config-scope filter. `None` matches any scope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, } /// Rules that define what operations are covered by a policy @@ -107,8 +112,9 @@ impl Rule { }; url_matches && dir_matches } - (Rule::Mcp(rule), PermissionOperation::Mcp { server, message: _ }) => { - match_pattern(&rule.mcp, server) + (Rule::Mcp(rule), PermissionOperation::Mcp { server, scope, message: _ }) => { + let scope_matches = rule.scope.is_none_or(|s| s == *scope); + scope_matches && match_pattern(&rule.mcp, server) } _ => false, } @@ -168,7 +174,11 @@ impl Display for Fetch { impl Display for McpRule { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "mcp server '{}'", self.mcp) + match self.scope { + Some(Scope::User) => write!(f, "mcp server '{}' (user scope)", self.mcp), + Some(Scope::Local) => write!(f, "mcp server '{}' (local scope)", self.mcp), + None => write!(f, "mcp server '{}'", self.mcp), + } } } @@ -234,6 +244,7 @@ mod tests { fn fixture_mcp_operation() -> PermissionOperation { PermissionOperation::Mcp { server: "github".to_string(), + scope: Scope::Local, message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), } } @@ -358,7 +369,7 @@ mod tests { #[test] fn test_mcp_rule_exact_match() { - let fixture = Rule::Mcp(McpRule { mcp: "github".to_string() }); + let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -368,7 +379,7 @@ mod tests { #[test] fn test_mcp_rule_glob_wildcard() { - let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string() }); + let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -378,7 +389,7 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_other_server() { - let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string() }); + let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), scope: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -388,11 +399,33 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_non_mcp_operation() { - let fixture = Rule::Mcp(McpRule { mcp: "*".to_string() }); + let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), scope: None }); let operation = fixture_execute_operation(); let actual = fixture.matches(&operation); assert_eq!(actual, false); } + + #[test] + fn test_mcp_rule_scope_matches_local() { + let fixture = + Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::Local) }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_scope_filters_out_user() { + let fixture = + Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User) }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } } diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index c403b09823..75b17af71b 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -108,7 +108,7 @@ where async fn init_mcp(&self) -> anyhow::Result<()> { let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; - let mut merged = user_cfg; + let mut merged = user_cfg.clone(); merged.merge(local_cfg.clone()); // Fast path: if config is unchanged, skip reinitialization without acquiring @@ -126,11 +126,12 @@ where return Ok(()); } - self.update_mcp(local_cfg, merged).await + self.update_mcp(user_cfg, local_cfg, merged).await } async fn update_mcp( &self, + user_cfg: McpConfig, local_cfg: McpConfig, merged: McpConfig, ) -> anyhow::Result<()> { @@ -141,22 +142,20 @@ where self.clear_tools().await; self.failed_servers.write().await.clear(); - // Trust model: - // - User-scope servers (~/.forge/.mcp.json) are auto-allowed: editing - // that file is itself a deliberate, global opt-in. - // - Local-scope servers (the project's .mcp.json) go through the - // policy engine. Since local entries shadow user entries of the - // same name, any name present in local must clear the prompt. - let local_authorized = self.authorize_servers(&local_cfg).await?; + // Run policy authorisation against both scopes. The default policy ships + // an `allow` rule for `scope: user` and a `confirm` rule for `scope: + // local`, but users can override either. Local entries are checked + // first because they shadow user entries of the same name in the + // merged config; the `visited` set prevents prompting twice for a + // duplicated name. + let authorized = self + .authorize_servers([(Scope::Local, &local_cfg), (Scope::User, &user_cfg)]) + .await?; let connections: Vec<_> = merged .mcp_servers .into_iter() - .filter(|(name, server)| { - !server.is_disabled() - && (!local_cfg.mcp_servers.contains_key(name) - || local_authorized.contains(name)) - }) + .filter(|(name, server)| !server.is_disabled() && authorized.contains(name)) .map(|(name, server)| async move { let conn = self .connect(&name, server) @@ -187,36 +186,44 @@ where Ok(()) } - /// Runs the permission policy against every enabled server in `config`, - /// returning the set of names the user authorised. Denials (whether from - /// an existing `Deny` policy or an interactive `Confirm` rejection) are - /// recorded in `failed_servers` with a human-readable reason. - async fn authorize_servers( + /// Runs the permission policy against every enabled server in each + /// `(scope, config)` pair, returning the set of names the user + /// authorised. Scopes are processed in iteration order; a server name + /// already seen in an earlier scope is skipped so duplicates (e.g. a + /// local entry overriding a user one) prompt only for the authoritative + /// scope. Denials — from a `Deny` policy or interactive rejection — + /// are recorded in `failed_servers` with a human-readable reason. + async fn authorize_servers<'a>( &self, - config: &McpConfig, + scoped: impl IntoIterator, ) -> anyhow::Result> { let mut authorized = HashSet::new(); + let mut visited: HashSet = HashSet::new(); let mut denied: Vec<(ServerName, String)> = Vec::new(); // Sequential: prompts require user input and must not hold any lock // while awaiting a response. - for (name, server) in &config.mcp_servers { - if server.is_disabled() { - continue; - } - let operation = PermissionOperation::Mcp { - server: name.to_string(), - message: format!("Connect to MCP server: {name}"), - }; - match self.policy.check_operation_permission(&operation).await { - Ok(decision) if decision.allowed => { - authorized.insert(name.clone()); - } - Ok(_) => { - denied.push((name.clone(), "Connection denied by user policy".to_string())); + for (scope, cfg) in scoped { + for (name, server) in &cfg.mcp_servers { + if server.is_disabled() || !visited.insert(name.clone()) { + continue; } - Err(err) => { - denied.push((name.clone(), format!("Policy check failed: {err:?}"))); + let operation = PermissionOperation::Mcp { + server: name.to_string(), + scope, + message: format!("Connect to MCP server: {name}"), + }; + match self.policy.check_operation_permission(&operation).await { + Ok(decision) if decision.allowed => { + authorized.insert(name.clone()); + } + Ok(_) => { + denied + .push((name.clone(), "Connection denied by user policy".to_string())); + } + Err(err) => { + denied.push((name.clone(), format!("Policy check failed: {err:?}"))); + } } } } diff --git a/crates/forge_services/src/permissions.default.yaml b/crates/forge_services/src/permissions.default.yaml index ee5ad96df4..b545185e6a 100644 --- a/crates/forge_services/src/permissions.default.yaml +++ b/crates/forge_services/src/permissions.default.yaml @@ -11,6 +11,15 @@ policies: - permission: allow rule: url: "*" + # MCP servers declared in the user-level ~/.forge/.mcp.json are trusted + # automatically: editing that file is itself a deliberate, global opt-in. + - permission: allow + rule: + mcp: "*" + scope: user + # MCP servers declared in the project's local .mcp.json require explicit + # per-server confirmation before the first connection. - permission: confirm rule: mcp: "*" + scope: local diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index f581d3a6c5..c25a53bb58 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -272,9 +272,13 @@ fn create_policy_for_operation( }), } } - PermissionOperation::Mcp { server, .. } => Some(Policy::Simple { + PermissionOperation::Mcp { server, scope, .. } => Some(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: server.clone() }), + // Scope the remembered decision to the same scope that triggered + // the prompt so the trust doesn't silently leak to a different + // `.mcp.json` later (e.g. accepting a local-scope server should + // not also auto-allow a user-scope entry with the same name). + rule: Rule::Mcp(McpRule { mcp: server.clone(), scope: Some(*scope) }), }), } } @@ -462,6 +466,7 @@ mod tests { fn test_create_policy_for_mcp_operation() { let operation = PermissionOperation::Mcp { server: "github".to_string(), + scope: forge_app::domain::Scope::Local, message: "Connect to MCP server: github".to_string(), }; @@ -469,7 +474,10 @@ mod tests { let expected = Some(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "github".to_string() }), + rule: Rule::Mcp(McpRule { + mcp: "github".to_string(), + scope: Some(forge_app::domain::Scope::Local), + }), }); assert_eq!(actual, expected); From 6e4390ba747ff6b663ef0afb96c054f9e59b2991 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 20:48:27 +0530 Subject: [PATCH 04/44] feat(policies): add directory glob matching to MCP permission rules --- crates/forge_domain/src/policies/engine.rs | 10 +- crates/forge_domain/src/policies/operation.rs | 2 + crates/forge_domain/src/policies/rule.rs | 110 +++++++++++++++--- crates/forge_services/src/mcp/service.rs | 2 + crates/forge_services/src/policy.rs | 10 +- 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index 4b1678adfc..dd8e4d9bf0 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -88,6 +88,8 @@ impl<'a> PolicyEngine<'a> { #[cfg(test)] mod tests { + use std::path::PathBuf; + use pretty_assertions::assert_eq; use super::*; @@ -209,12 +211,13 @@ mod tests { fn test_policy_engine_mcp_unmatched_defaults_to_confirm() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None }), + rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "slack".to_string(), scope: Scope::Local, + cwd: PathBuf::from("/home/user/project"), message: "Execute MCP tool: mcp_slack_tool_send".to_string(), }; @@ -227,12 +230,13 @@ mod tests { fn test_policy_engine_mcp_matching_glob_allows() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None }), + rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None, dir: None }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "github".to_string(), scope: Scope::Local, + cwd: PathBuf::from("/home/user/project"), message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), }; @@ -249,12 +253,14 @@ mod tests { rule: Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User), + dir: None, }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { server: "github".to_string(), scope: Scope::Local, + cwd: PathBuf::from("/home/user/project"), message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), }; diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index c0e7e231b4..61ed7f25be 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -35,6 +35,8 @@ pub enum PermissionOperation { /// differentiate user-level (global) trust from project-local /// trust. scope: Scope, + /// The current working directory at the time of the operation. + cwd: PathBuf, message: String, }, } diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index 1e12676b5d..846c070fee 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -41,11 +41,14 @@ pub struct Fetch { } /// Rule for MCP server connection authorization matched by server-name glob, -/// optionally narrowed to one config scope. +/// optionally narrowed to one config scope and directory. /// /// When `scope` is omitted the rule applies to servers from either the /// user-level or local `.mcp.json`; specifying `user` or `local` restricts /// the rule to that scope only. +/// +/// When `dir` is omitted the rule applies regardless of working directory; +/// specifying a glob pattern restricts the rule to matching directories. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] pub struct McpRule { /// Glob over the MCP server name as it appears in `.mcp.json`. @@ -53,6 +56,9 @@ pub struct McpRule { /// Optional config-scope filter. `None` matches any scope. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, + /// Optional working directory glob pattern. `None` matches any directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir: Option, } /// Rules that define what operations are covered by a policy @@ -112,9 +118,15 @@ impl Rule { }; url_matches && dir_matches } - (Rule::Mcp(rule), PermissionOperation::Mcp { server, scope, message: _ }) => { + (Rule::Mcp(rule), PermissionOperation::Mcp { server, scope, cwd, message: _ }) => { let scope_matches = rule.scope.is_none_or(|s| s == *scope); - scope_matches && match_pattern(&rule.mcp, server) + let server_matches = match_pattern(&rule.mcp, server); + let dir_matches = match &rule.dir { + Some(wd_pattern) => match_pattern(wd_pattern, cwd), + None => true, /* If no working directory pattern is specified, it matches any + * directory */ + }; + scope_matches && server_matches && dir_matches } _ => false, } @@ -174,10 +186,15 @@ impl Display for Fetch { impl Display for McpRule { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self.scope { - Some(Scope::User) => write!(f, "mcp server '{}' (user scope)", self.mcp), - Some(Scope::Local) => write!(f, "mcp server '{}' (local scope)", self.mcp), - None => write!(f, "mcp server '{}'", self.mcp), + let base = match self.scope { + Some(Scope::User) => format!("mcp server '{}' (user scope)", self.mcp), + Some(Scope::Local) => format!("mcp server '{}' (local scope)", self.mcp), + None => format!("mcp server '{}'", self.mcp), + }; + if let Some(wd) = &self.dir { + write!(f, "{} in '{}'", base, wd) + } else { + write!(f, "{}", base) } } } @@ -245,6 +262,7 @@ mod tests { PermissionOperation::Mcp { server: "github".to_string(), scope: Scope::Local, + cwd: PathBuf::from("/home/user/project"), message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), } } @@ -369,7 +387,7 @@ mod tests { #[test] fn test_mcp_rule_exact_match() { - let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None }); + let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -379,7 +397,7 @@ mod tests { #[test] fn test_mcp_rule_glob_wildcard() { - let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None }); + let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None, dir: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -389,7 +407,7 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_other_server() { - let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), scope: None }); + let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), scope: None, dir: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -399,7 +417,7 @@ mod tests { #[test] fn test_mcp_rule_does_not_match_non_mcp_operation() { - let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), scope: None }); + let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), scope: None, dir: None }); let operation = fixture_execute_operation(); let actual = fixture.matches(&operation); @@ -410,7 +428,7 @@ mod tests { #[test] fn test_mcp_rule_scope_matches_local() { let fixture = - Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::Local) }); + Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::Local), dir: None }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); @@ -421,7 +439,73 @@ mod tests { #[test] fn test_mcp_rule_scope_filters_out_user() { let fixture = - Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User) }); + Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User), dir: None }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_rule_dir_pattern_matches() { + let fixture = Rule::Mcp(McpRule { + mcp: "*".to_string(), + scope: None, + dir: Some("/home/user/*".to_string()), + }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_dir_pattern_no_match() { + let fixture = Rule::Mcp(McpRule { + mcp: "*".to_string(), + scope: None, + dir: Some("/different/path/*".to_string()), + }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_rule_no_dir_pattern_matches_any() { + let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_combined_scope_and_dir() { + let fixture = Rule::Mcp(McpRule { + mcp: "github".to_string(), + scope: Some(Scope::Local), + dir: Some("/home/user/*".to_string()), + }); + let operation = fixture_mcp_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_rule_combined_scope_and_dir_mismatch() { + let fixture = Rule::Mcp(McpRule { + mcp: "github".to_string(), + scope: Some(Scope::Local), + dir: Some("/different/*".to_string()), + }); let operation = fixture_mcp_operation(); let actual = fixture.matches(&operation); diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 75b17af71b..5ea8e5fedf 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -203,6 +203,7 @@ where // Sequential: prompts require user input and must not hold any lock // while awaiting a response. + let cwd = self.infra.get_environment().cwd; for (scope, cfg) in scoped { for (name, server) in &cfg.mcp_servers { if server.is_disabled() || !visited.insert(name.clone()) { @@ -211,6 +212,7 @@ where let operation = PermissionOperation::Mcp { server: name.to_string(), scope, + cwd: cwd.clone(), message: format!("Connect to MCP server: {name}"), }; match self.policy.check_operation_permission(&operation).await { diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index c25a53bb58..ecfde1f3a4 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -272,13 +272,17 @@ fn create_policy_for_operation( }), } } - PermissionOperation::Mcp { server, scope, .. } => Some(Policy::Simple { + PermissionOperation::Mcp { server, scope, cwd, .. } => Some(Policy::Simple { permission: Permission::Allow, // Scope the remembered decision to the same scope that triggered // the prompt so the trust doesn't silently leak to a different // `.mcp.json` later (e.g. accepting a local-scope server should // not also auto-allow a user-scope entry with the same name). - rule: Rule::Mcp(McpRule { mcp: server.clone(), scope: Some(*scope) }), + rule: Rule::Mcp(McpRule { + mcp: server.clone(), + scope: Some(*scope), + dir: Some(cwd.to_string_lossy().to_string()), + }), }), } } @@ -467,6 +471,7 @@ mod tests { let operation = PermissionOperation::Mcp { server: "github".to_string(), scope: forge_app::domain::Scope::Local, + cwd: PathBuf::from("/home/user/project"), message: "Connect to MCP server: github".to_string(), }; @@ -477,6 +482,7 @@ mod tests { rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: Some(forge_app::domain::Scope::Local), + dir: None, }), }); From 26e571a86c4c6a9c063363b3b77335ef960ebf10 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 20:59:28 +0530 Subject: [PATCH 05/44] feat(policies): add allow_operation for explicit MCP opt-ins --- crates/forge_api/src/api.rs | 7 ++++++- crates/forge_api/src/forge_api.rs | 7 ++++++- crates/forge_app/src/services.rs | 15 +++++++++++++++ crates/forge_main/src/ui.rs | 15 +++++++++++++++ crates/forge_services/src/policy.rs | 9 +++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index aafb112d49..e4e968db6f 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use forge_app::dto::ToolsOverview; use forge_app::{User, UserUsage}; -use forge_domain::{AgentId, Effort, ModelId, ProviderModels}; +use forge_domain::{AgentId, Effort, ModelId, PermissionOperation, ProviderModels}; use forge_stream::MpscStream; use futures::stream::BoxStream; use url::Url; @@ -124,6 +124,11 @@ pub trait API: Sync + Send { /// project directory async fn write_mcp_config(&self, scope: &Scope, config: &McpConfig) -> Result<()>; + /// Unconditionally persists an allow policy for the given operation. + /// Use this when the user has explicitly opted in (e.g. via `mcp import`) + /// so no interactive confirmation is required on first use. + async fn allow_operation(&self, operation: &PermissionOperation) -> Result<()>; + /// Retrieves the provider configuration for the specified agent async fn get_agent_provider(&self, agent_id: AgentId) -> anyhow::Result>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index b56d485bfd..862a813fd0 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -8,7 +8,8 @@ use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, - ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, + PolicyService, ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, + WorkspaceService, }; use forge_config::ForgeConfig; use forge_domain::{Agent, ConsoleWriter, *}; @@ -227,6 +228,10 @@ impl< .map_err(|e| anyhow::anyhow!(e)) } + async fn allow_operation(&self, operation: &PermissionOperation) -> Result<()> { + self.services.allow_operation(operation).await + } + async fn execute_shell_command_raw( &self, command: &str, diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 78ab0ca533..3823298daf 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -485,6 +485,14 @@ pub trait PolicyService: Send + Sync { &self, operation: &forge_domain::PermissionOperation, ) -> anyhow::Result; + + /// Unconditionally persist an allow policy for the given operation. + /// Used when the user has explicitly opted in (e.g. via `mcp import`) so + /// no interactive confirmation is needed. + async fn allow_operation( + &self, + operation: &forge_domain::PermissionOperation, + ) -> anyhow::Result<()>; } /// Skill fetch service @@ -942,6 +950,13 @@ impl PolicyService for I { .check_operation_permission(operation) .await } + + async fn allow_operation( + &self, + operation: &forge_domain::PermissionOperation, + ) -> anyhow::Result<()> { + self.policy_service().allow_operation(operation).await + } } #[async_trait::async_trait] diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 15d3a8cbe4..25d5c40844 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -576,6 +576,21 @@ impl A + Send + Sync> UI // Write back to the specific scope only self.api.write_mcp_config(&scope, &scope_config).await?; + let cwd = self.api.environment().cwd; + + // Grant allow permission for each imported server so the user + // is not prompted again on first use — importing is itself an + // explicit opt-in. + for server_name in &added_servers { + let operation = forge_domain::PermissionOperation::Mcp { + server: server_name.to_string(), + scope, + cwd: cwd.clone(), + message: format!("Connect to MCP server: {server_name}"), + }; + self.api.allow_operation(&operation).await?; + } + // Log each added server after successful write for server_name in added_servers { self.writeln_title(TitleFormat::info(format!( diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index ecfde1f3a4..354db28fef 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -163,6 +163,15 @@ where + DirectoryReaderInfra + UserInfra, { + /// Unconditionally persist an allow policy for the given operation. + async fn allow_operation( + &self, + operation: &PermissionOperation, + ) -> anyhow::Result<()> { + self.add_policy_for_operation(operation).await?; + Ok(()) + } + /// Check if an operation is allowed based on policies and handle user /// confirmation async fn check_operation_permission( From 77685fa9b371911efcc47d1f606831e3a5d0c3c2 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 21:11:12 +0530 Subject: [PATCH 06/44] feat(policies): add allow_operation method for permission handling in tests --- crates/forge_services/src/mcp/service.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 5ea8e5fedf..c8d1e95ff7 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -476,6 +476,13 @@ mod tests { ) -> anyhow::Result { Ok(PolicyDecision { allowed: true, path: None }) } + + async fn allow_operation( + &self, + _operation: &PermissionOperation, + ) -> anyhow::Result<()> { + Ok(()) + } } // ── Fixture ────────────────────────────────────────────────────────────── From efa3e456038e821f51c7181a3b32661fb4d6dc82 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 12 May 2026 21:23:53 +0530 Subject: [PATCH 07/44] feat(tests): update MCP rule directory path in tests --- crates/forge_services/src/policy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 354db28fef..09a8e4dd7d 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -491,7 +491,7 @@ mod tests { rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: Some(forge_app::domain::Scope::Local), - dir: None, + dir: Some("/home/user/project".to_string()), }), }); From cd349bda12267711aff2df14d629d5f1cac23fb6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 15:57:40 +0000 Subject: [PATCH 08/44] [autofix.ci] apply automated fixes --- crates/forge_api/src/forge_api.rs | 5 ++--- crates/forge_domain/src/mcp.rs | 12 +----------- crates/forge_domain/src/policies/engine.rs | 6 +----- crates/forge_services/src/forge_services.rs | 8 ++------ crates/forge_services/src/mcp/service.rs | 11 +++-------- crates/forge_services/src/policy.rs | 5 +---- 6 files changed, 10 insertions(+), 37 deletions(-) diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 862a813fd0..002d9e0d81 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -7,9 +7,8 @@ use forge_app::dto::ToolsOverview; use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, - FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, - PolicyService, ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, - WorkspaceService, + FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, PolicyService, + ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, }; use forge_config::ForgeConfig; use forge_domain::{Agent, ConsoleWriter, *}; diff --git a/crates/forge_domain/src/mcp.rs b/crates/forge_domain/src/mcp.rs index f9e1b1e964..8ca5f3ff6c 100644 --- a/crates/forge_domain/src/mcp.rs +++ b/crates/forge_domain/src/mcp.rs @@ -13,17 +13,7 @@ use serde::{Deserialize, Serialize}; /// Which `.mcp.json` declared a server: the user-level file (global to the /// machine) or the project-local one. #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Serialize, - Deserialize, - JsonSchema, + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, )] #[serde(rename_all = "lowercase")] pub enum Scope { diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index dd8e4d9bf0..fd48bf5a15 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -250,11 +250,7 @@ mod tests { // A `scope: user` rule must not affect a Local-scope operation. let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { - mcp: "*".to_string(), - scope: Some(Scope::User), - dir: None, - }), + rule: Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User), dir: None }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index b3b120dcfa..32a5b9a379 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -30,12 +30,8 @@ use crate::tool_services::{ ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, }; -type McpService = ForgeMcpService< - ForgeMcpManager, - F, - ::Client, - ForgePolicyService, ->; +type McpService = + ForgeMcpService, F, ::Client, ForgePolicyService>; type AuthService = ForgeAuthService; /// ForgeApp is the main application container that implements the App trait. diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index c8d1e95ff7..8e9b223ed5 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -220,8 +220,7 @@ where authorized.insert(name.clone()); } Ok(_) => { - denied - .push((name.clone(), "Connection denied by user policy".to_string())); + denied.push((name.clone(), "Connection denied by user policy".to_string())); } Err(err) => { denied.push((name.clone(), format!("Policy check failed: {err:?}"))); @@ -477,18 +476,14 @@ mod tests { Ok(PolicyDecision { allowed: true, path: None }) } - async fn allow_operation( - &self, - _operation: &PermissionOperation, - ) -> anyhow::Result<()> { + async fn allow_operation(&self, _operation: &PermissionOperation) -> anyhow::Result<()> { Ok(()) } } // ── Fixture ────────────────────────────────────────────────────────────── - fn fixture() - -> ForgeMcpService { + fn fixture() -> ForgeMcpService { ForgeMcpService::new( Arc::new(MockMcpManager), Arc::new(MockInfra), diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 09a8e4dd7d..083133c501 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -164,10 +164,7 @@ where + UserInfra, { /// Unconditionally persist an allow policy for the given operation. - async fn allow_operation( - &self, - operation: &PermissionOperation, - ) -> anyhow::Result<()> { + async fn allow_operation(&self, operation: &PermissionOperation) -> anyhow::Result<()> { self.add_policy_for_operation(operation).await?; Ok(()) } From b536d2b4b582a33c29c4f21e1ae6291dbd518c38 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 11:28:05 +0530 Subject: [PATCH 09/44] feat(policies): add is_operation_permitted for non-interactive checks --- crates/forge_app/src/services.rs | 17 +++++++++++++++++ crates/forge_services/src/policy.rs | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 3823298daf..e5e0dd85b5 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -486,6 +486,16 @@ pub trait PolicyService: Send + Sync { operation: &forge_domain::PermissionOperation, ) -> anyhow::Result; + /// Check whether an operation is explicitly permitted by the current + /// policy without prompting the user. Returns `true` only when the policy + /// engine resolves to `Allow`; `Confirm` and `Deny` both return `false`. + /// Use this instead of `check_operation_permission` when interactive + /// prompting must be avoided (e.g. MCP connection authorisation). + async fn is_operation_permitted( + &self, + operation: &forge_domain::PermissionOperation, + ) -> anyhow::Result; + /// Unconditionally persist an allow policy for the given operation. /// Used when the user has explicitly opted in (e.g. via `mcp import`) so /// no interactive confirmation is needed. @@ -951,6 +961,13 @@ impl PolicyService for I { .await } + async fn is_operation_permitted( + &self, + operation: &forge_domain::PermissionOperation, + ) -> anyhow::Result { + self.policy_service().is_operation_permitted(operation).await + } + async fn allow_operation( &self, operation: &forge_domain::PermissionOperation, diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 083133c501..e3f48bb503 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -169,6 +169,18 @@ where Ok(()) } + /// Check whether an operation is explicitly permitted by the current + /// policy without prompting the user. `Confirm` is treated as not + /// permitted so callers can handle it themselves (e.g. show a warning). + async fn is_operation_permitted( + &self, + operation: &PermissionOperation, + ) -> anyhow::Result { + let (policies, _) = self.get_or_create_policies().await?; + let engine = PolicyEngine::new(&policies); + Ok(matches!(engine.can_perform(operation), Permission::Allow)) + } + /// Check if an operation is allowed based on policies and handle user /// confirmation async fn check_operation_permission( From d0f94df3457b42db62c9d6d3fd35f75c1d11bf27 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 12:30:43 +0530 Subject: [PATCH 10/44] feat(policies): replace MCP scope filtering with config-based matching --- crates/forge_domain/src/mcp.rs | 10 +- crates/forge_domain/src/policies/engine.rs | 50 ++- crates/forge_domain/src/policies/operation.rs | 17 +- crates/forge_domain/src/policies/rule.rs | 400 ++++++++++++------ crates/forge_main/src/ui.rs | 15 +- crates/forge_services/src/mcp/service.rs | 14 +- .../src/permissions.default.yaml | 14 +- crates/forge_services/src/policy.rs | 58 ++- 8 files changed, 384 insertions(+), 194 deletions(-) diff --git a/crates/forge_domain/src/mcp.rs b/crates/forge_domain/src/mcp.rs index 8ca5f3ff6c..a8f395a7c8 100644 --- a/crates/forge_domain/src/mcp.rs +++ b/crates/forge_domain/src/mcp.rs @@ -21,7 +21,7 @@ pub enum Scope { User, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(untagged)] pub enum McpServerConfig { Stdio(McpStdioServer), @@ -71,7 +71,7 @@ impl McpServerConfig { } } -#[derive(Default, Debug, Clone, Serialize, Deserialize, Setters, PartialEq, Hash)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, Setters, PartialEq, Eq, Hash)] #[setters(strip_option, into)] pub struct McpStdioServer { /// Command to execute for starting this MCP server @@ -97,7 +97,7 @@ pub struct McpStdioServer { pub disable: bool, } -#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Hash)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct McpHttpServer { /// Url of the MCP server (auto-detects HTTP vs SSE transport) #[serde(skip_serializing_if = "String::is_empty", alias = "serverUrl")] @@ -150,7 +150,7 @@ impl McpHttpServer { /// Represents the OAuth setting for an MCP server. /// Supports three states: auto-detect (default), explicitly disabled, or /// explicitly configured. -#[derive(Debug, Clone, PartialEq, Hash, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub enum McpOAuthSetting { /// No explicit OAuth config - auto-detect via server 401 response #[default] @@ -233,7 +233,7 @@ impl McpOAuthSetting { /// Supports automatic OAuth configuration discovery from server metadata. /// When auth_url/token_url are not provided, Forge will automatically /// discover them using RFC 8414 OAuth 2.0 Authorization Server Metadata. -#[derive(Default, Debug, Clone, Serialize, Deserialize, Setters, PartialEq, Hash)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, Setters, PartialEq, Eq, Hash)] #[setters(strip_option, into)] #[serde(rename_all = "camelCase")] pub struct McpOAuthConfig { diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index fd48bf5a15..f39a3f786e 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -93,9 +93,10 @@ mod tests { use pretty_assertions::assert_eq; use super::*; - use crate::mcp::Scope; + use crate::mcp::McpServerConfig; use crate::{ - ExecuteRule, Fetch, McpRule, Permission, Policy, PolicyConfig, ReadRule, Rule, WriteRule, + ExecuteRule, Fetch, McpFilter, McpRule, Permission, Policy, PolicyConfig, ReadRule, Rule, + WriteRule, }; fn fixture_workflow_with_read_policy() -> PolicyConfig { @@ -208,17 +209,22 @@ mod tests { } #[test] - fn test_policy_engine_mcp_unmatched_defaults_to_confirm() { + fn test_policy_engine_mcp_unmatched_command_defaults_to_confirm() { + // Rule targets "node" but operation uses "npx" — should not match. let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }), + rule: Rule::Mcp(McpRule { + mcp: McpFilter { + command: Some("node".to_string()), + ..McpFilter::default() + }, + }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { - server: "slack".to_string(), - scope: Scope::Local, + config: McpServerConfig::new_stdio("npx", vec![], None), cwd: PathBuf::from("/home/user/project"), - message: "Execute MCP tool: mcp_slack_tool_send".to_string(), + message: "Connect to MCP server: github".to_string(), }; let actual = fixture.can_perform(&operation); @@ -227,17 +233,21 @@ mod tests { } #[test] - fn test_policy_engine_mcp_matching_glob_allows() { + fn test_policy_engine_mcp_matching_command_glob_allows() { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None, dir: None }), + rule: Rule::Mcp(McpRule { + mcp: McpFilter { + command: Some("np*".to_string()), + ..McpFilter::default() + }, + }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { - server: "github".to_string(), - scope: Scope::Local, + config: McpServerConfig::new_stdio("npx", vec![], None), cwd: PathBuf::from("/home/user/project"), - message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + message: "Connect to MCP server: github".to_string(), }; let actual = fixture.can_perform(&operation); @@ -246,18 +256,22 @@ mod tests { } #[test] - fn test_policy_engine_mcp_scope_filter_skips_non_matching_scope() { - // A `scope: user` rule must not affect a Local-scope operation. + fn test_policy_engine_mcp_url_rule_does_not_match_stdio() { + // A url-only rule must not match a stdio server. let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, - rule: Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User), dir: None }), + rule: Rule::Mcp(McpRule { + mcp: McpFilter { + url: Some("*".to_string()), + ..McpFilter::default() + }, + }), }); let fixture = PolicyEngine::new(&fixture_workflow); let operation = PermissionOperation::Mcp { - server: "github".to_string(), - scope: Scope::Local, + config: McpServerConfig::new_stdio("npx", vec![], None), cwd: PathBuf::from("/home/user/project"), - message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + message: "Connect to MCP server: github".to_string(), }; let actual = fixture.can_perform(&operation); diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index 61ed7f25be..a1128c3030 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::mcp::Scope; +use crate::mcp::McpServerConfig; /// Operations that can be performed and need policy checking #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,16 +25,13 @@ pub enum PermissionOperation { cwd: PathBuf, message: String, }, - /// MCP server connection authorization, identified by the server name as - /// it appears in `.mcp.json`. Evaluated once per server when the MCP - /// service brings up connections; the decision then gates every tool - /// call routed through that server. + /// MCP server connection authorization. Evaluated once per server when the + /// MCP service brings up connections; the decision then gates every tool + /// call routed through that server. The `config` field carries either a + /// stdio server (command + args) or an HTTP server (url) — never both. Mcp { - server: String, - /// Which config file declared the server. Lets policy rules - /// differentiate user-level (global) trust from project-local - /// trust. - scope: Scope, + /// The server configuration — either `Stdio` (command + args) or `Http` (url). + config: McpServerConfig, /// The current working directory at the time of the operation. cwd: PathBuf, message: String, diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index 846c070fee..f0d38dff36 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -6,7 +6,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::operation::PermissionOperation; -use crate::mcp::Scope; +use crate::mcp::McpServerConfig; /// Rule for write operations with a glob pattern #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] @@ -40,27 +40,40 @@ pub struct Fetch { pub dir: Option, } -/// Rule for MCP server connection authorization matched by server-name glob, -/// optionally narrowed to one config scope and directory. -/// -/// When `scope` is omitted the rule applies to servers from either the -/// user-level or local `.mcp.json`; specifying `user` or `local` restricts -/// the rule to that scope only. -/// -/// When `dir` is omitted the rule applies regardless of working directory; -/// specifying a glob pattern restricts the rule to matching directories. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] -pub struct McpRule { - /// Glob over the MCP server name as it appears in `.mcp.json`. - pub mcp: String, - /// Optional config-scope filter. `None` matches any scope. +/// Filter criteria nested inside an [`McpRule`]. All fields are optional; +/// omitting a field means "match any value" for that dimension. Multiple +/// fields are combined with logical AND. +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct McpFilter { + /// Optional glob over the command used to launch a stdio MCP server. #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, + pub command: Option, + /// Optional glob patterns over the server's argument list (all must match). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, + /// Optional glob over the URL of an HTTP/SSE MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, /// Optional working directory glob pattern. `None` matches any directory. #[serde(default, skip_serializing_if = "Option::is_none")] pub dir: Option, } +/// Rule for MCP server connection authorization. The required `mcp` key +/// identifies this as an MCP rule (analogous to `write:`, `read:`, etc.) and +/// disambiguates it from other rule types in the untagged `Rule` enum. +/// +/// The value is an [`McpFilter`] object whose fields are all optional: +/// an empty object `{}` matches any MCP server; populating fields narrows the +/// match. Stdio servers are matched via `command`/`args`; HTTP servers via +/// `url`. Specifying both `command` and `url` will never match (a server is +/// either stdio or HTTP, not both). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct McpRule { + /// Filter criteria for the MCP server. Use `{}` to match any server. + pub mcp: McpFilter, +} + /// Rules that define what operations are covered by a policy #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] @@ -81,66 +94,79 @@ impl Rule { /// Check if this rule matches the given operation pub fn matches(&self, operation: &PermissionOperation) -> bool { match (self, operation) { - (Rule::Write(rule), PermissionOperation::Write { path, cwd, message: _ }) => { - let pattern_matches = match_pattern(&rule.write, path); - let dir = match &rule.dir { - Some(wd_pattern) => match_pattern(wd_pattern, cwd), - None => true, /* If no working directory pattern is specified, it matches any - * directory */ - }; - pattern_matches && dir + (Rule::Write(rule), PermissionOperation::Write { path, cwd, .. }) => { + match_pattern(&rule.write, path) && match_dir(&rule.dir, cwd) } - (Rule::Read(rule), PermissionOperation::Read { path, cwd, message: _ }) => { - let pattern_matches = match_pattern(&rule.read, path); - let dir_matches = match &rule.dir { - Some(wd_pattern) => match_pattern(wd_pattern, cwd), - None => true, /* If no working directory pattern is specified, it matches any - * directory */ - }; - pattern_matches && dir_matches + (Rule::Read(rule), PermissionOperation::Read { path, cwd, .. }) => { + match_pattern(&rule.read, path) && match_dir(&rule.dir, cwd) } - (Rule::Execute(rule), PermissionOperation::Execute { command: cmd, cwd }) => { - let command_matches = match_pattern(&rule.command, cmd); - let dir_matches = match &rule.dir { - Some(wd_pattern) => match_pattern(wd_pattern, cwd), - None => true, /* If no working directory pattern is specified, it matches any - * directory */ - }; - command_matches && dir_matches + match_pattern(&rule.command, cmd) && match_dir(&rule.dir, cwd) } - (Rule::Fetch(rule), PermissionOperation::Fetch { url, cwd, message: _ }) => { - let url_matches = match_pattern(&rule.url, url); - let dir_matches = match &rule.dir { - Some(wd_pattern) => match_pattern(wd_pattern, cwd), - None => true, /* If no working directory pattern is specified, it matches any - * directory */ - }; - url_matches && dir_matches + (Rule::Fetch(rule), PermissionOperation::Fetch { url, cwd, .. }) => { + match_pattern(&rule.url, url) && match_dir(&rule.dir, cwd) } - (Rule::Mcp(rule), PermissionOperation::Mcp { server, scope, cwd, message: _ }) => { - let scope_matches = rule.scope.is_none_or(|s| s == *scope); - let server_matches = match_pattern(&rule.mcp, server); - let dir_matches = match &rule.dir { - Some(wd_pattern) => match_pattern(wd_pattern, cwd), - None => true, /* If no working directory pattern is specified, it matches any - * directory */ - }; - scope_matches && server_matches && dir_matches + (Rule::Mcp(rule), PermissionOperation::Mcp { config, cwd, .. }) => { + rule.mcp.matches_config(config) && match_dir(&rule.mcp.dir, cwd) } _ => false, } } } -/// Helper function to match a glob pattern against a path or string +/// Returns true when `opt_pattern` is absent (wildcard) or matches `target`. +fn match_dir>(opt_pattern: &Option, target: P) -> bool { + opt_pattern + .as_deref() + .is_none_or(|pat| match_pattern(pat, target)) +} + +/// Returns true when `pattern` glob-matches `target`. fn match_pattern>(pattern: &str, target: P) -> bool { - match Pattern::new(pattern) { - Ok(glob_pattern) => { - let target_str = target.as_ref().to_string_lossy(); - glob_pattern.matches(&target_str) + Pattern::new(pattern).is_ok_and(|p| p.matches(&target.as_ref().to_string_lossy())) +} + +impl McpFilter { + /// Build a filter that exactly pins `config` — stdio servers are matched by + /// `command` + `args`; HTTP servers by `url`. The `dir` is always set to + /// `cwd` so the rule is scoped to the working directory. + pub fn from_config(config: &McpServerConfig, cwd: &std::path::Path) -> Self { + let dir = Some(cwd.to_string_lossy().into_owned()); + match config { + McpServerConfig::Stdio(s) => Self { + command: Some(s.command.clone()), + args: if s.args.is_empty() { None } else { Some(s.args.clone()) }, + url: None, + dir, + }, + McpServerConfig::Http(h) => { + Self { command: None, args: None, url: Some(h.url.clone()), dir } + } + } + } + + /// Returns true when this filter is compatible with `config`. + /// + /// A stdio filter (has `command`/`args`, no `url`) only matches stdio servers; + /// an HTTP filter (has `url`, no `command`/`args`) only matches HTTP servers; + /// an empty filter matches both. + fn matches_config(&self, config: &McpServerConfig) -> bool { + match config { + McpServerConfig::Stdio(s) => { + // A url-only rule must not match a stdio server + self.url.is_none() + && self.command.as_deref().is_none_or(|p| match_pattern(p, &s.command)) + && self.args.as_deref().is_none_or(|patterns| { + patterns.iter().all(|p| s.args.iter().any(|a| match_pattern(p, a))) + }) + } + McpServerConfig::Http(h) => { + // A command/args-only rule must not match an HTTP server + self.command.is_none() + && self.args.is_none() + && self.url.as_deref().is_none_or(|p| match_pattern(p, &h.url)) + } } - Err(_) => false, // Invalid pattern doesn't match anything } } @@ -186,13 +212,24 @@ impl Display for Fetch { impl Display for McpRule { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let base = match self.scope { - Some(Scope::User) => format!("mcp server '{}' (user scope)", self.mcp), - Some(Scope::Local) => format!("mcp server '{}' (local scope)", self.mcp), - None => format!("mcp server '{}'", self.mcp), + let filter = &self.mcp; + let mut parts: Vec = Vec::new(); + if let Some(cmd) = &filter.command { + parts.push(format!("command '{cmd}'")); + } + if let Some(args) = &filter.args { + parts.push(format!("args [{}]", args.join(", "))); + } + if let Some(url) = &filter.url { + parts.push(format!("url '{url}'")); + } + let base = if parts.is_empty() { + "mcp server (any)".to_string() + } else { + format!("mcp server with {}", parts.join(", ")) }; - if let Some(wd) = &self.dir { - write!(f, "{} in '{}'", base, wd) + if let Some(wd) = &filter.dir { + write!(f, "{} in '{wd}'", base) } else { write!(f, "{}", base) } @@ -218,6 +255,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::mcp::McpServerConfig; fn fixture_write_operation() -> PermissionOperation { PermissionOperation::Write { @@ -258,15 +296,30 @@ mod tests { } } - fn fixture_mcp_operation() -> PermissionOperation { + fn fixture_mcp_stdio_operation() -> PermissionOperation { PermissionOperation::Mcp { - server: "github".to_string(), - scope: Scope::Local, + config: McpServerConfig::new_stdio( + "npx", + vec!["-y".to_string(), "@github/mcp".to_string()], + None, + ), cwd: PathBuf::from("/home/user/project"), - message: "Execute MCP tool: mcp_github_tool_create_issue".to_string(), + message: "Connect to MCP server: github".to_string(), } } + fn fixture_mcp_http_operation() -> PermissionOperation { + PermissionOperation::Mcp { + config: McpServerConfig::new_http("https://mcp.example.com/sse"), + cwd: PathBuf::from("/home/user/project"), + message: "Connect to MCP server: example".to_string(), + } + } + + fn fixture_mcp_rule(filter: McpFilter) -> Rule { + Rule::Mcp(McpRule { mcp: filter }) + } + #[test] fn test_rule_matches_write_operation() { let fixture = Rule::Write(WriteRule { write: "src/**/*.rs".to_string(), dir: None }); @@ -385,10 +438,12 @@ mod tests { assert_eq!(actual, true); } + // ── MCP stdio tests ────────────────────────────────────────────────────── + #[test] - fn test_mcp_rule_exact_match() { - let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_stdio_empty_filter_matches_any_stdio() { + let fixture = fixture_mcp_rule(McpFilter::default()); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -396,9 +451,12 @@ mod tests { } #[test] - fn test_mcp_rule_glob_wildcard() { - let fixture = Rule::Mcp(McpRule { mcp: "git*".to_string(), scope: None, dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_stdio_command_exact_match() { + let fixture = fixture_mcp_rule(McpFilter { + command: Some("npx".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -406,19 +464,25 @@ mod tests { } #[test] - fn test_mcp_rule_does_not_match_other_server() { - let fixture = Rule::Mcp(McpRule { mcp: "slack".to_string(), scope: None, dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_stdio_command_glob_match() { + let fixture = fixture_mcp_rule(McpFilter { + command: Some("np*".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); - assert_eq!(actual, false); + assert_eq!(actual, true); } #[test] - fn test_mcp_rule_does_not_match_non_mcp_operation() { - let fixture = Rule::Mcp(McpRule { mcp: "*".to_string(), scope: None, dir: None }); - let operation = fixture_execute_operation(); + fn test_mcp_stdio_command_no_match() { + let fixture = fixture_mcp_rule(McpFilter { + command: Some("node".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -426,10 +490,12 @@ mod tests { } #[test] - fn test_mcp_rule_scope_matches_local() { - let fixture = - Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::Local), dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_stdio_args_match() { + let fixture = fixture_mcp_rule(McpFilter { + args: Some(vec!["@github/mcp".to_string()]), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -437,10 +503,25 @@ mod tests { } #[test] - fn test_mcp_rule_scope_filters_out_user() { - let fixture = - Rule::Mcp(McpRule { mcp: "*".to_string(), scope: Some(Scope::User), dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_stdio_args_glob_match() { + let fixture = fixture_mcp_rule(McpFilter { + args: Some(vec!["@github/*".to_string()]), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_stdio_args_no_match() { + let fixture = fixture_mcp_rule(McpFilter { + args: Some(vec!["@slack/mcp".to_string()]), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -448,13 +529,25 @@ mod tests { } #[test] - fn test_mcp_rule_dir_pattern_matches() { - let fixture = Rule::Mcp(McpRule { - mcp: "*".to_string(), - scope: None, - dir: Some("/home/user/*".to_string()), + fn test_mcp_stdio_url_rule_does_not_match_stdio_server() { + // A url-only rule must not match a stdio server + let fixture = fixture_mcp_rule(McpFilter { + url: Some("*".to_string()), + ..McpFilter::default() }); - let operation = fixture_mcp_operation(); + let operation = fixture_mcp_stdio_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + // ── MCP HTTP tests ─────────────────────────────────────────────────────── + + #[test] + fn test_mcp_http_empty_filter_matches_any_http() { + let fixture = fixture_mcp_rule(McpFilter::default()); + let operation = fixture_mcp_http_operation(); let actual = fixture.matches(&operation); @@ -462,23 +555,77 @@ mod tests { } #[test] - fn test_mcp_rule_dir_pattern_no_match() { - let fixture = Rule::Mcp(McpRule { - mcp: "*".to_string(), - scope: None, - dir: Some("/different/path/*".to_string()), + fn test_mcp_http_url_exact_match() { + let fixture = fixture_mcp_rule(McpFilter { + url: Some("https://mcp.example.com/sse".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_http_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_http_url_glob_match() { + let fixture = fixture_mcp_rule(McpFilter { + url: Some("https://mcp.example.com/*".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_http_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, true); + } + + #[test] + fn test_mcp_http_url_no_match() { + let fixture = fixture_mcp_rule(McpFilter { + url: Some("https://other.example.com/*".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_http_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_http_command_rule_does_not_match_http_server() { + // A command-only rule must not match an HTTP server + let fixture = fixture_mcp_rule(McpFilter { + command: Some("*".to_string()), + ..McpFilter::default() }); - let operation = fixture_mcp_operation(); + let operation = fixture_mcp_http_operation(); let actual = fixture.matches(&operation); assert_eq!(actual, false); } + // ── Cross-type and dir tests ───────────────────────────────────────────── + #[test] - fn test_mcp_rule_no_dir_pattern_matches_any() { - let fixture = Rule::Mcp(McpRule { mcp: "github".to_string(), scope: None, dir: None }); - let operation = fixture_mcp_operation(); + fn test_mcp_rule_does_not_match_non_mcp_operation() { + let fixture = fixture_mcp_rule(McpFilter::default()); + let operation = fixture_execute_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_dir_pattern_matches_stdio() { + let fixture = fixture_mcp_rule(McpFilter { + dir: Some("/home/user/*".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -486,13 +633,27 @@ mod tests { } #[test] - fn test_mcp_rule_combined_scope_and_dir() { - let fixture = Rule::Mcp(McpRule { - mcp: "github".to_string(), - scope: Some(Scope::Local), + fn test_mcp_dir_pattern_no_match_stdio() { + let fixture = fixture_mcp_rule(McpFilter { + dir: Some("/different/path/*".to_string()), + ..McpFilter::default() + }); + let operation = fixture_mcp_stdio_operation(); + + let actual = fixture.matches(&operation); + + assert_eq!(actual, false); + } + + #[test] + fn test_mcp_combined_command_and_dir_match() { + let fixture = fixture_mcp_rule(McpFilter { + command: Some("npx".to_string()), + args: None, + url: None, dir: Some("/home/user/*".to_string()), }); - let operation = fixture_mcp_operation(); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -500,13 +661,14 @@ mod tests { } #[test] - fn test_mcp_rule_combined_scope_and_dir_mismatch() { - let fixture = Rule::Mcp(McpRule { - mcp: "github".to_string(), - scope: Some(Scope::Local), + fn test_mcp_combined_command_and_dir_dir_mismatch() { + let fixture = fixture_mcp_rule(McpFilter { + command: Some("npx".to_string()), + args: None, + url: None, dir: Some("/different/*".to_string()), }); - let operation = fixture_mcp_operation(); + let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 25d5c40844..ad309cb9fe 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -582,13 +582,14 @@ impl A + Send + Sync> UI // is not prompted again on first use — importing is itself an // explicit opt-in. for server_name in &added_servers { - let operation = forge_domain::PermissionOperation::Mcp { - server: server_name.to_string(), - scope, - cwd: cwd.clone(), - message: format!("Connect to MCP server: {server_name}"), - }; - self.api.allow_operation(&operation).await?; + if let Some(server_config) = scope_config.mcp_servers.get(server_name) { + let operation = forge_domain::PermissionOperation::Mcp { + config: server_config.clone(), + cwd: cwd.clone(), + message: format!("Connect to MCP server: {server_name}"), + }; + self.api.allow_operation(&operation).await?; + } } // Log each added server after successful write diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 8e9b223ed5..c8ecef7de0 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -148,7 +148,7 @@ where // first because they shadow user entries of the same name in the // merged config; the `visited` set prevents prompting twice for a // duplicated name. - let authorized = self + let authorized: HashSet = self .authorize_servers([(Scope::Local, &local_cfg), (Scope::User, &user_cfg)]) .await?; @@ -204,14 +204,13 @@ where // Sequential: prompts require user input and must not hold any lock // while awaiting a response. let cwd = self.infra.get_environment().cwd; - for (scope, cfg) in scoped { + for (_scope, cfg) in scoped { for (name, server) in &cfg.mcp_servers { if server.is_disabled() || !visited.insert(name.clone()) { continue; } let operation = PermissionOperation::Mcp { - server: name.to_string(), - scope, + config: server.clone(), cwd: cwd.clone(), message: format!("Connect to MCP server: {name}"), }; @@ -476,6 +475,13 @@ mod tests { Ok(PolicyDecision { allowed: true, path: None }) } + async fn is_operation_permitted( + &self, + _operation: &PermissionOperation, + ) -> anyhow::Result { + Ok(true) + } + async fn allow_operation(&self, _operation: &PermissionOperation) -> anyhow::Result<()> { Ok(()) } diff --git a/crates/forge_services/src/permissions.default.yaml b/crates/forge_services/src/permissions.default.yaml index b545185e6a..1728e54cee 100644 --- a/crates/forge_services/src/permissions.default.yaml +++ b/crates/forge_services/src/permissions.default.yaml @@ -10,16 +10,4 @@ policies: command: "*" - permission: allow rule: - url: "*" - # MCP servers declared in the user-level ~/.forge/.mcp.json are trusted - # automatically: editing that file is itself a deliberate, global opt-in. - - permission: allow - rule: - mcp: "*" - scope: user - # MCP servers declared in the project's local .mcp.json require explicit - # per-server confirmation before the first connection. - - permission: confirm - rule: - mcp: "*" - scope: local + url: "*" \ No newline at end of file diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index e3f48bb503..f9f62e6f49 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, LazyLock}; use anyhow::Context; use bytes::Bytes; use forge_app::domain::{ - ExecuteRule, Fetch, McpRule, Permission, PermissionOperation, Policy, PolicyConfig, - PolicyEngine, ReadRule, Rule, WriteRule, + ExecuteRule, Fetch, McpFilter, McpRule, Permission, PermissionOperation, + Policy, PolicyConfig, PolicyEngine, ReadRule, Rule, WriteRule, }; use forge_app::{ DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, @@ -290,17 +290,9 @@ fn create_policy_for_operation( }), } } - PermissionOperation::Mcp { server, scope, cwd, .. } => Some(Policy::Simple { + PermissionOperation::Mcp { config, cwd, .. } => Some(Policy::Simple { permission: Permission::Allow, - // Scope the remembered decision to the same scope that triggered - // the prompt so the trust doesn't silently leak to a different - // `.mcp.json` later (e.g. accepting a local-scope server should - // not also auto-allow a user-scope entry with the same name). - rule: Rule::Mcp(McpRule { - mcp: server.clone(), - scope: Some(*scope), - dir: Some(cwd.to_string_lossy().to_string()), - }), + rule: Rule::Mcp(McpRule { mcp: McpFilter::from_config(config, cwd) }), }), } } @@ -485,10 +477,13 @@ mod tests { } #[test] - fn test_create_policy_for_mcp_operation() { + fn test_create_policy_for_mcp_stdio_operation() { let operation = PermissionOperation::Mcp { - server: "github".to_string(), - scope: forge_app::domain::Scope::Local, + config: forge_app::domain::McpServerConfig::new_stdio( + "npx", + vec!["-y".to_string(), "@github/mcp".to_string()], + None, + ), cwd: PathBuf::from("/home/user/project"), message: "Connect to MCP server: github".to_string(), }; @@ -498,9 +493,36 @@ mod tests { let expected = Some(Policy::Simple { permission: Permission::Allow, rule: Rule::Mcp(McpRule { - mcp: "github".to_string(), - scope: Some(forge_app::domain::Scope::Local), - dir: Some("/home/user/project".to_string()), + mcp: McpFilter { + command: Some("npx".to_string()), + args: Some(vec!["-y".to_string(), "@github/mcp".to_string()]), + url: None, + dir: Some("/home/user/project".to_string()), + }, + }), + }); + + assert_eq!(actual, expected); + } + + #[test] + fn test_create_policy_for_mcp_http_operation() { + let operation = PermissionOperation::Mcp { + config: forge_app::domain::McpServerConfig::new_http("https://mcp.example.com/sse"), + cwd: PathBuf::from("/home/user/project"), + message: "Connect to MCP server: example".to_string(), + }; + + let actual = create_policy_for_operation(&operation, None); + + let expected = Some(Policy::Simple { + permission: Permission::Allow, + rule: Rule::Mcp(McpRule { + mcp: McpFilter { + url: Some("https://mcp.example.com/sse".to_string()), + dir: Some("/home/user/project".to_string()), + ..McpFilter::default() + }, }), }); From 1b1376b0b4616c5cfbb52be3758aca42805c8f6a Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 13:09:23 +0530 Subject: [PATCH 11/44] feat(mcp): show permission-denied warnings for blocked servers --- crates/forge_domain/src/mcp_servers.rs | 19 ++++++++- crates/forge_main/src/ui.rs | 34 ++++++++++++---- crates/forge_services/src/mcp/service.rs | 52 ++++++++++++------------ 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/crates/forge_domain/src/mcp_servers.rs b/crates/forge_domain/src/mcp_servers.rs index 68d2b1ae8b..9ee7453bdb 100644 --- a/crates/forge_domain/src/mcp_servers.rs +++ b/crates/forge_domain/src/mcp_servers.rs @@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize}; use crate::{ServerName, ToolDefinition}; +/// Describes a single MCP server whose connection was blocked by the default +/// permission policy. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpPermissionWarning { + /// Name of the server as declared in the config file. + pub server_name: ServerName, +} + /// Cache for MCP tool definitions /// /// Simplified cache structure that stores only the essential data. @@ -18,6 +26,10 @@ pub struct McpServers { /// Failed MCP servers with their error messages #[serde(default)] failures: HashMap, + /// Servers that were denied by the permission policy, one entry per + /// blocked server. The UI uses these to emit a structured warning. + #[serde(default)] + warnings: Vec, } impl McpServers { @@ -26,7 +38,7 @@ impl McpServers { servers: HashMap>, failures: HashMap, ) -> Self { - Self { servers, failures } + Self { servers, failures, warnings: Vec::new() } } /// Get the successful servers @@ -38,6 +50,11 @@ impl McpServers { pub fn get_failures(&self) -> &HashMap { &self.failures } + + /// Get the permission-denied warnings, one entry per blocked server + pub fn get_warnings(&self) -> &[McpPermissionWarning] { + &self.warnings + } } impl IntoIterator for McpServers { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index ad309cb9fe..23046f0894 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -132,6 +132,26 @@ impl A + Send + Sync> UI self.spinner.ewrite_ln(title) } + /// Initialises MCP connections and displays a single warning that lists + /// every server blocked by the default permission policy. + async fn load_tools(&mut self) -> anyhow::Result<()> { + if let Ok(tools) = self.api.get_tools().await { + let warnings = tools.mcp.get_warnings(); + if !warnings.is_empty() { + let server_list = warnings + .iter() + .map(|w| format!(" - {}", w.server_name)) + .collect::>() + .join("\n"); + self.writeln_title(TitleFormat::warning(format!( + "Some MCP servers are not allowed to execute by default. \ + To enable them, add matching rules in permissions.yaml.\n{server_list}", + )))?; + } + } + Ok(()) + } + /// Helper to get provider for an optional agent, defaulting to the current /// active agent's provider async fn get_provider(&self, agent_id: Option) -> Result> { @@ -223,9 +243,9 @@ impl A + Send + Sync> UI self.display_banner()?; self.trace_user(); self.hydrate_caches(); - // Resolve MCP trust prompts up front — see the note on - // `hydrate_caches` for why `get_tools` must not be spawned. - let _ = self.api.get_tools().await; + // Resolve MCP connections up front and surface any permission + // warnings before control returns to the caller. + self.load_tools().await?; Ok(()) } @@ -371,11 +391,9 @@ impl A + Send + Sync> UI self.hydrate_caches(); self.init_conversation().await?; - // Resolve any pending MCP trust prompts before the REPL takes over - // stdin. `get_tools` is the entry point that triggers MCP server - // authorisation; awaiting it here ensures every `Permission::Confirm` - // dialog is answered while the main task still owns the terminal. - let _ = self.api.get_tools().await; + // Initialise MCP connections and display any permission warnings + // before the REPL takes over stdin. + self.load_tools().await?; // Check for dispatch flag first if let Some(dispatch_json) = self.cli.event.clone() { diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index c8ecef7de0..fb1c64d28c 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Context; use forge_app::domain::{ - McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, - ToolDefinition, ToolName, ToolOutput, + McpConfig, McpPermissionWarning, McpServerConfig, McpServers, PermissionOperation, Scope, + ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, }; use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, @@ -105,7 +105,7 @@ where Ok(()) } - async fn init_mcp(&self) -> anyhow::Result<()> { + async fn init_mcp(&self) -> anyhow::Result> { let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; let mut merged = user_cfg.clone(); @@ -114,7 +114,7 @@ where // Fast path: if config is unchanged, skip reinitialization without acquiring // the lock if !self.is_config_modified(&merged).await { - return Ok(()); + return Ok(vec![]); } // Serialise concurrent initialisations so only one caller runs update_mcp at a @@ -123,7 +123,7 @@ where // Double-check under the lock: a concurrent caller may have already updated if !self.is_config_modified(&merged).await { - return Ok(()); + return Ok(vec![]); } self.update_mcp(user_cfg, local_cfg, merged).await @@ -134,7 +134,7 @@ where user_cfg: McpConfig, local_cfg: McpConfig, merged: McpConfig, - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { // Compute the hash early before `merged` is consumed, but write it only // after all connections are established so waiters on init_lock see a // consistent state. @@ -148,7 +148,7 @@ where // first because they shadow user entries of the same name in the // merged config; the `visited` set prevents prompting twice for a // duplicated name. - let authorized: HashSet = self + let (authorized, warnings) = self .authorize_servers([(Scope::Local, &local_cfg), (Scope::User, &user_cfg)]) .await?; @@ -183,27 +183,26 @@ where // populated, preventing "Tool not found" races. *self.previous_config_hash.lock().await = new_hash; - Ok(()) + Ok(warnings) } /// Runs the permission policy against every enabled server in each - /// `(scope, config)` pair, returning the set of names the user - /// authorised. Scopes are processed in iteration order; a server name - /// already seen in an earlier scope is skipped so duplicates (e.g. a - /// local entry overriding a user one) prompt only for the authoritative - /// scope. Denials — from a `Deny` policy or interactive rejection — - /// are recorded in `failed_servers` with a human-readable reason. + /// `(scope, config)` pair without prompting the user. Returns the set of + /// authorised server names and a list of typed warnings for every server + /// that was denied by policy. Denials are also recorded in + /// `failed_servers`. Scopes are processed in iteration order; a server + /// name already seen in an earlier scope is skipped so the authoritative + /// scope is used when the same name appears in both configs. async fn authorize_servers<'a>( &self, scoped: impl IntoIterator, - ) -> anyhow::Result> { + ) -> anyhow::Result<(HashSet, Vec)> { let mut authorized = HashSet::new(); let mut visited: HashSet = HashSet::new(); let mut denied: Vec<(ServerName, String)> = Vec::new(); + let mut warnings: Vec = Vec::new(); - // Sequential: prompts require user input and must not hold any lock - // while awaiting a response. - let cwd = self.infra.get_environment().cwd; + let env = self.infra.get_environment(); for (_scope, cfg) in scoped { for (name, server) in &cfg.mcp_servers { if server.is_disabled() || !visited.insert(name.clone()) { @@ -211,15 +210,16 @@ where } let operation = PermissionOperation::Mcp { config: server.clone(), - cwd: cwd.clone(), + cwd: env.cwd.clone(), message: format!("Connect to MCP server: {name}"), }; - match self.policy.check_operation_permission(&operation).await { - Ok(decision) if decision.allowed => { + match self.policy.is_operation_permitted(&operation).await { + Ok(true) => { authorized.insert(name.clone()); } - Ok(_) => { - denied.push((name.clone(), "Connection denied by user policy".to_string())); + Ok(false) => { + denied.push((name.clone(), "Connection denied by policy".to_string())); + warnings.push(McpPermissionWarning { server_name: name.clone() }); } Err(err) => { denied.push((name.clone(), format!("Policy check failed: {err:?}"))); @@ -235,11 +235,11 @@ where } } - Ok(authorized) + Ok((authorized, warnings)) } async fn list(&self) -> anyhow::Result { - self.init_mcp().await?; + let warnings = self.init_mcp().await?; let tools = self.tools.read().await; let mut grouped_tools = std::collections::HashMap::new(); @@ -253,7 +253,7 @@ where let failures = self.failed_servers.read().await.clone(); - Ok(McpServers::new(grouped_tools, failures)) + Ok(McpServers::new(grouped_tools, failures).warnings(warnings)) } async fn clear_tools(&self) { self.tools.write().await.clear() From 6b276818a9b77fbc94383a897de44d88431c8fd7 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 13:11:18 +0530 Subject: [PATCH 12/44] feat(mcp): show actual permissions path in blocked server warning --- crates/forge_main/src/ui.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 23046f0894..da11ca016a 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -138,6 +138,7 @@ impl A + Send + Sync> UI if let Ok(tools) = self.api.get_tools().await { let warnings = tools.mcp.get_warnings(); if !warnings.is_empty() { + let permissions_path = self.api.environment().permissions_path(); let server_list = warnings .iter() .map(|w| format!(" - {}", w.server_name)) @@ -145,7 +146,8 @@ impl A + Send + Sync> UI .join("\n"); self.writeln_title(TitleFormat::warning(format!( "Some MCP servers are not allowed to execute by default. \ - To enable them, add matching rules in permissions.yaml.\n{server_list}", + To enable them, add matching rules in '{}'.\n{server_list}", + permissions_path.display(), )))?; } } From a207aa54966c78086ab54028c1b4bbe32f16abbc Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 14:42:49 +0530 Subject: [PATCH 13/44] feat(ui): add PolicyNotice component for policy-blocked items --- crates/forge_main/src/lib.rs | 1 + crates/forge_main/src/policy_notice.rs | 120 +++++++++++++++++++++++++ crates/forge_main/src/ui.rs | 21 +++-- 3 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 crates/forge_main/src/policy_notice.rs diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 960f0f16b1..146220ab21 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -25,6 +25,7 @@ mod utils; mod vscode; mod zsh; +mod policy_notice; mod update; use std::sync::LazyLock; diff --git a/crates/forge_main/src/policy_notice.rs b/crates/forge_main/src/policy_notice.rs new file mode 100644 index 0000000000..55252cb60b --- /dev/null +++ b/crates/forge_main/src/policy_notice.rs @@ -0,0 +1,120 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +use colored::Colorize; + +/// A single row rendered inside a [`PolicyNotice`]. +enum Row { + /// A bold label followed by a plain value on the same line. + KeyValue { label: String, value: String }, + /// A bold label followed by a comma-separated, truncated item list. + Items { label: String, items: Vec, max_display: usize }, +} + +impl Row { + fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Row::KeyValue { label, value } => { + write!(f, " {} {value}", label.bold()) + } + Row::Items { label, items, max_display } => { + let shown = items + .iter() + .take(*max_display) + .cloned() + .collect::>() + .join(", "); + let list = if items.len() > *max_display { + format!("{shown} +{} more", items.len() - max_display) + } else { + shown + }; + write!(f, " {} {list}", label.bold()) + } + } + } +} + +/// A composable terminal notice for policy-blocked items. +/// +/// Build up any combination of key-value rows and truncated item-list rows, +/// then optionally attach a docs hyperlink. The `Display` impl renders each +/// row indented, with bold labels and a dimmed docs line at the end. +/// +/// # Example +/// +/// ```rust,ignore +/// let notice = PolicyNotice::new() +/// .row("Configure permissions:", tilde_path(&permissions_path)) +/// .items("Blocked servers:", server_names, 3) +/// .docs("https://forgecode.dev/docs/permissions/"); +/// ``` +#[derive(Default)] +pub struct PolicyNotice { + rows: Vec, + docs_url: Option, +} + +impl PolicyNotice { + /// Creates an empty notice. + pub fn new() -> Self { + Self::default() + } + + /// Appends a bold-label + plain-value row. + pub fn row(mut self, label: impl Into, value: impl Into) -> Self { + self.rows.push(Row::KeyValue { label: label.into(), value: value.into() }); + self + } + + /// Appends a bold-label + truncated item-list row. + pub fn items( + mut self, + label: impl Into, + items: Vec, + max_display: usize, + ) -> Self { + self.rows + .push(Row::Items { label: label.into(), items, max_display }); + self + } + + /// Attaches a dimmed OSC 8 docs hyperlink rendered as the last line. + pub fn docs(mut self, url: impl Into) -> Self { + self.docs_url = Some(url.into()); + self + } +} + +impl fmt::Display for PolicyNotice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for row in &self.rows { + if !first { + writeln!(f)?; + } + row.render(f)?; + first = false; + } + if let Some(url) = &self.docs_url { + let link = format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); + if !first { + writeln!(f)?; + } + write!(f, " {}", format!("Docs: {link}").dimmed())?; + } + Ok(()) + } +} + +/// Abbreviates a path by replacing the home directory prefix with `~`. +pub fn tilde_path(path: &PathBuf) -> String { + if let Ok(home) = std::env::var("HOME") { + let home_path = Path::new(&home); + path.strip_prefix(home_path) + .map(|p| format!("~/{}", p.display())) + .unwrap_or_else(|_| path.display().to_string()) + } else { + path.display().to_string() + } +} diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index da11ca016a..ad113d29b9 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -39,6 +39,7 @@ use crate::display_constants::{CommandType, headers, markers, status}; use crate::editor::ReadLineError; use crate::error::UIError; use crate::info::Info; +use crate::policy_notice::{PolicyNotice, tilde_path}; use crate::input::Console; use crate::model::{AppCommand, ForgeCommandManager}; use crate::porcelain::Porcelain; @@ -139,16 +140,18 @@ impl A + Send + Sync> UI let warnings = tools.mcp.get_warnings(); if !warnings.is_empty() { let permissions_path = self.api.environment().permissions_path(); - let server_list = warnings + let server_names = warnings .iter() - .map(|w| format!(" - {}", w.server_name)) - .collect::>() - .join("\n"); - self.writeln_title(TitleFormat::warning(format!( - "Some MCP servers are not allowed to execute by default. \ - To enable them, add matching rules in '{}'.\n{server_list}", - permissions_path.display(), - )))?; + .map(|w| w.server_name.to_string()) + .collect(); + let warning = PolicyNotice::new() + .row("Configure permissions:", tilde_path(&permissions_path)) + .items("Blocked servers:", server_names, 3) + .docs("https://forgecode.dev/docs/permissions/"); + self.writeln_title(TitleFormat::warning( + "MCP servers are disabled by default.", + ))?; + self.writeln(warning.to_string())?; } } Ok(()) From 70202a517b6ac9a7855f0813ea4d3a606a36699e Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 14:55:04 +0530 Subject: [PATCH 14/44] feat(ui): allow PolicyNotice row with empty value as section header --- crates/forge_main/src/policy_notice.rs | 33 +++++++++++++++++--------- crates/forge_main/src/ui.rs | 2 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/forge_main/src/policy_notice.rs b/crates/forge_main/src/policy_notice.rs index 55252cb60b..ded57d2167 100644 --- a/crates/forge_main/src/policy_notice.rs +++ b/crates/forge_main/src/policy_notice.rs @@ -5,7 +5,8 @@ use colored::Colorize; /// A single row rendered inside a [`PolicyNotice`]. enum Row { - /// A bold label followed by a plain value on the same line. + /// A bold label followed by a plain value on the same line. If `value` is + /// empty the label is rendered alone. KeyValue { label: String, value: String }, /// A bold label followed by a comma-separated, truncated item list. Items { label: String, items: Vec, max_display: usize }, @@ -14,6 +15,9 @@ enum Row { impl Row { fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Row::KeyValue { label, value } if value.is_empty() => { + write!(f, " {}", label.bold()) + } Row::KeyValue { label, value } => { write!(f, " {} {value}", label.bold()) } @@ -37,14 +41,21 @@ impl Row { /// A composable terminal notice for policy-blocked items. /// -/// Build up any combination of key-value rows and truncated item-list rows, -/// then optionally attach a docs hyperlink. The `Display` impl renders each -/// row indented, with bold labels and a dimmed docs line at the end. +/// Build up any combination of key-value rows, plain text rows, and truncated +/// item-list rows, then optionally attach a dimmed docs hyperlink at the end. +/// The `Display` impl renders each row indented with bold labels. /// /// # Example /// /// ```rust,ignore /// let notice = PolicyNotice::new() +/// .row("To enable them, configure", tilde_path(&permissions_path)) +/// .row("See docs for permission examples:", "") +/// .text("https://forgecode.dev/docs/permissions/") +/// .items("Blocked servers:", server_names, 3); +/// +/// // Or use the built-in docs hyperlink: +/// let notice = PolicyNotice::new() /// .row("Configure permissions:", tilde_path(&permissions_path)) /// .items("Blocked servers:", server_names, 3) /// .docs("https://forgecode.dev/docs/permissions/"); @@ -52,7 +63,7 @@ impl Row { #[derive(Default)] pub struct PolicyNotice { rows: Vec, - docs_url: Option, + docs: Option, } impl PolicyNotice { @@ -61,7 +72,8 @@ impl PolicyNotice { Self::default() } - /// Appends a bold-label + plain-value row. + /// Appends a bold-label + plain-value row. Pass an empty string as `value` + /// to render the label alone (e.g. as a section header). pub fn row(mut self, label: impl Into, value: impl Into) -> Self { self.rows.push(Row::KeyValue { label: label.into(), value: value.into() }); self @@ -74,14 +86,13 @@ impl PolicyNotice { items: Vec, max_display: usize, ) -> Self { - self.rows - .push(Row::Items { label: label.into(), items, max_display }); + self.rows.push(Row::Items { label: label.into(), items, max_display }); self } - /// Attaches a dimmed OSC 8 docs hyperlink rendered as the last line. + /// Attaches a dimmed OSC 8 clickable hyperlink rendered as the last line. pub fn docs(mut self, url: impl Into) -> Self { - self.docs_url = Some(url.into()); + self.docs = Some(url.into()); self } } @@ -96,7 +107,7 @@ impl fmt::Display for PolicyNotice { row.render(f)?; first = false; } - if let Some(url) = &self.docs_url { + if let Some(url) = &self.docs { let link = format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); if !first { writeln!(f)?; diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index ad113d29b9..50531c5d68 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -145,7 +145,7 @@ impl A + Send + Sync> UI .map(|w| w.server_name.to_string()) .collect(); let warning = PolicyNotice::new() - .row("Configure permissions:", tilde_path(&permissions_path)) + .row("To enable them, configure", tilde_path(&permissions_path)) .items("Blocked servers:", server_names, 3) .docs("https://forgecode.dev/docs/permissions/"); self.writeln_title(TitleFormat::warning( From 4e80cf52ecf2162e2578e0467a5bd14de025135c Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 15:31:46 +0530 Subject: [PATCH 15/44] feat(mcp): trust user-scoped servers by default, policy only for local --- crates/forge_services/src/mcp/service.rs | 74 +++++++++++------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index fb1c64d28c..71684a4aa0 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -142,15 +142,17 @@ where self.clear_tools().await; self.failed_servers.write().await.clear(); - // Run policy authorisation against both scopes. The default policy ships - // an `allow` rule for `scope: user` and a `confirm` rule for `scope: - // local`, but users can override either. Local entries are checked - // first because they shadow user entries of the same name in the - // merged config; the `visited` set prevents prompting twice for a - // duplicated name. - let (authorized, warnings) = self - .authorize_servers([(Scope::Local, &local_cfg), (Scope::User, &user_cfg)]) - .await?; + // User-scoped servers are trusted by default — authorize without policy check. + let mut authorized: HashSet = user_cfg + .mcp_servers + .iter() + .filter(|(_, s)| !s.is_disabled()) + .map(|(name, _)| name.clone()) + .collect(); + + // Only local-scoped servers go through the policy engine. + let (local_authorized, warnings) = self.authorize_servers(&local_cfg).await?; + authorized.extend(local_authorized); let connections: Vec<_> = merged .mcp_servers @@ -186,44 +188,38 @@ where Ok(warnings) } - /// Runs the permission policy against every enabled server in each - /// `(scope, config)` pair without prompting the user. Returns the set of - /// authorised server names and a list of typed warnings for every server - /// that was denied by policy. Denials are also recorded in - /// `failed_servers`. Scopes are processed in iteration order; a server - /// name already seen in an earlier scope is skipped so the authoritative - /// scope is used when the same name appears in both configs. - async fn authorize_servers<'a>( + /// Runs the permission policy against every enabled server in `cfg` + /// without prompting the user. Returns the set of authorised server names + /// and a list of typed warnings for every server denied by policy. + /// Denials are also recorded in `failed_servers`. + async fn authorize_servers( &self, - scoped: impl IntoIterator, + cfg: &McpConfig, ) -> anyhow::Result<(HashSet, Vec)> { let mut authorized = HashSet::new(); - let mut visited: HashSet = HashSet::new(); let mut denied: Vec<(ServerName, String)> = Vec::new(); let mut warnings: Vec = Vec::new(); let env = self.infra.get_environment(); - for (_scope, cfg) in scoped { - for (name, server) in &cfg.mcp_servers { - if server.is_disabled() || !visited.insert(name.clone()) { - continue; + for (name, server) in &cfg.mcp_servers { + if server.is_disabled() { + continue; + } + let operation = PermissionOperation::Mcp { + config: server.clone(), + cwd: env.cwd.clone(), + message: format!("Connect to MCP server: {name}"), + }; + match self.policy.is_operation_permitted(&operation).await { + Ok(true) => { + authorized.insert(name.clone()); + } + Ok(false) => { + denied.push((name.clone(), "Connection denied by policy".to_string())); + warnings.push(McpPermissionWarning { server_name: name.clone() }); } - let operation = PermissionOperation::Mcp { - config: server.clone(), - cwd: env.cwd.clone(), - message: format!("Connect to MCP server: {name}"), - }; - match self.policy.is_operation_permitted(&operation).await { - Ok(true) => { - authorized.insert(name.clone()); - } - Ok(false) => { - denied.push((name.clone(), "Connection denied by policy".to_string())); - warnings.push(McpPermissionWarning { server_name: name.clone() }); - } - Err(err) => { - denied.push((name.clone(), format!("Policy check failed: {err:?}"))); - } + Err(err) => { + denied.push((name.clone(), format!("Policy check failed: {err:?}"))); } } } From 11e76624b26345b3d1f85bc7a195fcf384b4b556 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 16:04:16 +0530 Subject: [PATCH 16/44] refactor(ui): make PolicyNotice docs rows composable with labels --- crates/forge_main/src/policy_notice.rs | 43 +++++++++++--------------- crates/forge_main/src/ui.rs | 4 +-- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/forge_main/src/policy_notice.rs b/crates/forge_main/src/policy_notice.rs index ded57d2167..4910d8de81 100644 --- a/crates/forge_main/src/policy_notice.rs +++ b/crates/forge_main/src/policy_notice.rs @@ -8,6 +8,9 @@ enum Row { /// A bold label followed by a plain value on the same line. If `value` is /// empty the label is rendered alone. KeyValue { label: String, value: String }, + /// A bold label on one line followed by a dimmed OSC 8 clickable URL on + /// the next line. + Docs { label: String, url: String }, /// A bold label followed by a comma-separated, truncated item list. Items { label: String, items: Vec, max_display: usize }, } @@ -21,6 +24,10 @@ impl Row { Row::KeyValue { label, value } => { write!(f, " {} {value}", label.bold()) } + Row::Docs { label, url } => { + let link = format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); + write!(f, " {} {}", label.bold(), link.dimmed()) + } Row::Items { label, items, max_display } => { let shown = items .iter() @@ -41,29 +48,21 @@ impl Row { /// A composable terminal notice for policy-blocked items. /// -/// Build up any combination of key-value rows, plain text rows, and truncated -/// item-list rows, then optionally attach a dimmed docs hyperlink at the end. -/// The `Display` impl renders each row indented with bold labels. +/// Build up any combination of key-value rows, docs hyperlink rows, and +/// truncated item-list rows in any order. The `Display` impl renders each row +/// indented with bold labels. /// /// # Example /// /// ```rust,ignore /// let notice = PolicyNotice::new() /// .row("To enable them, configure", tilde_path(&permissions_path)) -/// .row("See docs for permission examples:", "") -/// .text("https://forgecode.dev/docs/permissions/") +/// .docs("Learn how to configure permissions:", "https://forgecode.dev/docs/permissions/") /// .items("Blocked servers:", server_names, 3); -/// -/// // Or use the built-in docs hyperlink: -/// let notice = PolicyNotice::new() -/// .row("Configure permissions:", tilde_path(&permissions_path)) -/// .items("Blocked servers:", server_names, 3) -/// .docs("https://forgecode.dev/docs/permissions/"); /// ``` #[derive(Default)] pub struct PolicyNotice { rows: Vec, - docs: Option, } impl PolicyNotice { @@ -79,6 +78,13 @@ impl PolicyNotice { self } + /// Appends a bold label on one line followed by a dimmed OSC 8 clickable + /// URL on the next line. Position in the output respects insertion order. + pub fn docs(mut self, label: impl Into, url: impl Into) -> Self { + self.rows.push(Row::Docs { label: label.into(), url: url.into() }); + self + } + /// Appends a bold-label + truncated item-list row. pub fn items( mut self, @@ -89,12 +95,6 @@ impl PolicyNotice { self.rows.push(Row::Items { label: label.into(), items, max_display }); self } - - /// Attaches a dimmed OSC 8 clickable hyperlink rendered as the last line. - pub fn docs(mut self, url: impl Into) -> Self { - self.docs = Some(url.into()); - self - } } impl fmt::Display for PolicyNotice { @@ -107,13 +107,6 @@ impl fmt::Display for PolicyNotice { row.render(f)?; first = false; } - if let Some(url) = &self.docs { - let link = format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); - if !first { - writeln!(f)?; - } - write!(f, " {}", format!("Docs: {link}").dimmed())?; - } Ok(()) } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 50531c5d68..3b48c7fbc2 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -146,8 +146,8 @@ impl A + Send + Sync> UI .collect(); let warning = PolicyNotice::new() .row("To enable them, configure", tilde_path(&permissions_path)) - .items("Blocked servers:", server_names, 3) - .docs("https://forgecode.dev/docs/permissions/"); + .docs("Learn how to configure permissions:", "https://forgecode.dev/docs/permissions/") + .items("Blocked servers:", server_names, 3); self.writeln_title(TitleFormat::warning( "MCP servers are disabled by default.", ))?; From 65e1f9cab729eed4e2a91da83cd17c74cdbb1859 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 16:14:05 +0530 Subject: [PATCH 17/44] refactor(policy_notice): use Path instead of PathBuf in tilde_path --- crates/forge_main/src/policy_notice.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/forge_main/src/policy_notice.rs b/crates/forge_main/src/policy_notice.rs index 4910d8de81..b68994cbf0 100644 --- a/crates/forge_main/src/policy_notice.rs +++ b/crates/forge_main/src/policy_notice.rs @@ -1,5 +1,5 @@ use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::Path; use colored::Colorize; @@ -12,7 +12,11 @@ enum Row { /// the next line. Docs { label: String, url: String }, /// A bold label followed by a comma-separated, truncated item list. - Items { label: String, items: Vec, max_display: usize }, + Items { + label: String, + items: Vec, + max_display: usize, + }, } impl Row { @@ -74,14 +78,16 @@ impl PolicyNotice { /// Appends a bold-label + plain-value row. Pass an empty string as `value` /// to render the label alone (e.g. as a section header). pub fn row(mut self, label: impl Into, value: impl Into) -> Self { - self.rows.push(Row::KeyValue { label: label.into(), value: value.into() }); + self.rows + .push(Row::KeyValue { label: label.into(), value: value.into() }); self } /// Appends a bold label on one line followed by a dimmed OSC 8 clickable /// URL on the next line. Position in the output respects insertion order. pub fn docs(mut self, label: impl Into, url: impl Into) -> Self { - self.rows.push(Row::Docs { label: label.into(), url: url.into() }); + self.rows + .push(Row::Docs { label: label.into(), url: url.into() }); self } @@ -92,7 +98,8 @@ impl PolicyNotice { items: Vec, max_display: usize, ) -> Self { - self.rows.push(Row::Items { label: label.into(), items, max_display }); + self.rows + .push(Row::Items { label: label.into(), items, max_display }); self } } @@ -112,7 +119,7 @@ impl fmt::Display for PolicyNotice { } /// Abbreviates a path by replacing the home directory prefix with `~`. -pub fn tilde_path(path: &PathBuf) -> String { +pub fn tilde_path(path: &Path) -> String { if let Ok(home) = std::env::var("HOME") { let home_path = Path::new(&home); path.strip_prefix(home_path) From c9ec4ef9598e9d0a054c060d343578009dcfd41c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 10:47:13 +0000 Subject: [PATCH 18/44] [autofix.ci] apply automated fixes --- crates/forge_app/src/services.rs | 4 +- crates/forge_domain/src/policies/engine.rs | 15 ++------ crates/forge_domain/src/policies/operation.rs | 3 +- crates/forge_domain/src/policies/rule.rs | 37 +++++++++++-------- crates/forge_main/src/ui.rs | 16 ++++---- crates/forge_services/src/policy.rs | 4 +- 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index e5e0dd85b5..5a990d947f 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -965,7 +965,9 @@ impl PolicyService for I { &self, operation: &forge_domain::PermissionOperation, ) -> anyhow::Result { - self.policy_service().is_operation_permitted(operation).await + self.policy_service() + .is_operation_permitted(operation) + .await } async fn allow_operation( diff --git a/crates/forge_domain/src/policies/engine.rs b/crates/forge_domain/src/policies/engine.rs index f39a3f786e..da7e342982 100644 --- a/crates/forge_domain/src/policies/engine.rs +++ b/crates/forge_domain/src/policies/engine.rs @@ -214,10 +214,7 @@ mod tests { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, rule: Rule::Mcp(McpRule { - mcp: McpFilter { - command: Some("node".to_string()), - ..McpFilter::default() - }, + mcp: McpFilter { command: Some("node".to_string()), ..McpFilter::default() }, }), }); let fixture = PolicyEngine::new(&fixture_workflow); @@ -237,10 +234,7 @@ mod tests { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, rule: Rule::Mcp(McpRule { - mcp: McpFilter { - command: Some("np*".to_string()), - ..McpFilter::default() - }, + mcp: McpFilter { command: Some("np*".to_string()), ..McpFilter::default() }, }), }); let fixture = PolicyEngine::new(&fixture_workflow); @@ -261,10 +255,7 @@ mod tests { let fixture_workflow = PolicyConfig::new().add_policy(Policy::Simple { permission: Permission::Allow, rule: Rule::Mcp(McpRule { - mcp: McpFilter { - url: Some("*".to_string()), - ..McpFilter::default() - }, + mcp: McpFilter { url: Some("*".to_string()), ..McpFilter::default() }, }), }); let fixture = PolicyEngine::new(&fixture_workflow); diff --git a/crates/forge_domain/src/policies/operation.rs b/crates/forge_domain/src/policies/operation.rs index a1128c3030..46a89d0a50 100644 --- a/crates/forge_domain/src/policies/operation.rs +++ b/crates/forge_domain/src/policies/operation.rs @@ -30,7 +30,8 @@ pub enum PermissionOperation { /// call routed through that server. The `config` field carries either a /// stdio server (command + args) or an HTTP server (url) — never both. Mcp { - /// The server configuration — either `Stdio` (command + args) or `Http` (url). + /// The server configuration — either `Stdio` (command + args) or `Http` + /// (url). config: McpServerConfig, /// The current working directory at the time of the operation. cwd: PathBuf, diff --git a/crates/forge_domain/src/policies/rule.rs b/crates/forge_domain/src/policies/rule.rs index f0d38dff36..44cf35136b 100644 --- a/crates/forge_domain/src/policies/rule.rs +++ b/crates/forge_domain/src/policies/rule.rs @@ -43,7 +43,9 @@ pub struct Fetch { /// Filter criteria nested inside an [`McpRule`]. All fields are optional; /// omitting a field means "match any value" for that dimension. Multiple /// fields are combined with logical AND. -#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] pub struct McpFilter { /// Optional glob over the command used to launch a stdio MCP server. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -135,7 +137,11 @@ impl McpFilter { match config { McpServerConfig::Stdio(s) => Self { command: Some(s.command.clone()), - args: if s.args.is_empty() { None } else { Some(s.args.clone()) }, + args: if s.args.is_empty() { + None + } else { + Some(s.args.clone()) + }, url: None, dir, }, @@ -147,17 +153,22 @@ impl McpFilter { /// Returns true when this filter is compatible with `config`. /// - /// A stdio filter (has `command`/`args`, no `url`) only matches stdio servers; - /// an HTTP filter (has `url`, no `command`/`args`) only matches HTTP servers; - /// an empty filter matches both. + /// A stdio filter (has `command`/`args`, no `url`) only matches stdio + /// servers; an HTTP filter (has `url`, no `command`/`args`) only + /// matches HTTP servers; an empty filter matches both. fn matches_config(&self, config: &McpServerConfig) -> bool { match config { McpServerConfig::Stdio(s) => { // A url-only rule must not match a stdio server self.url.is_none() - && self.command.as_deref().is_none_or(|p| match_pattern(p, &s.command)) + && self + .command + .as_deref() + .is_none_or(|p| match_pattern(p, &s.command)) && self.args.as_deref().is_none_or(|patterns| { - patterns.iter().all(|p| s.args.iter().any(|a| match_pattern(p, a))) + patterns + .iter() + .all(|p| s.args.iter().any(|a| match_pattern(p, a))) }) } McpServerConfig::Http(h) => { @@ -531,10 +542,8 @@ mod tests { #[test] fn test_mcp_stdio_url_rule_does_not_match_stdio_server() { // A url-only rule must not match a stdio server - let fixture = fixture_mcp_rule(McpFilter { - url: Some("*".to_string()), - ..McpFilter::default() - }); + let fixture = + fixture_mcp_rule(McpFilter { url: Some("*".to_string()), ..McpFilter::default() }); let operation = fixture_mcp_stdio_operation(); let actual = fixture.matches(&operation); @@ -596,10 +605,8 @@ mod tests { #[test] fn test_mcp_http_command_rule_does_not_match_http_server() { // A command-only rule must not match an HTTP server - let fixture = fixture_mcp_rule(McpFilter { - command: Some("*".to_string()), - ..McpFilter::default() - }); + let fixture = + fixture_mcp_rule(McpFilter { command: Some("*".to_string()), ..McpFilter::default() }); let operation = fixture_mcp_http_operation(); let actual = fixture.matches(&operation); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 3b48c7fbc2..4c99cb8b93 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -39,9 +39,9 @@ use crate::display_constants::{CommandType, headers, markers, status}; use crate::editor::ReadLineError; use crate::error::UIError; use crate::info::Info; -use crate::policy_notice::{PolicyNotice, tilde_path}; use crate::input::Console; use crate::model::{AppCommand, ForgeCommandManager}; +use crate::policy_notice::{PolicyNotice, tilde_path}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; use crate::state::UIState; @@ -140,17 +140,15 @@ impl A + Send + Sync> UI let warnings = tools.mcp.get_warnings(); if !warnings.is_empty() { let permissions_path = self.api.environment().permissions_path(); - let server_names = warnings - .iter() - .map(|w| w.server_name.to_string()) - .collect(); + let server_names = warnings.iter().map(|w| w.server_name.to_string()).collect(); let warning = PolicyNotice::new() .row("To enable them, configure", tilde_path(&permissions_path)) - .docs("Learn how to configure permissions:", "https://forgecode.dev/docs/permissions/") + .docs( + "Learn how to configure permissions:", + "https://forgecode.dev/docs/permissions/", + ) .items("Blocked servers:", server_names, 3); - self.writeln_title(TitleFormat::warning( - "MCP servers are disabled by default.", - ))?; + self.writeln_title(TitleFormat::warning("MCP servers are disabled by default."))?; self.writeln(warning.to_string())?; } } diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index f9f62e6f49..15774cd4ca 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, LazyLock}; use anyhow::Context; use bytes::Bytes; use forge_app::domain::{ - ExecuteRule, Fetch, McpFilter, McpRule, Permission, PermissionOperation, - Policy, PolicyConfig, PolicyEngine, ReadRule, Rule, WriteRule, + ExecuteRule, Fetch, McpFilter, McpRule, Permission, PermissionOperation, Policy, PolicyConfig, + PolicyEngine, ReadRule, Rule, WriteRule, }; use forge_app::{ DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, From b175ce447b0f45b882a311c259fea5d6d278db39 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 16:48:00 +0530 Subject: [PATCH 19/44] refactor(mcp): parallelize server permission checks --- crates/forge_services/src/mcp/service.rs | 63 +++++++++++++++++------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 71684a4aa0..970be0ce97 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use anyhow::Context; +use futures::future; use forge_app::domain::{ McpConfig, McpPermissionWarning, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, @@ -15,6 +16,13 @@ use tokio::sync::{Mutex, RwLock}; use crate::mcp::tool::McpExecutor; +/// Result of a single server authorization check. +enum AuthorizationResult { + Authorized(ServerName), + Denied(ServerName, String), + Failed(ServerName, String), +} + fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> ToolName { let sanitized_server_name = ToolName::sanitized(server_name.to_string().as_str()); let sanitized_tool_name = tool_name.clone().into_sanitized(); @@ -196,30 +204,49 @@ where &self, cfg: &McpConfig, ) -> anyhow::Result<(HashSet, Vec)> { + let env = self.infra.get_environment(); + + // Collect all enabled servers and run permission checks in parallel + let permission_futures: Vec<_> = cfg + .mcp_servers + .iter() + .filter(|(_, server)| !server.is_disabled()) + .map(|(name, server)| { + let operation = PermissionOperation::Mcp { + config: server.clone(), + cwd: env.cwd.clone(), + message: format!("Connect to MCP server: {name}"), + }; + async move { + let name = name.clone(); + match self.policy.is_operation_permitted(&operation).await { + Ok(true) => AuthorizationResult::Authorized(name), + Ok(false) => AuthorizationResult::Denied(name, "Connection denied by policy".to_string()), + Err(err) => AuthorizationResult::Failed(name, format!("Policy check failed: {err:?}")), + } + } + }) + .collect(); + + // Execute all permission checks concurrently + let results = future::join_all(permission_futures).await; + + // Collect results let mut authorized = HashSet::new(); let mut denied: Vec<(ServerName, String)> = Vec::new(); let mut warnings: Vec = Vec::new(); - let env = self.infra.get_environment(); - for (name, server) in &cfg.mcp_servers { - if server.is_disabled() { - continue; - } - let operation = PermissionOperation::Mcp { - config: server.clone(), - cwd: env.cwd.clone(), - message: format!("Connect to MCP server: {name}"), - }; - match self.policy.is_operation_permitted(&operation).await { - Ok(true) => { - authorized.insert(name.clone()); + for result in results { + match result { + AuthorizationResult::Authorized(name) => { + authorized.insert(name); } - Ok(false) => { - denied.push((name.clone(), "Connection denied by policy".to_string())); - warnings.push(McpPermissionWarning { server_name: name.clone() }); + AuthorizationResult::Denied(name, reason) => { + denied.push((name.clone(), reason)); + warnings.push(McpPermissionWarning { server_name: name }); } - Err(err) => { - denied.push((name.clone(), format!("Policy check failed: {err:?}"))); + AuthorizationResult::Failed(name, reason) => { + denied.push((name, reason)); } } } From f511f06e8eb6fe5fc2db16ec39e9d74de6983283 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 11:20:55 +0000 Subject: [PATCH 20/44] [autofix.ci] apply automated fixes --- crates/forge_services/src/mcp/service.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 970be0ce97..09129bcf8f 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -2,7 +2,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use anyhow::Context; -use futures::future; use forge_app::domain::{ McpConfig, McpPermissionWarning, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, @@ -11,6 +10,7 @@ use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, PolicyService, }; +use futures::future; use merge::Merge; use tokio::sync::{Mutex, RwLock}; @@ -221,8 +221,14 @@ where let name = name.clone(); match self.policy.is_operation_permitted(&operation).await { Ok(true) => AuthorizationResult::Authorized(name), - Ok(false) => AuthorizationResult::Denied(name, "Connection denied by policy".to_string()), - Err(err) => AuthorizationResult::Failed(name, format!("Policy check failed: {err:?}")), + Ok(false) => AuthorizationResult::Denied( + name, + "Connection denied by policy".to_string(), + ), + Err(err) => AuthorizationResult::Failed( + name, + format!("Policy check failed: {err:?}"), + ), } } }) From 454c65829819904272ff28b224ff690a6a01717d Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 17:01:59 +0530 Subject: [PATCH 21/44] fix(ui): clarify MCP warning to specify local scope servers --- crates/forge_main/src/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 4c99cb8b93..dd0f4b989c 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -148,7 +148,7 @@ impl A + Send + Sync> UI "https://forgecode.dev/docs/permissions/", ) .items("Blocked servers:", server_names, 3); - self.writeln_title(TitleFormat::warning("MCP servers are disabled by default."))?; + self.writeln_title(TitleFormat::warning("Local scope MCP servers are disabled by default."))?; self.writeln(warning.to_string())?; } } From b818ce4ab1e896164444fd69b0be03cd63f0a515 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 18:54:21 +0530 Subject: [PATCH 22/44] refactor(mcp): remove permission warning handling and related structures --- crates/forge_domain/src/mcp_servers.rs | 22 +--- crates/forge_main/src/lib.rs | 1 - crates/forge_main/src/policy_notice.rs | 131 ----------------------- crates/forge_main/src/ui.rs | 21 +--- crates/forge_services/src/mcp/service.rs | 113 ++++++++----------- 5 files changed, 47 insertions(+), 241 deletions(-) delete mode 100644 crates/forge_main/src/policy_notice.rs diff --git a/crates/forge_domain/src/mcp_servers.rs b/crates/forge_domain/src/mcp_servers.rs index 9ee7453bdb..7dbd7ad821 100644 --- a/crates/forge_domain/src/mcp_servers.rs +++ b/crates/forge_domain/src/mcp_servers.rs @@ -4,32 +4,19 @@ use serde::{Deserialize, Serialize}; use crate::{ServerName, ToolDefinition}; -/// Describes a single MCP server whose connection was blocked by the default -/// permission policy. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct McpPermissionWarning { - /// Name of the server as declared in the config file. - pub server_name: ServerName, -} - /// Cache for MCP tool definitions /// /// Simplified cache structure that stores only the essential data. /// Validation and TTL checking are handled by the infrastructure layer /// using cacache's built-in metadata capabilities. -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, derive_setters::Setters)] +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] -#[setters(strip_option, into)] pub struct McpServers { /// Successfully loaded MCP servers with their tools servers: HashMap>, /// Failed MCP servers with their error messages #[serde(default)] failures: HashMap, - /// Servers that were denied by the permission policy, one entry per - /// blocked server. The UI uses these to emit a structured warning. - #[serde(default)] - warnings: Vec, } impl McpServers { @@ -38,7 +25,7 @@ impl McpServers { servers: HashMap>, failures: HashMap, ) -> Self { - Self { servers, failures, warnings: Vec::new() } + Self { servers, failures } } /// Get the successful servers @@ -50,11 +37,6 @@ impl McpServers { pub fn get_failures(&self) -> &HashMap { &self.failures } - - /// Get the permission-denied warnings, one entry per blocked server - pub fn get_warnings(&self) -> &[McpPermissionWarning] { - &self.warnings - } } impl IntoIterator for McpServers { diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 146220ab21..960f0f16b1 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -25,7 +25,6 @@ mod utils; mod vscode; mod zsh; -mod policy_notice; mod update; use std::sync::LazyLock; diff --git a/crates/forge_main/src/policy_notice.rs b/crates/forge_main/src/policy_notice.rs deleted file mode 100644 index b68994cbf0..0000000000 --- a/crates/forge_main/src/policy_notice.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::fmt; -use std::path::Path; - -use colored::Colorize; - -/// A single row rendered inside a [`PolicyNotice`]. -enum Row { - /// A bold label followed by a plain value on the same line. If `value` is - /// empty the label is rendered alone. - KeyValue { label: String, value: String }, - /// A bold label on one line followed by a dimmed OSC 8 clickable URL on - /// the next line. - Docs { label: String, url: String }, - /// A bold label followed by a comma-separated, truncated item list. - Items { - label: String, - items: Vec, - max_display: usize, - }, -} - -impl Row { - fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Row::KeyValue { label, value } if value.is_empty() => { - write!(f, " {}", label.bold()) - } - Row::KeyValue { label, value } => { - write!(f, " {} {value}", label.bold()) - } - Row::Docs { label, url } => { - let link = format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); - write!(f, " {} {}", label.bold(), link.dimmed()) - } - Row::Items { label, items, max_display } => { - let shown = items - .iter() - .take(*max_display) - .cloned() - .collect::>() - .join(", "); - let list = if items.len() > *max_display { - format!("{shown} +{} more", items.len() - max_display) - } else { - shown - }; - write!(f, " {} {list}", label.bold()) - } - } - } -} - -/// A composable terminal notice for policy-blocked items. -/// -/// Build up any combination of key-value rows, docs hyperlink rows, and -/// truncated item-list rows in any order. The `Display` impl renders each row -/// indented with bold labels. -/// -/// # Example -/// -/// ```rust,ignore -/// let notice = PolicyNotice::new() -/// .row("To enable them, configure", tilde_path(&permissions_path)) -/// .docs("Learn how to configure permissions:", "https://forgecode.dev/docs/permissions/") -/// .items("Blocked servers:", server_names, 3); -/// ``` -#[derive(Default)] -pub struct PolicyNotice { - rows: Vec, -} - -impl PolicyNotice { - /// Creates an empty notice. - pub fn new() -> Self { - Self::default() - } - - /// Appends a bold-label + plain-value row. Pass an empty string as `value` - /// to render the label alone (e.g. as a section header). - pub fn row(mut self, label: impl Into, value: impl Into) -> Self { - self.rows - .push(Row::KeyValue { label: label.into(), value: value.into() }); - self - } - - /// Appends a bold label on one line followed by a dimmed OSC 8 clickable - /// URL on the next line. Position in the output respects insertion order. - pub fn docs(mut self, label: impl Into, url: impl Into) -> Self { - self.rows - .push(Row::Docs { label: label.into(), url: url.into() }); - self - } - - /// Appends a bold-label + truncated item-list row. - pub fn items( - mut self, - label: impl Into, - items: Vec, - max_display: usize, - ) -> Self { - self.rows - .push(Row::Items { label: label.into(), items, max_display }); - self - } -} - -impl fmt::Display for PolicyNotice { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut first = true; - for row in &self.rows { - if !first { - writeln!(f)?; - } - row.render(f)?; - first = false; - } - Ok(()) - } -} - -/// Abbreviates a path by replacing the home directory prefix with `~`. -pub fn tilde_path(path: &Path) -> String { - if let Ok(home) = std::env::var("HOME") { - let home_path = Path::new(&home); - path.strip_prefix(home_path) - .map(|p| format!("~/{}", p.display())) - .unwrap_or_else(|_| path.display().to_string()) - } else { - path.display().to_string() - } -} diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index dd0f4b989c..af6addc563 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -41,7 +41,6 @@ use crate::error::UIError; use crate::info::Info; use crate::input::Console; use crate::model::{AppCommand, ForgeCommandManager}; -use crate::policy_notice::{PolicyNotice, tilde_path}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; use crate::state::UIState; @@ -133,25 +132,9 @@ impl A + Send + Sync> UI self.spinner.ewrite_ln(title) } - /// Initialises MCP connections and displays a single warning that lists - /// every server blocked by the default permission policy. + /// Initialises MCP connections. async fn load_tools(&mut self) -> anyhow::Result<()> { - if let Ok(tools) = self.api.get_tools().await { - let warnings = tools.mcp.get_warnings(); - if !warnings.is_empty() { - let permissions_path = self.api.environment().permissions_path(); - let server_names = warnings.iter().map(|w| w.server_name.to_string()).collect(); - let warning = PolicyNotice::new() - .row("To enable them, configure", tilde_path(&permissions_path)) - .docs( - "Learn how to configure permissions:", - "https://forgecode.dev/docs/permissions/", - ) - .items("Blocked servers:", server_names, 3); - self.writeln_title(TitleFormat::warning("Local scope MCP servers are disabled by default."))?; - self.writeln(warning.to_string())?; - } - } + self.api.get_tools().await.ok(); Ok(()) } diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 09129bcf8f..68b333ab38 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -3,26 +3,18 @@ use std::sync::Arc; use anyhow::Context; use forge_app::domain::{ - McpConfig, McpPermissionWarning, McpServerConfig, McpServers, PermissionOperation, Scope, + McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, }; use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, PolicyService, }; -use futures::future; use merge::Merge; use tokio::sync::{Mutex, RwLock}; use crate::mcp::tool::McpExecutor; -/// Result of a single server authorization check. -enum AuthorizationResult { - Authorized(ServerName), - Denied(ServerName, String), - Failed(ServerName, String), -} - fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> ToolName { let sanitized_server_name = ToolName::sanitized(server_name.to_string().as_str()); let sanitized_tool_name = tool_name.clone().into_sanitized(); @@ -113,7 +105,7 @@ where Ok(()) } - async fn init_mcp(&self) -> anyhow::Result> { + async fn init_mcp(&self) -> anyhow::Result<()> { let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; let mut merged = user_cfg.clone(); @@ -122,7 +114,7 @@ where // Fast path: if config is unchanged, skip reinitialization without acquiring // the lock if !self.is_config_modified(&merged).await { - return Ok(vec![]); + return Ok(()); } // Serialise concurrent initialisations so only one caller runs update_mcp at a @@ -131,7 +123,7 @@ where // Double-check under the lock: a concurrent caller may have already updated if !self.is_config_modified(&merged).await { - return Ok(vec![]); + return Ok(()); } self.update_mcp(user_cfg, local_cfg, merged).await @@ -142,7 +134,7 @@ where user_cfg: McpConfig, local_cfg: McpConfig, merged: McpConfig, - ) -> anyhow::Result> { + ) -> anyhow::Result<()> { // Compute the hash early before `merged` is consumed, but write it only // after all connections are established so waiters on init_lock see a // consistent state. @@ -159,7 +151,7 @@ where .collect(); // Only local-scoped servers go through the policy engine. - let (local_authorized, warnings) = self.authorize_servers(&local_cfg).await?; + let local_authorized = self.authorize_servers(&local_cfg).await?; authorized.extend(local_authorized); let connections: Vec<_> = merged @@ -193,82 +185,63 @@ where // populated, preventing "Tool not found" races. *self.previous_config_hash.lock().await = new_hash; - Ok(warnings) + Ok(()) } - /// Runs the permission policy against every enabled server in `cfg` - /// without prompting the user. Returns the set of authorised server names - /// and a list of typed warnings for every server denied by policy. - /// Denials are also recorded in `failed_servers`. + /// Runs the permission policy against every enabled server in `cfg`. + /// Returns the set of authorised server names. + /// Denials are recorded in `failed_servers`. async fn authorize_servers( &self, cfg: &McpConfig, - ) -> anyhow::Result<(HashSet, Vec)> { + ) -> anyhow::Result> { let env = self.infra.get_environment(); - // Collect all enabled servers and run permission checks in parallel - let permission_futures: Vec<_> = cfg - .mcp_servers - .iter() - .filter(|(_, server)| !server.is_disabled()) - .map(|(name, server)| { - let operation = PermissionOperation::Mcp { - config: server.clone(), - cwd: env.cwd.clone(), - message: format!("Connect to MCP server: {name}"), - }; - async move { - let name = name.clone(); - match self.policy.is_operation_permitted(&operation).await { - Ok(true) => AuthorizationResult::Authorized(name), - Ok(false) => AuthorizationResult::Denied( - name, - "Connection denied by policy".to_string(), - ), - Err(err) => AuthorizationResult::Failed( - name, - format!("Policy check failed: {err:?}"), - ), + let mut authorized = HashSet::new(); + let mut failures = Vec::new(); + + for (name, server) in cfg.mcp_servers.iter().filter(|(_, s)| !s.is_disabled()) { + let detail = match server { + McpServerConfig::Stdio(s) => { + let args = s.args.join(" "); + if args.is_empty() { + s.command.clone() + } else { + format!("{} {args}", s.command) } } - }) - .collect(); - - // Execute all permission checks concurrently - let results = future::join_all(permission_futures).await; - - // Collect results - let mut authorized = HashSet::new(); - let mut denied: Vec<(ServerName, String)> = Vec::new(); - let mut warnings: Vec = Vec::new(); - - for result in results { - match result { - AuthorizationResult::Authorized(name) => { - authorized.insert(name); + McpServerConfig::Http(h) => h.url.clone(), + }; + let operation = PermissionOperation::Mcp { + config: server.clone(), + cwd: env.cwd.clone(), + message: format!("Allow MCP server \"{name}\" to connect?\n {detail}"), + }; + match self.policy.check_operation_permission(&operation).await { + Ok(decision) if decision.allowed => { + authorized.insert(name.clone()); } - AuthorizationResult::Denied(name, reason) => { - denied.push((name.clone(), reason)); - warnings.push(McpPermissionWarning { server_name: name }); + Ok(_) => { + failures.push((name.clone(), "Connection denied by policy".to_string())); } - AuthorizationResult::Failed(name, reason) => { - denied.push((name, reason)); + Err(err) => { + failures.push((name.clone(), format!("Policy check failed: {err:?}"))); } } } - if !denied.is_empty() { - let mut failures = self.failed_servers.write().await; - for (name, reason) in denied { - failures.insert(name, reason); + if !failures.is_empty() { + let mut failed = self.failed_servers.write().await; + for (name, reason) in failures { + failed.insert(name, reason); } } - Ok((authorized, warnings)) + Ok(authorized) } async fn list(&self) -> anyhow::Result { - let warnings = self.init_mcp().await?; + self.init_mcp().await?; let tools = self.tools.read().await; let mut grouped_tools = std::collections::HashMap::new(); @@ -282,7 +255,7 @@ where let failures = self.failed_servers.read().await.clone(); - Ok(McpServers::new(grouped_tools, failures).warnings(warnings)) + Ok(McpServers::new(grouped_tools, failures)) } async fn clear_tools(&self) { self.tools.write().await.clear() From e4e8050d7947374dfabac2ab0ecff521e7f0114d Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Wed, 13 May 2026 20:15:16 +0530 Subject: [PATCH 23/44] feat(ui): implement SelectPrompt for user prompts and update selection methods --- crates/forge_app/src/infra.rs | 44 +++++++++++++++++-- crates/forge_infra/src/forge_infra.rs | 4 +- crates/forge_infra/src/inquire.rs | 17 ++++--- crates/forge_main/src/ui.rs | 2 +- crates/forge_repo/src/forge_repo.rs | 8 ++-- crates/forge_select/src/select.rs | 18 +++++--- crates/forge_select/src/widget.rs | 2 +- crates/forge_services/src/mcp/service.rs | 13 +----- crates/forge_services/src/policy.rs | 34 ++++++++++---- .../src/tool_services/followup.rs | 2 +- 10 files changed, 99 insertions(+), 45 deletions(-) diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index 63d65c83a3..41e83afa09 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -164,6 +164,44 @@ pub trait CommandInfra: Send + Sync { ) -> anyhow::Result; } +/// A prompt shown to the user in a selection widget. +/// +/// `message` is the question displayed on the prompt line. +/// `header` contains zero or more lines shown as non-selectable context rows +/// above the list of options. +#[derive(Debug, Clone, Default)] +pub struct SelectPrompt { + /// The question shown on the prompt line. + pub message: String, + /// Optional context lines rendered above the selectable options. + pub header: Vec, +} + +impl SelectPrompt { + /// Creates a prompt with a message and no header lines. + pub fn new(message: impl Into) -> Self { + Self { message: message.into(), header: Vec::new() } + } + + /// Adds header lines to the prompt. + pub fn with_header(mut self, lines: impl IntoIterator>) -> Self { + self.header.extend(lines.into_iter().map(|l| l.into())); + self + } +} + +impl From<&str> for SelectPrompt { + fn from(s: &str) -> Self { + Self::new(s) + } +} + +impl From for SelectPrompt { + fn from(s: String) -> Self { + Self::new(s) + } +} + #[async_trait::async_trait] pub trait UserInfra: Send + Sync { /// Prompts the user with question @@ -174,19 +212,19 @@ pub trait UserInfra: Send + Sync { /// Returns None if the user interrupts the selection async fn select_one( &self, - message: &str, + prompt: impl Into + Send, options: Vec, ) -> anyhow::Result>; /// Prompts the user to select a single option from an enum that implements /// IntoEnumIterator Returns None if the user interrupts the selection - async fn select_one_enum(&self, message: &str) -> anyhow::Result> + async fn select_one_enum(&self, prompt: impl Into + Send) -> anyhow::Result> where T: Clone + std::fmt::Display + Send + 'static + strum::IntoEnumIterator + std::str::FromStr, ::Err: std::fmt::Debug, { let options: Vec = T::iter().collect(); - let selected = self.select_one(message, options).await?; + let selected = self.select_one(prompt, options).await?; Ok(selected) } diff --git a/crates/forge_infra/src/forge_infra.rs b/crates/forge_infra/src/forge_infra.rs index 31f5cb63e5..75dd3fa490 100644 --- a/crates/forge_infra/src/forge_infra.rs +++ b/crates/forge_infra/src/forge_infra.rs @@ -260,10 +260,10 @@ impl UserInfra for ForgeInfra { async fn select_one( &self, - message: &str, + prompt: impl Into + Send, options: Vec, ) -> anyhow::Result> { - self.inquire_service.select_one(message, options).await + self.inquire_service.select_one(prompt, options).await } async fn select_many( diff --git a/crates/forge_infra/src/inquire.rs b/crates/forge_infra/src/inquire.rs index f31b8f2a2f..d0a71d9796 100644 --- a/crates/forge_infra/src/inquire.rs +++ b/crates/forge_infra/src/inquire.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use forge_app::UserInfra; +use forge_app::{SelectPrompt, UserInfra}; use forge_select::ForgeWidget; pub struct ForgeInquire; @@ -34,16 +34,23 @@ impl UserInfra for ForgeInquire { async fn select_one( &self, - message: &str, + prompt: impl Into + Send, options: Vec, ) -> Result> { if options.is_empty() { return Ok(None); } - let message = message.to_string(); - self.prompt(move || ForgeWidget::select(&message, options).prompt()) - .await + let SelectPrompt { message, header } = prompt.into(); + self.prompt(move || { + let builder = ForgeWidget::select(&message, options); + if header.is_empty() { + builder.prompt() + } else { + builder.with_help_message(header).prompt() + } + }) + .await } async fn select_many( diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index af6addc563..8d64809e96 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3349,7 +3349,7 @@ impl A + Send + Sync> UI .collect(); match ForgeWidget::select("Select authentication method:", method_names.clone()) - .with_help_message("Use arrow keys to navigate and Enter to select") + .with_help_message(vec!["Use arrow keys to navigate and Enter to select"]) .prompt()? { Some(selected_name) => { diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 555758c7b5..1b4354244d 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -418,18 +418,18 @@ where async fn select_one( &self, - message: &str, + prompt: impl Into + Send, options: Vec, ) -> anyhow::Result> { - self.infra.select_one(message, options).await + self.infra.select_one(prompt, options).await } - async fn select_one_enum(&self, message: &str) -> anyhow::Result> + async fn select_one_enum(&self, prompt: impl Into + Send) -> anyhow::Result> where T: Clone + std::fmt::Display + Send + 'static + strum::IntoEnumIterator + std::str::FromStr, ::Err: std::fmt::Debug, { - self.infra.select_one_enum(message).await + self.infra.select_one_enum(prompt).await } async fn select_many( diff --git a/crates/forge_select/src/select.rs b/crates/forge_select/src/select.rs index ed59b9d4ea..0cbf1099df 100644 --- a/crates/forge_select/src/select.rs +++ b/crates/forge_select/src/select.rs @@ -11,7 +11,7 @@ pub struct SelectBuilder { pub(crate) options: Vec, pub(crate) starting_cursor: Option, pub(crate) default: Option, - pub(crate) help_message: Option<&'static str>, + pub(crate) help_message: Vec, pub(crate) initial_text: Option, pub(crate) header_lines: usize, pub(crate) preview: Option, @@ -43,9 +43,10 @@ impl SelectBuilder { self } - /// Set help message displayed as a header above the list. - pub fn with_help_message(mut self, message: &'static str) -> Self { - self.help_message = Some(message); + /// Set one or more header lines displayed above the list. + /// Each entry becomes a separate non-selectable header row. + pub fn with_help_message(mut self, lines: impl IntoIterator>) -> Self { + self.help_message = lines.into_iter().map(|l| l.into()).collect(); self } @@ -124,9 +125,12 @@ impl SelectBuilder { selector = selector.initial_raw(Some(cursor.to_string())); } - if let Some(help) = self.help_message { - selector.rows.insert(0, SelectRow::header(help)); - selector.header_lines = selector.header_lines.saturating_add(1); + if !self.help_message.is_empty() { + let count = self.help_message.len(); + for line in self.help_message.into_iter().rev() { + selector.rows.insert(0, SelectRow::header(line)); + } + selector.header_lines = selector.header_lines.saturating_add(count); } let selected = selector.prompt()?; diff --git a/crates/forge_select/src/widget.rs b/crates/forge_select/src/widget.rs index ac73b5cd57..075c4b56e9 100644 --- a/crates/forge_select/src/widget.rs +++ b/crates/forge_select/src/widget.rs @@ -18,7 +18,7 @@ impl ForgeWidget { options, starting_cursor: None, default: None, - help_message: None, + help_message: Vec::new(), initial_text: None, header_lines: 0, preview: None, diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 68b333ab38..1684bec449 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -201,21 +201,10 @@ where let mut failures = Vec::new(); for (name, server) in cfg.mcp_servers.iter().filter(|(_, s)| !s.is_disabled()) { - let detail = match server { - McpServerConfig::Stdio(s) => { - let args = s.args.join(" "); - if args.is_empty() { - s.command.clone() - } else { - format!("{} {args}", s.command) - } - } - McpServerConfig::Http(h) => h.url.clone(), - }; let operation = PermissionOperation::Mcp { config: server.clone(), cwd: env.cwd.clone(), - message: format!("Allow MCP server \"{name}\" to connect?\n {detail}"), + message: format!("Allow MCP server \"{name}\" to connect?"), }; match self.policy.check_operation_permission(&operation).await { Ok(decision) if decision.allowed => { diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 15774cd4ca..99984f95fe 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -9,7 +9,7 @@ use forge_app::domain::{ }; use forge_app::{ DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, - PolicyDecision, PolicyService, UserInfra, + PolicyDecision, PolicyService, SelectPrompt, UserInfra, }; use strum_macros::{Display, EnumIter}; @@ -197,27 +197,28 @@ where Permission::Allow => Ok(PolicyDecision { allowed: true, path }), Permission::Confirm => { // Request user confirmation using UserInfra - let confirmation_msg = match operation { + let prompt = match operation { PermissionOperation::Read { message, .. } => { - format!("{message}. How would you like to proceed?") + SelectPrompt::new(format!("{message}. How would you like to proceed?")) } PermissionOperation::Write { message, .. } => { - format!("{message}. How would you like to proceed?") + SelectPrompt::new(format!("{message}. How would you like to proceed?")) } PermissionOperation::Execute { .. } => { - "How would you like to proceed?".to_string() + SelectPrompt::new("How would you like to proceed?") } PermissionOperation::Fetch { message, .. } => { - format!("{message}. How would you like to proceed?") + SelectPrompt::new(format!("{message}. How would you like to proceed?")) } - PermissionOperation::Mcp { message, .. } => { - format!("{message}. How would you like to proceed?") + PermissionOperation::Mcp { message, config, .. } => { + let header = mcp_config_header(config); + SelectPrompt::new(message.clone()).with_header(header) } }; match self .infra - .select_one_enum::(&confirmation_msg) + .select_one_enum::(prompt) .await? { Some(PolicyPermission::Accept) => Ok(PolicyDecision { allowed: true, path }), @@ -234,6 +235,21 @@ where } } +/// Builds the header lines describing an MCP server's configuration. +fn mcp_config_header(config: &forge_app::domain::McpServerConfig) -> Vec { + use forge_app::domain::McpServerConfig; + match config { + McpServerConfig::Stdio(s) => { + let mut lines = vec![format!("command: {}", s.command)]; + if !s.args.is_empty() { + lines.push(format!("args: {}", s.args.join(" "))); + } + lines + } + McpServerConfig::Http(h) => vec![format!("url: {}", h.url)], + } +} + /// Create a policy for an operation based on its type fn create_policy_for_operation( operation: &PermissionOperation, diff --git a/crates/forge_services/src/tool_services/followup.rs b/crates/forge_services/src/tool_services/followup.rs index 7af2b5fc2a..b791cfb94f 100644 --- a/crates/forge_services/src/tool_services/followup.rs +++ b/crates/forge_services/src/tool_services/followup.rs @@ -39,7 +39,7 @@ impl FollowUpService for ForgeFollowup { ) }), (false, false) => inquire - .select_one(&question, options) + .select_one(question.as_str(), options) .await? .map(|selected| format!("User selected: {selected}")), }; From bfa4255ac71f54d72f383cea57581c89987d0d43 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 08:29:28 +0530 Subject: [PATCH 24/44] refactor(mcp): spawn server connections sequentially instead of joining --- crates/forge_services/src/mcp/service.rs | 82 +++++++++++++++--------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 1684bec449..57e5d78892 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -24,7 +24,6 @@ fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> Too )) } -#[derive(Clone)] pub struct ForgeMcpService { tools: Arc>>>>, failed_servers: Arc>>, @@ -35,6 +34,20 @@ pub struct ForgeMcpService { policy: Arc

, } +impl Clone for ForgeMcpService { + fn clone(&self) -> Self { + ForgeMcpService { + tools: Arc::clone(&self.tools), + failed_servers: Arc::clone(&self.failed_servers), + previous_config_hash: Arc::clone(&self.previous_config_hash), + init_lock: Arc::clone(&self.init_lock), + manager: Arc::clone(&self.manager), + infra: Arc::clone(&self.infra), + policy: Arc::clone(&self.policy), + } + } +} + #[derive(Clone)] struct ToolHolder { definition: ToolDefinition, @@ -44,11 +57,11 @@ struct ToolHolder { impl ForgeMcpService where - M: McpConfigManager, - I: McpServerInfra + KVStore + EnvironmentInfra, + M: McpConfigManager + 'static, + I: McpServerInfra + KVStore + EnvironmentInfra + 'static, C: McpClientInfra + Clone, C: From<::Client>, - P: PolicyService, + P: PolicyService + 'static, { pub fn new(manager: Arc, infra: Arc, policy: Arc

) -> Self { Self { @@ -126,11 +139,12 @@ where return Ok(()); } - self.update_mcp(user_cfg, local_cfg, merged).await + // Pass owned clone for fire-and-forget spawn + self.clone().update_mcp(user_cfg, local_cfg, merged).await } async fn update_mcp( - &self, + self, user_cfg: McpConfig, local_cfg: McpConfig, merged: McpConfig, @@ -154,36 +168,37 @@ where let local_authorized = self.authorize_servers(&local_cfg).await?; authorized.extend(local_authorized); - let connections: Vec<_> = merged + // Clone self before spawning to avoid lifetime issues + let service = self.clone(); + let previous_config_hash = Arc::clone(&service.previous_config_hash); + let failed_servers = Arc::clone(&service.failed_servers); + let mcp_servers = merged .mcp_servers .into_iter() .filter(|(name, server)| !server.is_disabled() && authorized.contains(name)) - .map(|(name, server)| async move { - let conn = self - .connect(&name, server) - .await + .collect::>(); + let new_hash = new_hash; + + tokio::spawn(async move { + // Connect to each server sequentially and collect results + let mut results = Vec::with_capacity(mcp_servers.len()); + for (name, server) in mcp_servers { + let result = service.connect(&name, server).await .context(format!("Failed to initiate MCP server: {name}")); + results.push((name, result)); + } - (name, conn) - }) - .collect(); - - let results = futures::future::join_all(connections).await; - - for (server_name, result) in results { - if let Err(error) = result { - // Debug formatting preserves the full error chain for diagnostics. - self.failed_servers - .write() - .await - .insert(server_name, format!("{error:?}")); + // Record failures + for (server_name, result) in results { + if let Err(error) = result { + failed_servers.write().await.insert(server_name, format!("{error:?}")); + } } - } - // Write the hash only after join_all finishes so that any waiter on - // init_lock re-checks is_config_modified only once self.tools is fully - // populated, preventing "Tool not found" races. - *self.previous_config_hash.lock().await = new_hash; + // Write the hash only after all connections complete so waiters see + // fully populated tools, preventing "Tool not found" races. + *previous_config_hash.lock().await = new_hash; + }); Ok(()) } @@ -287,11 +302,11 @@ where #[async_trait::async_trait] impl McpService for ForgeMcpService where - M: McpConfigManager, - I: McpServerInfra + KVStore + EnvironmentInfra, + M: McpConfigManager + 'static, + I: McpServerInfra + KVStore + EnvironmentInfra + 'static, C: McpClientInfra + Clone, C: From<::Client>, - P: PolicyService, + P: PolicyService + 'static, { async fn get_mcp_servers(&self) -> anyhow::Result { // Read current configs to compute merged hash @@ -546,6 +561,9 @@ mod tests { r1.unwrap(); r2.unwrap(); + // Wait for background initialization tasks to complete + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let servers = service.get_mcp_servers().await.unwrap(); let tool_name = servers .get_servers() From cbbe56cdb7dde3dc2382974fe8fcaa68ba3f2d10 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:12:52 +0530 Subject: [PATCH 25/44] refactor(mcp): only cache servers when permissions are persisted --- crates/forge_services/src/mcp/service.rs | 45 +++++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 57e5d78892..f8f60726c7 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -177,7 +177,6 @@ where .into_iter() .filter(|(name, server)| !server.is_disabled() && authorized.contains(name)) .collect::>(); - let new_hash = new_hash; tokio::spawn(async move { // Connect to each server sequentially and collect results @@ -281,6 +280,23 @@ where tool.executable.call_tool(call.arguments.parse()?).await } + /// Returns `true` if any enabled server in `cfg` does not have a + /// persisted `Allow` policy, meaning a permission prompt would be required. + async fn has_servers_requiring_permission(&self, cfg: &McpConfig) -> anyhow::Result { + let env = self.infra.get_environment(); + for (name, server) in cfg.mcp_servers.iter().filter(|(_, s)| !s.is_disabled()) { + let operation = PermissionOperation::Mcp { + config: server.clone(), + cwd: env.cwd.clone(), + message: format!("Allow MCP server \"{name}\" to connect?"), + }; + if !self.policy.is_operation_permitted(&operation).await? { + return Ok(true); + } + } + Ok(false) + } + /// Refresh the MCP cache by clearing cached data. /// Does NOT eagerly connect to servers - connections happen lazily /// when list() or call() is invoked, avoiding interactive OAuth during @@ -310,19 +326,30 @@ where { async fn get_mcp_servers(&self) -> anyhow::Result { // Read current configs to compute merged hash - let mcp_config = self.manager.read_mcp_config(None).await?; + let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; + let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; + let config_hash = user_cfg.cache_key(); - // Compute unified hash from merged config - let config_hash = mcp_config.cache_key(); + // Skip the cache if any servers require permission confirmation. + // Local-scoped servers always require permission re-verification. + // User-scoped servers that were accepted only once (not "Accept and Remember") + // do not have a persisted Allow policy, so they must prompt again. + let needs_permission_check = self.has_servers_requiring_permission(&local_cfg).await?; - // Check if cache is valid (exists and not expired) - // Cache is valid, retrieve it - if let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { - return Ok(cache.clone()); + if !needs_permission_check { + if let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { + return Ok(cache.clone()); + } } let servers = self.list().await?; - self.infra.cache_set(&config_hash, &servers).await?; + + // Only cache when all servers have explicit persisted Allow policies so + // we never silently skip a required permission prompt on the next call. + if !needs_permission_check { + self.infra.cache_set(&config_hash, &servers).await?; + } + Ok(servers) } From 808245f23296aa9452d2ccfbda60e8b463aa01d0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 04:45:39 +0000 Subject: [PATCH 26/44] [autofix.ci] apply automated fixes --- crates/forge_app/src/infra.rs | 5 ++++- crates/forge_repo/src/forge_repo.rs | 5 ++++- crates/forge_services/src/mcp/service.rs | 23 ++++++++++++----------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index 41e83afa09..c9a3d0c71e 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -218,7 +218,10 @@ pub trait UserInfra: Send + Sync { /// Prompts the user to select a single option from an enum that implements /// IntoEnumIterator Returns None if the user interrupts the selection - async fn select_one_enum(&self, prompt: impl Into + Send) -> anyhow::Result> + async fn select_one_enum( + &self, + prompt: impl Into + Send, + ) -> anyhow::Result> where T: Clone + std::fmt::Display + Send + 'static + strum::IntoEnumIterator + std::str::FromStr, ::Err: std::fmt::Debug, diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 1b4354244d..6e2f7d55bf 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -424,7 +424,10 @@ where self.infra.select_one(prompt, options).await } - async fn select_one_enum(&self, prompt: impl Into + Send) -> anyhow::Result> + async fn select_one_enum( + &self, + prompt: impl Into + Send, + ) -> anyhow::Result> where T: Clone + std::fmt::Display + Send + 'static + strum::IntoEnumIterator + std::str::FromStr, ::Err: std::fmt::Debug, diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index f8f60726c7..f057c2d2b7 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Context; use forge_app::domain::{ - McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, - ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, + McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, + ToolDefinition, ToolName, ToolOutput, }; use forge_app::{ EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, @@ -182,7 +182,9 @@ where // Connect to each server sequentially and collect results let mut results = Vec::with_capacity(mcp_servers.len()); for (name, server) in mcp_servers { - let result = service.connect(&name, server).await + let result = service + .connect(&name, server) + .await .context(format!("Failed to initiate MCP server: {name}")); results.push((name, result)); } @@ -190,7 +192,10 @@ where // Record failures for (server_name, result) in results { if let Err(error) = result { - failed_servers.write().await.insert(server_name, format!("{error:?}")); + failed_servers + .write() + .await + .insert(server_name, format!("{error:?}")); } } @@ -205,10 +210,7 @@ where /// Runs the permission policy against every enabled server in `cfg`. /// Returns the set of authorised server names. /// Denials are recorded in `failed_servers`. - async fn authorize_servers( - &self, - cfg: &McpConfig, - ) -> anyhow::Result> { + async fn authorize_servers(&self, cfg: &McpConfig) -> anyhow::Result> { let env = self.infra.get_environment(); let mut authorized = HashSet::new(); @@ -336,11 +338,10 @@ where // do not have a persisted Allow policy, so they must prompt again. let needs_permission_check = self.has_servers_requiring_permission(&local_cfg).await?; - if !needs_permission_check { - if let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { + if !needs_permission_check + && let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { return Ok(cache.clone()); } - } let servers = self.list().await?; From f5bb7122e6d9c39706648822118262231995dddb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 04:48:04 +0000 Subject: [PATCH 27/44] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_services/src/mcp/service.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index f057c2d2b7..7d14b76a01 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -339,9 +339,10 @@ where let needs_permission_check = self.has_servers_requiring_permission(&local_cfg).await?; if !needs_permission_check - && let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { - return Ok(cache.clone()); - } + && let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? + { + return Ok(cache.clone()); + } let servers = self.list().await?; From 506576a5eaf3a914f2a49f1de0a027adb6dadaf7 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:38:29 +0530 Subject: [PATCH 28/44] refactor(ui): remove load_tools wrapper method --- crates/forge_main/src/ui.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 8d64809e96..966332be03 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -133,10 +133,6 @@ impl A + Send + Sync> UI } /// Initialises MCP connections. - async fn load_tools(&mut self) -> anyhow::Result<()> { - self.api.get_tools().await.ok(); - Ok(()) - } /// Helper to get provider for an optional agent, defaulting to the current /// active agent's provider @@ -231,7 +227,7 @@ impl A + Send + Sync> UI self.hydrate_caches(); // Resolve MCP connections up front and surface any permission // warnings before control returns to the caller. - self.load_tools().await?; + let _ = self.api.get_tools().await; Ok(()) } @@ -379,7 +375,7 @@ impl A + Send + Sync> UI // Initialise MCP connections and display any permission warnings // before the REPL takes over stdin. - self.load_tools().await?; + let _ = self.api.get_tools().await; // Check for dispatch flag first if let Some(dispatch_json) = self.cli.event.clone() { From c50d165b49b4747165de5c31863560404059561a Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:40:38 +0530 Subject: [PATCH 29/44] refactor(ui): remove obsolete MCP initialization comment --- crates/forge_main/src/ui.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 966332be03..9650a70b57 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -132,8 +132,6 @@ impl A + Send + Sync> UI self.spinner.ewrite_ln(title) } - /// Initialises MCP connections. - /// Helper to get provider for an optional agent, defaulting to the current /// active agent's provider async fn get_provider(&self, agent_id: Option) -> Result> { From 14e7ab9da2d46bb7c93ffe3a73ec51f16bf7d09c Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:45:57 +0530 Subject: [PATCH 30/44] refactor(mcp): use merged config for cache key computation --- crates/forge_services/src/mcp/service.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 7d14b76a01..77e776a442 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -327,10 +327,11 @@ where P: PolicyService + 'static, { async fn get_mcp_servers(&self) -> anyhow::Result { - // Read current configs to compute merged hash let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; - let config_hash = user_cfg.cache_key(); + let mut merged_cfg = user_cfg.clone(); + merged_cfg.merge(local_cfg.clone()); + let config_hash = merged_cfg.cache_key(); // Skip the cache if any servers require permission confirmation. // Local-scoped servers always require permission re-verification. From 622cf8663a955a88bc01c92d20560f35d78ed281 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:47:58 +0530 Subject: [PATCH 31/44] refactor(mcp): remove redundant comments from service cache logic --- crates/forge_services/src/mcp/service.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 77e776a442..980c15b9fe 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -333,10 +333,6 @@ where merged_cfg.merge(local_cfg.clone()); let config_hash = merged_cfg.cache_key(); - // Skip the cache if any servers require permission confirmation. - // Local-scoped servers always require permission re-verification. - // User-scoped servers that were accepted only once (not "Accept and Remember") - // do not have a persisted Allow policy, so they must prompt again. let needs_permission_check = self.has_servers_requiring_permission(&local_cfg).await?; if !needs_permission_check @@ -347,8 +343,6 @@ where let servers = self.list().await?; - // Only cache when all servers have explicit persisted Allow policies so - // we never silently skip a required permission prompt on the next call. if !needs_permission_check { self.infra.cache_set(&config_hash, &servers).await?; } From 903d7fa11725930156b716084d1a43352f9752b1 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 10:56:34 +0530 Subject: [PATCH 32/44] refactor(infra): make with_header replace instead of extend --- crates/forge_app/src/infra.rs | 4 ++-- crates/forge_services/src/permissions.default.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index c9a3d0c71e..ff884464ae 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -183,9 +183,9 @@ impl SelectPrompt { Self { message: message.into(), header: Vec::new() } } - /// Adds header lines to the prompt. + /// Sets the header lines of the prompt, replacing any previously set lines. pub fn with_header(mut self, lines: impl IntoIterator>) -> Self { - self.header.extend(lines.into_iter().map(|l| l.into())); + self.header = lines.into_iter().map(|l| l.into()).collect(); self } } diff --git a/crates/forge_services/src/permissions.default.yaml b/crates/forge_services/src/permissions.default.yaml index 1728e54cee..aeca231ebd 100644 --- a/crates/forge_services/src/permissions.default.yaml +++ b/crates/forge_services/src/permissions.default.yaml @@ -10,4 +10,4 @@ policies: command: "*" - permission: allow rule: - url: "*" \ No newline at end of file + url: "*" From 0a1e4f62a49bee882d61ad09e2d652549a113ea4 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 11:00:00 +0530 Subject: [PATCH 33/44] refactor(policy): simplify allow_operation return handling --- crates/forge_services/src/policy.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 99984f95fe..7eccca11b8 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -165,8 +165,7 @@ where { /// Unconditionally persist an allow policy for the given operation. async fn allow_operation(&self, operation: &PermissionOperation) -> anyhow::Result<()> { - self.add_policy_for_operation(operation).await?; - Ok(()) + self.add_policy_for_operation(operation).await.map(|_| ()) } /// Check whether an operation is explicitly permitted by the current From ad2818cdaa2dd816702741b9643efbf969fe9428 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 11:18:54 +0530 Subject: [PATCH 34/44] refactor(mcp): remove JsonSchema derive from Scope enum --- crates/forge_domain/src/mcp.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/forge_domain/src/mcp.rs b/crates/forge_domain/src/mcp.rs index a8f395a7c8..809bc073a3 100644 --- a/crates/forge_domain/src/mcp.rs +++ b/crates/forge_domain/src/mcp.rs @@ -7,15 +7,9 @@ use std::ops::Deref; use derive_more::{Deref, Display, From}; use derive_setters::Setters; use merge::Merge; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Which `.mcp.json` declared a server: the user-level file (global to the -/// machine) or the project-local one. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Scope { Local, User, From 2517ce903c3ec184c9ff2f9ae2c1e9446b175adf Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 11:58:11 +0530 Subject: [PATCH 35/44] feat(policy): persist MCP connection permission decisions --- crates/forge_services/src/mcp/service.rs | 3 -- crates/forge_services/src/policy.rs | 45 ++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 980c15b9fe..5e7fe22b4f 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -585,9 +585,6 @@ mod tests { r1.unwrap(); r2.unwrap(); - // Wait for background initialization tasks to complete - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let servers = service.get_mcp_servers().await.unwrap(); let tool_name = servers .get_servers() diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 7eccca11b8..080eba5f68 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -27,6 +27,19 @@ pub enum PolicyPermission { AcceptAndRemember, } +/// Two-choice prompt used exclusively for MCP server connections. +/// Both choices are persisted so the user is not re-prompted on subsequent +/// starts. +#[derive(Debug, Clone, PartialEq, Eq, Display, EnumIter, strum_macros::EnumString)] +enum McpPermission { + /// Allow this MCP server to connect and persist the decision + #[strum(to_string = "Accept")] + Accept, + /// Deny this MCP server and persist the decision + #[strum(to_string = "Reject")] + Reject, +} + pub struct ForgePolicyService { infra: Arc, } @@ -209,9 +222,37 @@ where PermissionOperation::Fetch { message, .. } => { SelectPrompt::new(format!("{message}. How would you like to proceed?")) } - PermissionOperation::Mcp { message, config, .. } => { + PermissionOperation::Mcp { message, config, cwd } => { let header = mcp_config_header(config); - SelectPrompt::new(message.clone()).with_header(header) + let prompt = SelectPrompt::new(message.clone()).with_header(header); + return match self + .infra + .select_one_enum::(prompt) + .await? + { + Some(McpPermission::Accept) => { + let update_path = + self.add_policy_for_operation(operation).await?; + Ok(PolicyDecision { allowed: true, path: update_path.or(path) }) + } + Some(McpPermission::Reject) | None => { + let deny_policy = Policy::Simple { + permission: Permission::Deny, + rule: forge_app::domain::Rule::Mcp( + forge_app::domain::McpRule { + mcp: forge_app::domain::McpFilter::from_config( + config, cwd, + ), + }, + ), + }; + self.modify_policy(deny_policy).await?; + Ok(PolicyDecision { + allowed: false, + path: Some(self.permissions_path()), + }) + } + }; } }; From e1656e41e0aa223d2d192363d88dd3fe02a8d424 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 06:34:27 +0000 Subject: [PATCH 36/44] [autofix.ci] apply automated fixes --- crates/forge_services/src/policy.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 080eba5f68..735714a48e 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -225,14 +225,9 @@ where PermissionOperation::Mcp { message, config, cwd } => { let header = mcp_config_header(config); let prompt = SelectPrompt::new(message.clone()).with_header(header); - return match self - .infra - .select_one_enum::(prompt) - .await? - { + return match self.infra.select_one_enum::(prompt).await? { Some(McpPermission::Accept) => { - let update_path = - self.add_policy_for_operation(operation).await?; + let update_path = self.add_policy_for_operation(operation).await?; Ok(PolicyDecision { allowed: true, path: update_path.or(path) }) } Some(McpPermission::Reject) | None => { From ce023b7aee128daa254477ce90d2024cb9ba5a47 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 12:10:16 +0530 Subject: [PATCH 37/44] refactor(mcp): use join_all instead of spawn for connection handling --- crates/forge_services/src/mcp/service.rs | 50 ++++++++++-------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 5e7fe22b4f..35a78e28c4 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -139,12 +139,11 @@ where return Ok(()); } - // Pass owned clone for fire-and-forget spawn - self.clone().update_mcp(user_cfg, local_cfg, merged).await + self.update_mcp(user_cfg, local_cfg, merged).await } async fn update_mcp( - self, + &self, user_cfg: McpConfig, local_cfg: McpConfig, merged: McpConfig, @@ -168,41 +167,34 @@ where let local_authorized = self.authorize_servers(&local_cfg).await?; authorized.extend(local_authorized); - // Clone self before spawning to avoid lifetime issues - let service = self.clone(); - let previous_config_hash = Arc::clone(&service.previous_config_hash); - let failed_servers = Arc::clone(&service.failed_servers); - let mcp_servers = merged + let connections: Vec<_> = merged .mcp_servers .into_iter() .filter(|(name, server)| !server.is_disabled() && authorized.contains(name)) - .collect::>(); - - tokio::spawn(async move { - // Connect to each server sequentially and collect results - let mut results = Vec::with_capacity(mcp_servers.len()); - for (name, server) in mcp_servers { - let result = service + .map(|(name, server)| async move { + let conn = self .connect(&name, server) .await .context(format!("Failed to initiate MCP server: {name}")); - results.push((name, result)); - } + (name, conn) + }) + .collect(); - // Record failures - for (server_name, result) in results { - if let Err(error) = result { - failed_servers - .write() - .await - .insert(server_name, format!("{error:?}")); - } + let results = futures::future::join_all(connections).await; + + for (server_name, result) in results { + if let Err(error) = result { + self.failed_servers + .write() + .await + .insert(server_name, format!("{error:?}")); } + } - // Write the hash only after all connections complete so waiters see - // fully populated tools, preventing "Tool not found" races. - *previous_config_hash.lock().await = new_hash; - }); + // Write the hash only after join_all finishes so that any waiter on + // init_lock re-checks is_config_modified only once self.tools is fully + // populated, preventing "Tool not found" races. + *self.previous_config_hash.lock().await = new_hash; Ok(()) } From a45235f209f86b151c254b8de08910241235e58a Mon Sep 17 00:00:00 2001 From: laststylebender <43403528+laststylebender14@users.noreply.github.com> Date: Thu, 14 May 2026 13:56:14 +0530 Subject: [PATCH 38/44] ext: ask permissions first approach (#3334) --- crates/forge_api/src/api.rs | 14 +- crates/forge_api/src/forge_api.rs | 19 +- crates/forge_app/src/lib.rs | 2 + crates/forge_app/src/mcp_app.rs | 110 ++++++++ crates/forge_app/src/mcp_executor.rs | 10 +- crates/forge_app/src/services.rs | 19 +- crates/forge_app/src/tool_registry.rs | 4 +- crates/forge_main/src/ui.rs | 40 ++- crates/forge_services/src/forge_services.rs | 9 +- crates/forge_services/src/mcp/service.rs | 262 ++++---------------- crates/forge_services/src/policy.rs | 26 +- 11 files changed, 239 insertions(+), 276 deletions(-) create mode 100644 crates/forge_app/src/mcp_app.rs diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index e4e968db6f..a55f3496f8 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use forge_app::dto::ToolsOverview; use forge_app::{User, UserUsage}; -use forge_domain::{AgentId, Effort, ModelId, PermissionOperation, ProviderModels}; +use forge_domain::{AgentId, Effort, ModelId, ProviderModels}; use forge_stream::MpscStream; use futures::stream::BoxStream; use url::Url; @@ -124,10 +124,14 @@ pub trait API: Sync + Send { /// project directory async fn write_mcp_config(&self, scope: &Scope, config: &McpConfig) -> Result<()>; - /// Unconditionally persists an allow policy for the given operation. - /// Use this when the user has explicitly opted in (e.g. via `mcp import`) - /// so no interactive confirmation is required on first use. - async fn allow_operation(&self, operation: &PermissionOperation) -> Result<()>; + /// Prompts for missing permissions for each enabled server in `cfg`. + /// Idempotent — servers with existing decisions are skipped. + /// Call this synchronously at startup before the REPL takes over stdin. + async fn request_mcp_permissions(&self, cfg: McpConfig) -> Result<()>; + + /// Persist `Allow` decisions for the named servers without prompting. + /// Used by `mcp import` to record consent on the user's behalf. + async fn allow_mcp_servers(&self, names: &[ServerName]) -> Result<()>; /// Retrieves the provider configuration for the specified agent async fn get_agent_provider(&self, agent_id: AgentId) -> anyhow::Result>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 002d9e0d81..23efad7222 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -7,7 +7,7 @@ use forge_app::dto::ToolsOverview; use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, - FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, PolicyService, + FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpApp, McpConfigManager, McpService, ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, }; use forge_config::ForgeConfig; @@ -39,6 +39,15 @@ impl ForgeAPI { { ForgeApp::new(self.services.clone()) } + + /// Creates an McpApp instance for MCP permission and connection orchestration. + fn mcp_app(&self) -> McpApp + where + A: Services + EnvironmentInfra, + F: EnvironmentInfra, + { + McpApp::new(self.services.clone()) + } } impl ForgeAPI>, ForgeRepo> { @@ -227,8 +236,12 @@ impl< .map_err(|e| anyhow::anyhow!(e)) } - async fn allow_operation(&self, operation: &PermissionOperation) -> Result<()> { - self.services.allow_operation(operation).await + async fn allow_mcp_servers(&self, names: &[ServerName]) -> Result<()> { + self.mcp_app().allow_mcp_servers(names).await + } + + async fn request_mcp_permissions(&self, cfg: McpConfig) -> Result<()> { + self.mcp_app().request_mcp_permissions(cfg).await } async fn execute_shell_command_raw( diff --git a/crates/forge_app/src/lib.rs b/crates/forge_app/src/lib.rs index 66de3e618d..644ace81aa 100644 --- a/crates/forge_app/src/lib.rs +++ b/crates/forge_app/src/lib.rs @@ -15,6 +15,7 @@ mod git_app; mod hooks; mod infra; mod init_conversation_metrics; +mod mcp_app; mod mcp_executor; mod operation; mod orch; @@ -47,6 +48,7 @@ pub use data_gen::*; pub use error::*; pub use git_app::*; pub use infra::*; +pub use mcp_app::*; pub use services::*; pub use template_engine::*; pub use terminal_context::*; diff --git a/crates/forge_app/src/mcp_app.rs b/crates/forge_app/src/mcp_app.rs new file mode 100644 index 0000000000..b8c457c3a0 --- /dev/null +++ b/crates/forge_app/src/mcp_app.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use anyhow::Result; +use forge_domain::*; +use merge::Merge; + +use crate::services::{McpConfigManager, McpService, PolicyService}; +use crate::{EnvironmentInfra, Services}; + +/// McpApp handles MCP permission reconciliation and policy-filtered +/// connections, keeping `McpService` free of any policy awareness. +pub struct McpApp { + services: Arc, +} + +impl McpApp { + /// Creates a new McpApp instance with the provided services. + pub fn new(services: Arc) -> Self { + Self { services } + } +} + +impl> McpApp { + /// Prompts for missing permissions for each enabled server in `cfg`. + /// Idempotent — servers that already have a recorded decision are skipped + /// silently. + /// + /// This is the only place a permission prompt can fire for MCP. Call this + /// synchronously at startup (before the REPL takes over stdin) so prompts + /// don't race with user input. + pub async fn request_mcp_permissions(&self, cfg: McpConfig) -> Result<()> { + let cwd = self.services.get_environment().cwd; + for (name, server) in cfg + .mcp_servers + .into_iter() + .filter(|(_, s)| !s.is_disabled()) + { + let op = PermissionOperation::Mcp { + config: server, + cwd: cwd.clone(), + message: format!("Allow MCP server \"{name}\" to connect?"), + }; + // check_operation_permission handles the prompt + persist. + // The return value is intentionally discarded here; the caller + // just needs all decisions to be recorded before connections start. + let _ = self.services.check_operation_permission(&op).await?; + } + Ok(()) + } + + /// Returns a merged MCP config where user-scope servers are trusted + /// unconditionally and local-scope servers are filtered to those with an + /// explicit `Allow` policy. Never prompts — call + /// [`Self::request_mcp_permissions`] first to ensure decisions exist. + pub async fn permitted_mcp_config(&self) -> Result { + let mut user = self + .services + .read_mcp_config(Some(&Scope::User)) + .await?; + let local = self + .services + .read_mcp_config(Some(&Scope::Local)) + .await?; + let cwd = self.services.get_environment().cwd; + + let mut filtered_local = McpConfig::default(); + for (name, server) in local.mcp_servers { + if server.is_disabled() { + continue; + } + let op = PermissionOperation::Mcp { + config: server.clone(), + cwd: cwd.clone(), + message: String::new(), + }; + if self.services.is_operation_permitted(&op).await? { + filtered_local.mcp_servers.insert(name, server); + } + } + user.merge(filtered_local); + Ok(user) + } + + /// Lists MCP tools, connecting only servers that have an explicit `Allow` + /// policy for local-scope entries (user-scope are trusted unconditionally). + /// Never prompts. + pub async fn get_mcp_servers(&self) -> Result { + let cfg = self.permitted_mcp_config().await?; + self.services.get_mcp_servers(cfg).await + } + + /// Persist `Allow` decisions for the named servers without prompting. + /// Used by `mcp import` to record consent on the user's behalf — importing + /// is itself an explicit opt-in. + pub async fn allow_mcp_servers(&self, names: &[ServerName]) -> Result<()> { + let cfg = self.services.read_mcp_config(None).await?; + let cwd = self.services.get_environment().cwd; + for name in names { + if let Some(server) = cfg.mcp_servers.get(name) { + let op = PermissionOperation::Mcp { + config: server.clone(), + cwd: cwd.clone(), + message: format!("Connect to MCP server: {name}"), + }; + self.services.allow_operation(&op).await?; + } + } + Ok(()) + } +} diff --git a/crates/forge_app/src/mcp_executor.rs b/crates/forge_app/src/mcp_executor.rs index 21e3d024ba..94bc63a483 100644 --- a/crates/forge_app/src/mcp_executor.rs +++ b/crates/forge_app/src/mcp_executor.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use forge_domain::{TitleFormat, ToolCallContext, ToolCallFull, ToolName, ToolOutput}; -use crate::McpService; +use crate::{EnvironmentInfra, McpApp, McpService, Services}; pub struct McpExecutor { services: Arc, } -impl McpExecutor { +impl> McpExecutor { pub fn new(services: Arc) -> Self { Self { services } } @@ -22,11 +22,13 @@ impl McpExecutor { .send_tool_input(TitleFormat::info("MCP").sub_title(input.name.as_str())) .await?; - self.services.execute_mcp(input).await + let mcp_app = McpApp::new(self.services.clone()); + let cfg = mcp_app.permitted_mcp_config().await?; + self.services.execute_mcp(input, cfg).await } pub async fn contains_tool(&self, tool_name: &ToolName) -> anyhow::Result { - let mcp_servers = self.services.get_mcp_servers().await?; + let mcp_servers = McpApp::new(self.services.clone()).get_mcp_servers().await?; // Convert Claude Code format (mcp__{server}__{tool}) to the internal legacy // format (mcp_{server}_tool_{tool}) before checking, so both name styles match. let legacy = tool_name.to_legacy_mcp_name(); diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 5a990d947f..3a3dae5965 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -218,8 +218,15 @@ pub trait McpConfigManager: Send + Sync { #[async_trait::async_trait] pub trait McpService: Send + Sync { - async fn get_mcp_servers(&self) -> anyhow::Result; - async fn execute_mcp(&self, call: ToolCallFull) -> anyhow::Result; + /// Connect to and list tools from the given MCP servers. + /// The caller is responsible for filtering `cfg` through any policy + /// gating before calling; this method connects every enabled server + /// in `cfg` without re-checking permissions. + async fn get_mcp_servers(&self, cfg: McpConfig) -> anyhow::Result; + /// Execute a tool call against an already-connected server. The caller is + /// responsible for supplying a pre-filtered `cfg` (same one used for + /// `get_mcp_servers`) so denied servers are never reconnected here. + async fn execute_mcp(&self, call: ToolCallFull, cfg: McpConfig) -> anyhow::Result; /// Refresh the MCP cache by fetching fresh data async fn reload_mcp(&self) -> anyhow::Result<()>; } @@ -702,12 +709,12 @@ impl McpConfigManager for I { #[async_trait::async_trait] impl McpService for I { - async fn get_mcp_servers(&self) -> anyhow::Result { - self.mcp_service().get_mcp_servers().await + async fn get_mcp_servers(&self, cfg: McpConfig) -> anyhow::Result { + self.mcp_service().get_mcp_servers(cfg).await } - async fn execute_mcp(&self, call: ToolCallFull) -> anyhow::Result { - self.mcp_service().execute_mcp(call).await + async fn execute_mcp(&self, call: ToolCallFull, cfg: McpConfig) -> anyhow::Result { + self.mcp_service().execute_mcp(call, cfg).await } async fn reload_mcp(&self) -> anyhow::Result<()> { diff --git a/crates/forge_app/src/tool_registry.rs b/crates/forge_app/src/tool_registry.rs index dbfff3da06..4d46bc4e48 100644 --- a/crates/forge_app/src/tool_registry.rs +++ b/crates/forge_app/src/tool_registry.rs @@ -21,7 +21,7 @@ use crate::fmt::content::FormatContent; use crate::mcp_executor::McpExecutor; use crate::tool_executor::ToolExecutor; use crate::{ - AgentRegistry, EnvironmentInfra, McpService, PolicyService, ProviderService, Services, + AgentRegistry, EnvironmentInfra, McpApp, PolicyService, ProviderService, Services, ToolResolver, WorkspaceService, }; @@ -241,7 +241,7 @@ impl> ToolReg } pub async fn tools_overview(&self) -> anyhow::Result { - let mcp_tools = self.services.get_mcp_servers().await?; + let mcp_tools = McpApp::new(self.services.clone()).get_mcp_servers().await?; let agent_tools = self.agent_executor.agent_definitions().await?; // Get agents for template rendering in Task tool description diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9650a70b57..efea6a0896 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -11,7 +11,7 @@ use convert_case::{Case, Casing}; use forge_api::{ API, AgentId, AnyProvider, ApiKeyRequest, AuthContextRequest, AuthContextResponse, ChatRequest, ChatResponse, CodeRequest, ConfigOperation, Conversation, ConversationId, DeviceCodeRequest, - Event, InterruptionReason, ModelId, Provider, ProviderId, TextMessage, UserPrompt, + Event, InterruptionReason, ModelId, Provider, ProviderId, Scope, TextMessage, UserPrompt, }; use forge_app::utils::{format_display_path, truncate_key}; use forge_app::{CommitResult, ToolResolver}; @@ -223,9 +223,7 @@ impl A + Send + Sync> UI self.display_banner()?; self.trace_user(); self.hydrate_caches(); - // Resolve MCP connections up front and surface any permission - // warnings before control returns to the caller. - let _ = self.api.get_tools().await; + self.request_local_mcp_permissions().await?; Ok(()) } @@ -370,10 +368,7 @@ impl A + Send + Sync> UI self.trace_user(); self.hydrate_caches(); self.init_conversation().await?; - - // Initialise MCP connections and display any permission warnings - // before the REPL takes over stdin. - let _ = self.api.get_tools().await; + self.request_local_mcp_permissions().await?; // Check for dispatch flag first if let Some(dispatch_json) = self.cli.event.clone() { @@ -450,11 +445,22 @@ impl A + Send + Sync> UI } } + /// Reads the local-scope MCP config and asks the user for permission for + /// each server that does not yet have a recorded decision. Call this + /// synchronously before the REPL takes over stdin so prompts don't race + /// with user input. + async fn request_local_mcp_permissions(&self) -> Result<()> { + let local_cfg = self.api.read_mcp_config(Some(&Scope::Local)).await?; + self.api.request_mcp_permissions(local_cfg).await + } + // Improve startup time by hydrating caches fn hydrate_caches(&self) { let api = self.api.clone(); tokio::spawn(async move { api.get_models().await }); let api = self.api.clone(); + tokio::spawn(async move { let _ = api.get_tools().await; }); + let api = self.api.clone(); tokio::spawn(async move { api.get_agent_infos().await }); let api = self.api.clone(); tokio::spawn(async move { @@ -574,21 +580,9 @@ impl A + Send + Sync> UI // Write back to the specific scope only self.api.write_mcp_config(&scope, &scope_config).await?; - let cwd = self.api.environment().cwd; - - // Grant allow permission for each imported server so the user - // is not prompted again on first use — importing is itself an - // explicit opt-in. - for server_name in &added_servers { - if let Some(server_config) = scope_config.mcp_servers.get(server_name) { - let operation = forge_domain::PermissionOperation::Mcp { - config: server_config.clone(), - cwd: cwd.clone(), - message: format!("Connect to MCP server: {server_name}"), - }; - self.api.allow_operation(&operation).await?; - } - } + // Importing is an explicit opt-in — persist Allow decisions so + // the user is not prompted on first use. + self.api.allow_mcp_servers(&added_servers).await?; // Log each added server after successful write for server_name in added_servers { diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 32a5b9a379..d2ba2b0522 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -30,8 +30,7 @@ use crate::tool_services::{ ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, }; -type McpService = - ForgeMcpService, F, ::Client, ForgePolicyService>; +type McpService = ForgeMcpService::Client>; type AuthService = ForgeAuthService; /// ForgeApp is the main application container that implements the App trait. @@ -110,13 +109,9 @@ impl< > ForgeServices { pub fn new(infra: Arc) -> Self { + let mcp_service = Arc::new(ForgeMcpService::new(infra.clone())); let mcp_manager = Arc::new(ForgeMcpManager::new(infra.clone())); let policy_service = ForgePolicyService::new(infra.clone()); - let mcp_service = Arc::new(ForgeMcpService::new( - mcp_manager.clone(), - infra.clone(), - Arc::new(policy_service.clone()), - )); let template_service = Arc::new(ForgeTemplateService::new(infra.clone())); let attachment_service = Arc::new(ForgeChatRequest::new(infra.clone())); let suggestion_service = Arc::new(ForgeDiscoveryService::new(infra.clone())); diff --git a/crates/forge_services/src/mcp/service.rs b/crates/forge_services/src/mcp/service.rs index 35a78e28c4..5c03cfa7c6 100644 --- a/crates/forge_services/src/mcp/service.rs +++ b/crates/forge_services/src/mcp/service.rs @@ -1,16 +1,12 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use anyhow::Context; use forge_app::domain::{ - McpConfig, McpServerConfig, McpServers, PermissionOperation, Scope, ServerName, ToolCallFull, - ToolDefinition, ToolName, ToolOutput, + McpConfig, McpServerConfig, McpServers, ServerName, ToolCallFull, ToolDefinition, ToolName, + ToolOutput, }; -use forge_app::{ - EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, - PolicyService, -}; -use merge::Merge; +use forge_app::{EnvironmentInfra, KVStore, McpClientInfra, McpServerInfra, McpService}; use tokio::sync::{Mutex, RwLock}; use crate::mcp::tool::McpExecutor; @@ -24,28 +20,13 @@ fn generate_mcp_tool_name(server_name: &ServerName, tool_name: &ToolName) -> Too )) } -pub struct ForgeMcpService { +#[derive(Clone)] +pub struct ForgeMcpService { tools: Arc>>>>, failed_servers: Arc>>, previous_config_hash: Arc>, init_lock: Arc>, - manager: Arc, infra: Arc, - policy: Arc

, -} - -impl Clone for ForgeMcpService { - fn clone(&self) -> Self { - ForgeMcpService { - tools: Arc::clone(&self.tools), - failed_servers: Arc::clone(&self.failed_servers), - previous_config_hash: Arc::clone(&self.previous_config_hash), - init_lock: Arc::clone(&self.init_lock), - manager: Arc::clone(&self.manager), - infra: Arc::clone(&self.infra), - policy: Arc::clone(&self.policy), - } - } } #[derive(Clone)] @@ -55,23 +36,19 @@ struct ToolHolder { server_name: String, } -impl ForgeMcpService +impl ForgeMcpService where - M: McpConfigManager + 'static, I: McpServerInfra + KVStore + EnvironmentInfra + 'static, C: McpClientInfra + Clone, C: From<::Client>, - P: PolicyService + 'static, { - pub fn new(manager: Arc, infra: Arc, policy: Arc

) -> Self { + pub fn new(infra: Arc) -> Self { Self { tools: Default::default(), failed_servers: Default::default(), previous_config_hash: Arc::new(Mutex::new(Default::default())), init_lock: Arc::new(Mutex::new(())), - manager, infra, - policy, } } @@ -118,15 +95,10 @@ where Ok(()) } - async fn init_mcp(&self) -> anyhow::Result<()> { - let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; - let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; - let mut merged = user_cfg.clone(); - merged.merge(local_cfg.clone()); - + async fn init_mcp(&self, cfg: McpConfig) -> anyhow::Result<()> { // Fast path: if config is unchanged, skip reinitialization without acquiring // the lock - if !self.is_config_modified(&merged).await { + if !self.is_config_modified(&cfg).await { return Ok(()); } @@ -135,42 +107,25 @@ where let _guard = self.init_lock.lock().await; // Double-check under the lock: a concurrent caller may have already updated - if !self.is_config_modified(&merged).await { + if !self.is_config_modified(&cfg).await { return Ok(()); } - self.update_mcp(user_cfg, local_cfg, merged).await + self.update_mcp(cfg).await } - async fn update_mcp( - &self, - user_cfg: McpConfig, - local_cfg: McpConfig, - merged: McpConfig, - ) -> anyhow::Result<()> { - // Compute the hash early before `merged` is consumed, but write it only + async fn update_mcp(&self, mcp: McpConfig) -> anyhow::Result<()> { + // Compute the hash early before `mcp` is consumed, but write it only // after all connections are established so waiters on init_lock see a // consistent state. - let new_hash = merged.cache_key(); + let new_hash = mcp.cache_key(); self.clear_tools().await; self.failed_servers.write().await.clear(); - // User-scoped servers are trusted by default — authorize without policy check. - let mut authorized: HashSet = user_cfg - .mcp_servers - .iter() - .filter(|(_, s)| !s.is_disabled()) - .map(|(name, _)| name.clone()) - .collect(); - - // Only local-scoped servers go through the policy engine. - let local_authorized = self.authorize_servers(&local_cfg).await?; - authorized.extend(local_authorized); - - let connections: Vec<_> = merged + let connections: Vec<_> = mcp .mcp_servers .into_iter() - .filter(|(name, server)| !server.is_disabled() && authorized.contains(name)) + .filter(|(_, server)| !server.is_disabled()) .map(|(name, server)| async move { let conn = self .connect(&name, server) @@ -199,46 +154,8 @@ where Ok(()) } - /// Runs the permission policy against every enabled server in `cfg`. - /// Returns the set of authorised server names. - /// Denials are recorded in `failed_servers`. - async fn authorize_servers(&self, cfg: &McpConfig) -> anyhow::Result> { - let env = self.infra.get_environment(); - - let mut authorized = HashSet::new(); - let mut failures = Vec::new(); - - for (name, server) in cfg.mcp_servers.iter().filter(|(_, s)| !s.is_disabled()) { - let operation = PermissionOperation::Mcp { - config: server.clone(), - cwd: env.cwd.clone(), - message: format!("Allow MCP server \"{name}\" to connect?"), - }; - match self.policy.check_operation_permission(&operation).await { - Ok(decision) if decision.allowed => { - authorized.insert(name.clone()); - } - Ok(_) => { - failures.push((name.clone(), "Connection denied by policy".to_string())); - } - Err(err) => { - failures.push((name.clone(), format!("Policy check failed: {err:?}"))); - } - } - } - - if !failures.is_empty() { - let mut failed = self.failed_servers.write().await; - for (name, reason) in failures { - failed.insert(name, reason); - } - } - - Ok(authorized) - } - - async fn list(&self) -> anyhow::Result { - self.init_mcp().await?; + async fn list(&self, cfg: McpConfig) -> anyhow::Result { + self.init_mcp(cfg).await?; let tools = self.tools.read().await; let mut grouped_tools = std::collections::HashMap::new(); @@ -254,13 +171,15 @@ where Ok(McpServers::new(grouped_tools, failures)) } + async fn clear_tools(&self) { self.tools.write().await.clear() } - async fn call(&self, call: ToolCallFull) -> anyhow::Result { - // Ensure MCP connections are initialized before calling tools - self.init_mcp().await?; + async fn call(&self, call: ToolCallFull, cfg: McpConfig) -> anyhow::Result { + // Use the caller-supplied pre-filtered config so only permitted servers + // are (re)connected here. + self.init_mcp(cfg).await?; let tools = self.tools.read().await; @@ -274,23 +193,6 @@ where tool.executable.call_tool(call.arguments.parse()?).await } - /// Returns `true` if any enabled server in `cfg` does not have a - /// persisted `Allow` policy, meaning a permission prompt would be required. - async fn has_servers_requiring_permission(&self, cfg: &McpConfig) -> anyhow::Result { - let env = self.infra.get_environment(); - for (name, server) in cfg.mcp_servers.iter().filter(|(_, s)| !s.is_disabled()) { - let operation = PermissionOperation::Mcp { - config: server.clone(), - cwd: env.cwd.clone(), - message: format!("Allow MCP server \"{name}\" to connect?"), - }; - if !self.policy.is_operation_permitted(&operation).await? { - return Ok(true); - } - } - Ok(false) - } - /// Refresh the MCP cache by clearing cached data. /// Does NOT eagerly connect to servers - connections happen lazily /// when list() or call() is invoked, avoiding interactive OAuth during @@ -310,40 +212,27 @@ where } #[async_trait::async_trait] -impl McpService for ForgeMcpService +impl McpService for ForgeMcpService where - M: McpConfigManager + 'static, I: McpServerInfra + KVStore + EnvironmentInfra + 'static, C: McpClientInfra + Clone, C: From<::Client>, - P: PolicyService + 'static, { - async fn get_mcp_servers(&self) -> anyhow::Result { - let user_cfg = self.manager.read_mcp_config(Some(&Scope::User)).await?; - let local_cfg = self.manager.read_mcp_config(Some(&Scope::Local)).await?; - let mut merged_cfg = user_cfg.clone(); - merged_cfg.merge(local_cfg.clone()); - let config_hash = merged_cfg.cache_key(); - - let needs_permission_check = self.has_servers_requiring_permission(&local_cfg).await?; + async fn get_mcp_servers(&self, cfg: McpConfig) -> anyhow::Result { + let config_hash = cfg.cache_key(); - if !needs_permission_check - && let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? - { - return Ok(cache.clone()); + if let Some(cache) = self.infra.cache_get::<_, McpServers>(&config_hash).await? { + return Ok(cache); } - let servers = self.list().await?; - - if !needs_permission_check { - self.infra.cache_set(&config_hash, &servers).await?; - } + let servers = self.list(cfg).await?; + self.infra.cache_set(&config_hash, &servers).await?; Ok(servers) } - async fn execute_mcp(&self, call: ToolCallFull) -> anyhow::Result { - self.call(call).await + async fn execute_mcp(&self, call: ToolCallFull, cfg: McpConfig) -> anyhow::Result { + self.call(call, cfg).await } async fn reload_mcp(&self) -> anyhow::Result<()> { @@ -358,13 +247,10 @@ mod tests { use fake::{Fake, Faker}; use forge_app::domain::{ - ConfigOperation, Environment, McpConfig, McpServerConfig, PermissionOperation, Scope, - ServerName, ToolCallFull, ToolDefinition, ToolName, ToolOutput, - }; - use forge_app::{ - EnvironmentInfra, KVStore, McpClientInfra, McpConfigManager, McpServerInfra, McpService, - PolicyDecision, PolicyService, + ConfigOperation, Environment, McpConfig, McpServerConfig, ServerName, ToolCallFull, + ToolDefinition, ToolName, ToolOutput, }; + use forge_app::{EnvironmentInfra, KVStore, McpClientInfra, McpServerInfra, McpService}; use forge_config::ForgeConfig; use pretty_assertions::assert_eq; use serde::de::DeserializeOwned; @@ -391,30 +277,6 @@ mod tests { } } - // ── Mock config manager ────────────────────────────────────────────────── - - struct MockMcpManager; - - #[async_trait::async_trait] - impl McpConfigManager for MockMcpManager { - async fn read_mcp_config(&self, _scope: Option<&Scope>) -> anyhow::Result { - let mut servers = BTreeMap::new(); - servers.insert( - ServerName::from("test-server".to_string()), - McpServerConfig::new_stdio("echo", vec![], None), - ); - Ok(McpConfig { mcp_servers: servers }) - } - - async fn write_mcp_config( - &self, - _config: &McpConfig, - _scope: &Scope, - ) -> anyhow::Result<()> { - Ok(()) - } - } - // ── Mock infrastructure ────────────────────────────────────────────────── #[derive(Clone)] @@ -481,42 +343,19 @@ mod tests { } } - // ── Mock policy service ────────────────────────────────────────────────── - - /// Permits every operation. Tests need MCP connections to go through - /// without prompting; production behaviour (Confirm by default) is covered - /// by `forge_services::policy` and `forge_domain::policies::engine` tests. - struct AlwaysAllowPolicy; - - #[async_trait::async_trait] - impl PolicyService for AlwaysAllowPolicy { - async fn check_operation_permission( - &self, - _operation: &PermissionOperation, - ) -> anyhow::Result { - Ok(PolicyDecision { allowed: true, path: None }) - } - - async fn is_operation_permitted( - &self, - _operation: &PermissionOperation, - ) -> anyhow::Result { - Ok(true) - } + // ── Fixture ────────────────────────────────────────────────────────────── - async fn allow_operation(&self, _operation: &PermissionOperation) -> anyhow::Result<()> { - Ok(()) - } + fn fixture() -> ForgeMcpService { + ForgeMcpService::new(Arc::new(MockInfra)) } - // ── Fixture ────────────────────────────────────────────────────────────── - - fn fixture() -> ForgeMcpService { - ForgeMcpService::new( - Arc::new(MockMcpManager), - Arc::new(MockInfra), - Arc::new(AlwaysAllowPolicy), - ) + fn fixture_cfg() -> McpConfig { + let mut servers = BTreeMap::new(); + servers.insert( + ServerName::from("test-server".to_string()), + McpServerConfig::new_stdio("echo", vec![], None), + ); + McpConfig { mcp_servers: servers } } #[test] @@ -570,14 +409,17 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_concurrent_init_does_not_race() { let service = Arc::new(fixture()); + let cfg = fixture_cfg(); let s1 = service.clone(); let s2 = service.clone(); - let (r1, r2) = tokio::join!(s1.get_mcp_servers(), s2.get_mcp_servers()); + let c1 = cfg.clone(); + let c2 = cfg.clone(); + let (r1, r2) = tokio::join!(s1.get_mcp_servers(c1), s2.get_mcp_servers(c2)); r1.unwrap(); r2.unwrap(); - let servers = service.get_mcp_servers().await.unwrap(); + let servers = service.get_mcp_servers(cfg).await.unwrap(); let tool_name = servers .get_servers() .values() @@ -588,7 +430,7 @@ mod tests { .clone(); let call = ToolCallFull::new(tool_name); - let actual = service.execute_mcp(call).await.unwrap(); + let actual = service.execute_mcp(call, fixture_cfg()).await.unwrap(); let expected = ToolOutput::text("mock result"); assert_eq!(actual, expected); } diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index 735714a48e..fb45254e46 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -27,30 +27,24 @@ pub enum PolicyPermission { AcceptAndRemember, } -/// Two-choice prompt used exclusively for MCP server connections. -/// Both choices are persisted so the user is not re-prompted on subsequent -/// starts. +/// Two-choice prompt for operations where both Accept and Reject are +/// persisted so the user is never asked again. Use this instead of +/// [`PolicyPermission`] when there is no meaningful "one-off allow" path. #[derive(Debug, Clone, PartialEq, Eq, Display, EnumIter, strum_macros::EnumString)] -enum McpPermission { - /// Allow this MCP server to connect and persist the decision +enum ConfirmPermission { + /// Allow the operation and remember this choice #[strum(to_string = "Accept")] Accept, - /// Deny this MCP server and persist the decision + /// Deny the operation and remember this choice #[strum(to_string = "Reject")] Reject, } +#[derive(Clone)] pub struct ForgePolicyService { infra: Arc, } -impl Clone for ForgePolicyService { - // Manual impl so callers don't need `I: Clone`; we only ever clone the - // `Arc` which is always cheap. - fn clone(&self) -> Self { - Self { infra: self.infra.clone() } - } -} /// Default policies loaded once at startup from the embedded YAML file static DEFAULT_POLICIES: LazyLock = LazyLock::new(|| { let yaml_content = include_str!("./permissions.default.yaml"); @@ -225,12 +219,12 @@ where PermissionOperation::Mcp { message, config, cwd } => { let header = mcp_config_header(config); let prompt = SelectPrompt::new(message.clone()).with_header(header); - return match self.infra.select_one_enum::(prompt).await? { - Some(McpPermission::Accept) => { + return match self.infra.select_one_enum::(prompt).await? { + Some(ConfirmPermission::Accept) => { let update_path = self.add_policy_for_operation(operation).await?; Ok(PolicyDecision { allowed: true, path: update_path.or(path) }) } - Some(McpPermission::Reject) | None => { + Some(ConfirmPermission::Reject) | None => { let deny_policy = Policy::Simple { permission: Permission::Deny, rule: forge_app::domain::Rule::Mcp( From 43749dac862c88ec382f82403d9682309cb31f73 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 08:28:39 +0000 Subject: [PATCH 39/44] [autofix.ci] apply automated fixes --- crates/forge_api/src/forge_api.rs | 3 ++- crates/forge_app/src/mcp_app.rs | 10 ++-------- crates/forge_main/src/ui.rs | 4 +++- crates/forge_services/src/policy.rs | 6 +++++- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 23efad7222..b210dd820f 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -40,7 +40,8 @@ impl ForgeAPI { ForgeApp::new(self.services.clone()) } - /// Creates an McpApp instance for MCP permission and connection orchestration. + /// Creates an McpApp instance for MCP permission and connection + /// orchestration. fn mcp_app(&self) -> McpApp where A: Services + EnvironmentInfra, diff --git a/crates/forge_app/src/mcp_app.rs b/crates/forge_app/src/mcp_app.rs index b8c457c3a0..a361b0b8f8 100644 --- a/crates/forge_app/src/mcp_app.rs +++ b/crates/forge_app/src/mcp_app.rs @@ -53,14 +53,8 @@ impl> McpApp< /// explicit `Allow` policy. Never prompts — call /// [`Self::request_mcp_permissions`] first to ensure decisions exist. pub async fn permitted_mcp_config(&self) -> Result { - let mut user = self - .services - .read_mcp_config(Some(&Scope::User)) - .await?; - let local = self - .services - .read_mcp_config(Some(&Scope::Local)) - .await?; + let mut user = self.services.read_mcp_config(Some(&Scope::User)).await?; + let local = self.services.read_mcp_config(Some(&Scope::Local)).await?; let cwd = self.services.get_environment().cwd; let mut filtered_local = McpConfig::default(); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index efea6a0896..f416d382ca 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -459,7 +459,9 @@ impl A + Send + Sync> UI let api = self.api.clone(); tokio::spawn(async move { api.get_models().await }); let api = self.api.clone(); - tokio::spawn(async move { let _ = api.get_tools().await; }); + tokio::spawn(async move { + let _ = api.get_tools().await; + }); let api = self.api.clone(); tokio::spawn(async move { api.get_agent_infos().await }); let api = self.api.clone(); diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index fb45254e46..ff0e2cb777 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -219,7 +219,11 @@ where PermissionOperation::Mcp { message, config, cwd } => { let header = mcp_config_header(config); let prompt = SelectPrompt::new(message.clone()).with_header(header); - return match self.infra.select_one_enum::(prompt).await? { + return match self + .infra + .select_one_enum::(prompt) + .await? + { Some(ConfirmPermission::Accept) => { let update_path = self.add_policy_for_operation(operation).await?; Ok(PolicyDecision { allowed: true, path: update_path.or(path) }) From 93797ffcb30c6b157b55dd5fdd8b441ff413b1a8 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 14:09:17 +0530 Subject: [PATCH 40/44] refactor(policy): use imported types instead of fully-qualified paths in Mcp deny branch Co-Authored-By: ForgeCode --- crates/forge_services/src/policy.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/forge_services/src/policy.rs b/crates/forge_services/src/policy.rs index ff0e2cb777..4c0751084f 100644 --- a/crates/forge_services/src/policy.rs +++ b/crates/forge_services/src/policy.rs @@ -231,13 +231,9 @@ where Some(ConfirmPermission::Reject) | None => { let deny_policy = Policy::Simple { permission: Permission::Deny, - rule: forge_app::domain::Rule::Mcp( - forge_app::domain::McpRule { - mcp: forge_app::domain::McpFilter::from_config( - config, cwd, - ), - }, - ), + rule: Rule::Mcp(McpRule { + mcp: McpFilter::from_config(config, cwd), + }), }; self.modify_policy(deny_policy).await?; Ok(PolicyDecision { From 0f7443cb4c79b7c6eeb306d4287eb77861f45e3b Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 14:53:01 +0530 Subject: [PATCH 41/44] docs(skills): add test-mcp-permissions skill with e2e test script --- .forge/skills/test-mcp-permissions/SKILL.md | 168 ++++++ .../test-mcp-permissions/scripts/test.py | 532 ++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 .forge/skills/test-mcp-permissions/SKILL.md create mode 100644 .forge/skills/test-mcp-permissions/scripts/test.py diff --git a/.forge/skills/test-mcp-permissions/SKILL.md b/.forge/skills/test-mcp-permissions/SKILL.md new file mode 100644 index 0000000000..745ff71beb --- /dev/null +++ b/.forge/skills/test-mcp-permissions/SKILL.md @@ -0,0 +1,168 @@ +--- +name: test-mcp-permissions +description: Test the MCP server permission policy feature end-to-end. Use when asked to test MCP permissions, verify that local MCP servers are gated by policy, or validate the allow/deny/prompt behavior for MCP connections introduced in PR #3324. +--- + +# Test MCP Permissions + +This skill validates the MCP server permission policy feature (PR #3324). The feature gates **local-scope** MCP servers (`.mcp.json` in the project directory) through a permission prompt at startup, while **user-scope** servers (`~/.forge/.mcp.json`) are trusted unconditionally. + +## How the Feature Works + +1. **Startup flow**: `UI::request_local_mcp_permissions()` reads the local `.mcp.json` and calls `McpApp::request_mcp_permissions(cfg)` **before** the REPL starts, so the prompt never races with user input. +2. **Permission check**: For each enabled local server, a `PermissionOperation::Mcp` is evaluated against `~/.forge/permissions.yaml` by `PolicyEngine`. +3. **Policy result**: + - `Allow` → server connects silently + - `Deny` → server is filtered out silently + - `Confirm` (no matching rule) → user is prompted +4. **Prompt**: A two-choice `ConfirmPermission` (Accept / Reject) is shown with the server's command/url as a header. **Both** choices are persisted — the user is never asked again for the same server+cwd combination. +5. **Import shortcut**: `/mcp import` auto-persists `Allow` via `allow_mcp_servers()` — importing itself counts as consent, no prompt shown. + +## Permissions File + +`~/.forge/permissions.yaml` — written decisions look like: + +```yaml +# stdio server (Allow) +policies: + - permission: allow + rule: + mcp: + command: npx + args: ["-y", "@github/mcp"] + dir: /path/to/project + +# HTTP server (Deny) + - permission: deny + rule: + mcp: + url: "https://untrusted.example.com/sse" + dir: /path/to/project +``` + +Glob patterns work in all fields (`command: "np*"`, `url: "https://trusted.com/*"`). + +## Test Scenarios + +### Scenario 1 — No permissions.yaml: prompt fires + +```bash +rm -f ~/.forge/permissions.yaml +# Add a local MCP server to .mcp.json in the project dir: +echo '{"mcpServers":{"test-server":{"command":"npx","args":["-y","@github/mcp"]}}}' > .mcp.json +forge +``` + +**Expected:** Prompt appears — `Allow MCP server "test-server" to connect?` with `command: npx` shown as a header line. Choose **Accept**. + +**Verify:** +```bash +cat ~/.forge/permissions.yaml +# → contains: permission: allow, mcp: {command: npx, args: ["-y", "@github/mcp"], dir: } +``` + +--- + +### Scenario 2 — Accept persisted: no prompt on second run + +After Scenario 1 (accepted), restart forge in the same directory. + +**Expected:** No prompt. Server connects silently. + +--- + +### Scenario 3 — Reject persisted: server silently blocked + +Run Scenario 1 again (`rm ~/.forge/permissions.yaml`, restart forge), choose **Reject**. + +**Expected:** Forge starts without the server's tools available. + +**Verify:** +```bash +cat ~/.forge/permissions.yaml +# → contains: permission: deny +``` + +Ask forge to use a tool from that server — it should report it as unavailable. + +--- + +### Scenario 4 — User-scope server: never prompted + +Add a server to `~/.forge/.mcp.json` (user scope, not `.mcp.json` in cwd). + +```bash +rm -f ~/.forge/permissions.yaml +forge +``` + +**Expected:** No prompt. User-scope servers bypass the permission gate and always connect. + +--- + +### Scenario 5 — `mcp import` auto-approves + +```bash +rm -f ~/.forge/permissions.yaml +forge +# Inside forge REPL: +/mcp import +``` + +**Expected:** No permission prompt during import. After import, `~/.forge/permissions.yaml` contains `allow` rules for each imported server. + +--- + +### Scenario 6 — Glob rule pre-set in permissions.yaml + +```bash +cat > ~/.forge/permissions.yaml << 'EOF' +policies: + - permission: allow + rule: + mcp: + command: "np*" +EOF +forge +``` + +**Expected:** No prompt for any stdio server whose command starts with `np` (e.g. `npx`). The glob match skips the prompt entirely. + +--- + +### Scenario 7 — HTTP MCP server + +```bash +rm -f ~/.forge/permissions.yaml +echo '{"mcpServers":{"http-server":{"url":"https://mcp.example.com/sse"}}}' > .mcp.json +forge +``` + +**Expected:** Prompt shows `url: https://mcp.example.com/sse` as header. Accepting writes: +```yaml +- permission: allow + rule: + mcp: + url: "https://mcp.example.com/sse" + dir: +``` + +--- + +## Quick Reset Between Tests + +```bash +rm -f ~/.forge/permissions.yaml +rm -f .mcp.json +``` + +## Key Code Locations + +| What | File | +|---|---| +| Startup permission gate | `crates/forge_main/src/ui.rs:445-457` | +| McpApp orchestration | `crates/forge_app/src/mcp_app.rs` | +| Policy prompt logic | `crates/forge_services/src/policy.rs:218-244` | +| MCP rule matching | `crates/forge_domain/src/policies/rule.rs:111-116` | +| MCP filter (glob match) | `crates/forge_domain/src/policies/rule.rs:159-181` | +| Default permissions | `crates/forge_services/src/permissions.default.yaml` | diff --git a/.forge/skills/test-mcp-permissions/scripts/test.py b/.forge/skills/test-mcp-permissions/scripts/test.py new file mode 100644 index 0000000000..13eb0be2b4 --- /dev/null +++ b/.forge/skills/test-mcp-permissions/scripts/test.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +""" +End-to-end tests for MCP server permission policy (PR #3324). + +The forge permission TUI renders on stderr using crossterm raw mode. +pexpect.spawn() allocates a PTY so forge sees a real terminal, which lets +the TUI actually start. We send: + - Enter → select the first item (Accept by default) + - Down + Enter → select the second item (Reject) + +After each interaction we assert on the contents of permissions.yaml. +""" + +import os +import sys +import json +import shutil +import tempfile +import subprocess +import textwrap +import time + +# --------------------------------------------------------------------------- +# Dependency bootstrap +# --------------------------------------------------------------------------- +try: + import pexpect +except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pexpect"]) + import pexpect + +import yaml # noqa: E402 (installed below if missing) +try: + import yaml +except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pyyaml"]) + import yaml + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +FORGE_BIN = os.path.abspath(os.environ.get("FORGE_BIN", "forge")) + +# Each test scenario creates its own isolated FORGE_CONFIG dir so: +# 1. We know exactly where permissions.yaml lives. +# 2. Tests don't interfere with the real ~/.forge or ~/forge directories. +FORGE_CONFIG_DIR: str = "" # set per-scenario + +PASS = "\033[32mPASS\033[0m" +FAIL = "\033[31mFAIL\033[0m" + +results = [] + + +def perm_file() -> str: + """Path to permissions.yaml inside the current test's FORGE_CONFIG dir.""" + return os.path.join(FORGE_CONFIG_DIR, "permissions.yaml") + + +def log(msg: str): + print(f" {msg}", flush=True) + + +def _format_permissions(perms: dict, label: str) -> None: + """Print a labelled permissions block as indented YAML.""" + print(f" {label}", flush=True) + if not perms: + print(" (empty — no permissions.yaml)", flush=True) + return + for line in yaml.dump(perms, default_flow_style=False, sort_keys=False).splitlines(): + print(f" {line}", flush=True) + + +def log_permissions(before: dict, after: dict) -> None: + """Print before/after permissions.yaml side by side (sequentially).""" + print(" ┌─ before ─────────────────────────────────", flush=True) + _format_permissions(before, "") + print(" ├─ after ──────────────────────────────────", flush=True) + _format_permissions(after, "") + print(" └──────────────────────────────────────────", flush=True) + + +def assert_true(cond: bool, msg: str): + if not cond: + raise AssertionError(msg) + + +def read_permissions() -> dict: + path = perm_file() + if not os.path.exists(path): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def write_permissions(data: dict): + path = perm_file() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False) + + +def remove_permissions(): + path = perm_file() + if os.path.exists(path): + os.remove(path) + + +def run_scenario(name: str, fn): + print(f"\n{'─'*60}") + print(f"Scenario: {name}") + print(f"{'─'*60}") + try: + fn() + print(f"Result: {PASS}") + results.append((name, True, None)) + except Exception as e: + print(f"Result: {FAIL} — {e}") + results.append((name, False, str(e))) + + +def spawn_forge_with_prompt(tmpdir: str, forge_config: str, timeout: int = 30) -> pexpect.spawn: + """ + Spawn `forge -p hello` inside tmpdir with a PTY. + + FORGE_CONFIG is set to an isolated temp dir so permissions.yaml is written + there, not into the real user config directory. + + The MCP permission TUI guards on `stderr().is_terminal()` before rendering. + pexpect.spawn() only allocates a PTY for stdout; stderr is a plain pipe so + is_terminal() returns false and the prompt is skipped entirely. + + Fix: wrap the call in `sh -c 'forge ... 2>&1'` so both stdout and stderr + share the same PTY file descriptor. With that, is_terminal() sees a TTY + and the crossterm TUI renders and accepts keyboard input normally. + """ + cmd = f"{FORGE_BIN} -p hello" + env = { + **os.environ, + "TERM": "xterm-256color", + "COLUMNS": "120", + "LINES": "40", + "FORGE_CONFIG": forge_config, + } + child = pexpect.spawn( + "/bin/sh", + args=["-c", f"exec {cmd} 2>&1"], + cwd=tmpdir, + timeout=timeout, + encoding="utf-8", + codec_errors="replace", + env=env, + ) + return child + + +def mcp_json(command: str, args=None) -> dict: + server: dict = {"command": command} + if args: + server["args"] = args + return {"mcpServers": {"test-server": server}} + + +# --------------------------------------------------------------------------- +# Scenario implementations +# --------------------------------------------------------------------------- + +def make_dirs() -> "tuple[str, str]": + """Return (tmpdir, forge_config) — two fresh isolated temp directories. + + forge_config is seeded with the real forge config files (toml, credentials, + provider) so forge starts without a first-time setup prompt, but with no + permissions.yaml so the MCP gate fires normally. + """ + tmpdir = tempfile.mkdtemp(prefix="forge_mcp_cwd_") + forge_config = tempfile.mkdtemp(prefix="forge_mcp_cfg_") + + # Copy config files from the real forge base dir so forge starts configured. + real_base = None + for candidate in [ + os.path.expanduser("~/forge"), + os.path.expanduser("~/.forge"), + ]: + if os.path.isdir(candidate): + real_base = candidate + break + + if real_base: + for name in [".forge.toml", ".config.json", ".credentials.json", "provider.json"]: + src = os.path.join(real_base, name) + if os.path.exists(src): + shutil.copy2(src, os.path.join(forge_config, name)) + + return tmpdir, forge_config + + +def scenario_accept_writes_allow_rule(): + """ + No permissions.yaml + local MCP server → prompt fires → user picks Accept + → permissions.yaml written with allow rule for the server. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + # Write a local .mcp.json + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(mcp_json("echo", ["hello"]), f) + + before = read_permissions() + log("Spawning forge (no permissions.yaml) ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + + log("Waiting for permission prompt ...") + child.expect("Allow MCP server", timeout=30) + log("Prompt detected. Sending Enter (Accept) ...") + child.send("\r") + + # Wait for forge to persist the decision + time.sleep(4) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + policies = after.get("policies", []) + assert_true(len(policies) > 0, "Expected at least one policy to be written") + allow_policies = [p for p in policies if isinstance(p, dict) and p.get("permission") == "allow"] + assert_true(len(allow_policies) > 0, "Expected an 'allow' policy to be written") + mcp_rules = [p for p in allow_policies if isinstance(p.get("rule"), dict) and "mcp" in p["rule"]] + assert_true(len(mcp_rules) > 0, "Expected an MCP rule in the allow policy") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_reject_writes_deny_rule(): + """ + No permissions.yaml + local MCP server → prompt fires → user picks Reject + → permissions.yaml written with deny rule for the server. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(mcp_json("echo", ["hello"]), f) + + before = read_permissions() + log("Spawning forge (no permissions.yaml) ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + + log("Waiting for permission prompt ...") + child.expect("Allow MCP server", timeout=30) + log("Prompt detected. Sending Down then Enter (Reject) ...") + # Send arrow-down as individual bytes to ensure raw-mode TUI receives it + child.send("\x1b") + time.sleep(0.1) + child.send("[") + time.sleep(0.1) + child.send("B") + time.sleep(0.5) + child.send("\r") + + time.sleep(4) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + policies = after.get("policies", []) + deny_policies = [p for p in policies if isinstance(p, dict) and p.get("permission") == "deny"] + assert_true(len(deny_policies) > 0, "Expected a 'deny' policy to be written") + mcp_rules = [p for p in deny_policies if isinstance(p.get("rule"), dict) and "mcp" in p["rule"]] + assert_true(len(mcp_rules) > 0, "Expected an MCP deny rule for the server") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_existing_allow_skips_prompt(): + """ + permissions.yaml already has an allow rule → forge starts without prompting. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + write_permissions({ + "policies": [ + {"permission": "allow", "rule": {"mcp": {"command": "echo"}}} + ] + }) + + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(mcp_json("echo", ["hello"]), f) + + before = read_permissions() + log("Spawning forge (allow rule pre-written) ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + + idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + assert_true(idx != 0, "Permission prompt appeared even though allow rule was pre-written") + log("No permission prompt shown — correct.") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_second_run_skips_prompt(): + """ + After accepting on first run (permissions.yaml written), a second forge + invocation must NOT show the prompt again. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(mcp_json("echo", ["hello"]), f) + + # First run — accept + before_run1 = read_permissions() + log("First run: accepting permission ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + child.expect("Allow MCP server", timeout=30) + child.send("\r") + time.sleep(4) + child.close(force=True) + + after_run1 = read_permissions() + log(" [run 1]") + log_permissions(before_run1, after_run1) + assert_true(os.path.exists(perm_file()), "permissions.yaml not created after first accept") + + # Second run — should NOT prompt + before_run2 = read_permissions() + log("Second run: verifying no prompt ...") + child2 = spawn_forge_with_prompt(tmpdir, forge_config) + idx = child2.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) + child2.close(force=True) + + after_run2 = read_permissions() + log(" [run 2]") + log_permissions(before_run2, after_run2) + assert_true(idx != 0, "Permission prompt appeared on second run — decision was not persisted") + log("No prompt on second run — decision persisted correctly.") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_custom_mcp_server_accept(): + """ + Add a custom realistic MCP server (npx-based filesystem server) to + .mcp.json, start forge, answer Accept in the TUI prompt, and verify + permissions.yaml contains an allow rule for that exact server. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + custom_server = { + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", tmpdir], + } + } + } + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(custom_server, f) + + before = read_permissions() + log("Spawning forge with custom filesystem MCP server ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + + log("Waiting for permission prompt for 'filesystem' server ...") + child.expect('Allow MCP server "filesystem"', timeout=30) + log("Prompt detected. Sending Enter (Accept) ...") + child.send("\r") + + time.sleep(4) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + policies = after.get("policies", []) + allow_mcp = [ + p for p in policies + if isinstance(p, dict) and p.get("permission") == "allow" + and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] + ] + assert_true(len(allow_mcp) > 0, "Expected an allow MCP rule in permissions.yaml") + rule_mcp = allow_mcp[0]["rule"]["mcp"] + assert_true(rule_mcp.get("command") == "npx", f"Expected command='npx' in rule, got: {rule_mcp}") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_custom_mcp_server_reject(): + """ + Add a custom MCP server, start forge, answer Reject in the TUI prompt, + and verify permissions.yaml contains a deny rule for that server. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + custom_server = { + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", tmpdir], + } + } + } + with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: + json.dump(custom_server, f) + + before = read_permissions() + log("Spawning forge with custom filesystem MCP server ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + + log("Waiting for permission prompt for 'filesystem' server ...") + child.expect('Allow MCP server "filesystem"', timeout=30) + log("Prompt detected. Sending Down + Enter (Reject) ...") + # Send arrow-down as individual bytes to ensure raw-mode TUI receives it + child.send("\x1b") + time.sleep(0.1) + child.send("[") + time.sleep(0.1) + child.send("B") + time.sleep(0.5) + child.send("\r") + + time.sleep(4) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + policies = after.get("policies", []) + deny_mcp = [ + p for p in policies + if isinstance(p, dict) and p.get("permission") == "deny" + and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] + ] + assert_true(len(deny_mcp) > 0, "Expected a deny MCP rule in permissions.yaml") + rule_mcp = deny_mcp[0]["rule"]["mcp"] + assert_true(rule_mcp.get("command") == "npx", f"Expected command='npx' in deny rule, got: {rule_mcp}") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +def scenario_user_scope_never_prompts(): + """ + A server in the user-scope config (~/.mcp.json relative to FORGE_CONFIG) + must never prompt, regardless of permissions.yaml. + + The user-scope MCP file is `/.mcp.json`. + """ + global FORGE_CONFIG_DIR + tmpdir, forge_config = make_dirs() + FORGE_CONFIG_DIR = forge_config + try: + # Write the server to the user-scope MCP path inside our isolated FORGE_CONFIG + user_mcp = os.path.join(forge_config, ".mcp.json") + with open(user_mcp, "w") as f: + json.dump(mcp_json("echo", ["user-scope"]), f) + + before = read_permissions() + log("Spawning forge (user-scope server, no permissions.yaml) ...") + child = spawn_forge_with_prompt(tmpdir, forge_config) + idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) + child.close(force=True) + + after = read_permissions() + log_permissions(before, after) + assert_true(idx != 0, "Permission prompt appeared for a user-scope server — should be trusted unconditionally") + log("No prompt for user-scope server — trusted unconditionally.") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + shutil.rmtree(forge_config, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + print("=" * 60) + print("MCP Permission Policy — End-to-End Tests") + print("=" * 60) + print(f" forge binary: {FORGE_BIN}") + + run_scenario("Accept → allow rule written to permissions.yaml", scenario_accept_writes_allow_rule) + run_scenario("Reject → deny rule written to permissions.yaml", scenario_reject_writes_deny_rule) + run_scenario("Pre-existing allow rule → no prompt", scenario_existing_allow_skips_prompt) + run_scenario("Second run after accept → no prompt", scenario_second_run_skips_prompt) + run_scenario("Custom MCP server (npx) Accept → allow rule in permissions.yaml", scenario_custom_mcp_server_accept) + run_scenario("Custom MCP server (npx) Reject → deny rule in permissions.yaml", scenario_custom_mcp_server_reject) + run_scenario("User-scope server → never prompts", scenario_user_scope_never_prompts) + + # Summary + print(f"\n{'='*60}") + print("SUMMARY") + print(f"{'='*60}") + passed = sum(1 for _, ok, _ in results if ok) + failed = sum(1 for _, ok, _ in results if not ok) + for name, ok, err in results: + status = PASS if ok else FAIL + print(f" {status} {name}") + if err: + for line in textwrap.wrap(err, width=72): + print(f" {line}") + print(f"\n{passed}/{len(results)} passed") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() From 5b5868d981bc3aefd7cedbf6b55da2f5030e9c36 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 14:58:33 +0530 Subject: [PATCH 42/44] refactor(skills): simplify and deduplicate mcp permissions test script --- .../test-mcp-permissions/scripts/test.py | 645 ++++++------------ 1 file changed, 191 insertions(+), 454 deletions(-) diff --git a/.forge/skills/test-mcp-permissions/scripts/test.py b/.forge/skills/test-mcp-permissions/scripts/test.py index 13eb0be2b4..9a8e4fc5d0 100644 --- a/.forge/skills/test-mcp-permissions/scripts/test.py +++ b/.forge/skills/test-mcp-permissions/scripts/test.py @@ -1,35 +1,22 @@ #!/usr/bin/env python3 -""" -End-to-end tests for MCP server permission policy (PR #3324). +"""End-to-end tests for MCP server permission policy (PR #3324).""" -The forge permission TUI renders on stderr using crossterm raw mode. -pexpect.spawn() allocates a PTY so forge sees a real terminal, which lets -the TUI actually start. We send: - - Enter → select the first item (Accept by default) - - Down + Enter → select the second item (Reject) - -After each interaction we assert on the contents of permissions.yaml. -""" - -import os -import sys +import contextlib import json +import os import shutil -import tempfile import subprocess +import sys +import tempfile import textwrap import time -# --------------------------------------------------------------------------- -# Dependency bootstrap -# --------------------------------------------------------------------------- try: import pexpect except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "pexpect"]) import pexpect -import yaml # noqa: E402 (installed below if missing) try: import yaml except ImportError: @@ -37,79 +24,111 @@ import yaml # --------------------------------------------------------------------------- -# Helpers +# Config # --------------------------------------------------------------------------- FORGE_BIN = os.path.abspath(os.environ.get("FORGE_BIN", "forge")) - -# Each test scenario creates its own isolated FORGE_CONFIG dir so: -# 1. We know exactly where permissions.yaml lives. -# 2. Tests don't interfere with the real ~/.forge or ~/forge directories. -FORGE_CONFIG_DIR: str = "" # set per-scenario - PASS = "\033[32mPASS\033[0m" FAIL = "\033[31mFAIL\033[0m" +results: list = [] -results = [] - - -def perm_file() -> str: - """Path to permissions.yaml inside the current test's FORGE_CONFIG dir.""" - return os.path.join(FORGE_CONFIG_DIR, "permissions.yaml") +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -def log(msg: str): - print(f" {msg}", flush=True) +@contextlib.contextmanager +def scenario_dirs(): + """Yield (cwd, forge_config) as isolated temp dirs, cleaned up on exit.""" + cwd = tempfile.mkdtemp(prefix="forge_mcp_cwd_") + cfg = tempfile.mkdtemp(prefix="forge_mcp_cfg_") + # Seed config so forge skips first-time setup + for base in [os.path.expanduser("~/forge"), os.path.expanduser("~/.forge")]: + if os.path.isdir(base): + for name in [".forge.toml", ".config.json", ".credentials.json"]: + src = os.path.join(base, name) + if os.path.exists(src): + shutil.copy2(src, os.path.join(cfg, name)) + break + try: + yield cwd, cfg + finally: + shutil.rmtree(cwd, ignore_errors=True) + shutil.rmtree(cfg, ignore_errors=True) -def _format_permissions(perms: dict, label: str) -> None: - """Print a labelled permissions block as indented YAML.""" - print(f" {label}", flush=True) - if not perms: - print(" (empty — no permissions.yaml)", flush=True) - return - for line in yaml.dump(perms, default_flow_style=False, sort_keys=False).splitlines(): - print(f" {line}", flush=True) +def perm_path(cfg: str) -> str: + return os.path.join(cfg, "permissions.yaml") -def log_permissions(before: dict, after: dict) -> None: - """Print before/after permissions.yaml side by side (sequentially).""" - print(" ┌─ before ─────────────────────────────────", flush=True) - _format_permissions(before, "") - print(" ├─ after ──────────────────────────────────", flush=True) - _format_permissions(after, "") - print(" └──────────────────────────────────────────", flush=True) +def read_perms(cfg: str) -> dict: + p = perm_path(cfg) + return yaml.safe_load(open(p)) or {} if os.path.exists(p) else {} -def assert_true(cond: bool, msg: str): - if not cond: - raise AssertionError(msg) +def write_perms(cfg: str, data: dict): + with open(perm_path(cfg), "w") as f: + yaml.dump(data, f, default_flow_style=False) -def read_permissions() -> dict: - path = perm_file() - if not os.path.exists(path): - return {} - with open(path) as f: - return yaml.safe_load(f) or {} +def write_mcp(cwd: str, command: str, args=None, *, key="test-server"): + server: dict = {"command": command} + if args: + server["args"] = args + with open(os.path.join(cwd, ".mcp.json"), "w") as f: + json.dump({"mcpServers": {key: server}}, f) -def write_permissions(data: dict): - path = perm_file() - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - yaml.dump(data, f, default_flow_style=False) +def spawn(cwd: str, cfg: str, timeout: int = 30) -> pexpect.spawn: + env = {**os.environ, "TERM": "xterm-256color", "COLUMNS": "120", "LINES": "40", "FORGE_CONFIG": cfg} + return pexpect.spawn( + "/bin/sh", args=["-c", f"exec {FORGE_BIN} -p hello 2>&1"], + cwd=cwd, timeout=timeout, encoding="utf-8", codec_errors="replace", env=env, + ) -def remove_permissions(): - path = perm_file() - if os.path.exists(path): - os.remove(path) +def accept(child: pexpect.spawn): + """Wait for the MCP permission prompt and press Enter (Accept).""" + child.expect("Allow MCP server", timeout=30) + child.send("\r") + time.sleep(4) + child.close(force=True) -def run_scenario(name: str, fn): - print(f"\n{'─'*60}") - print(f"Scenario: {name}") - print(f"{'─'*60}") +def reject(child: pexpect.spawn): + """Wait for the MCP permission prompt and press Down+Enter (Reject).""" + child.expect("Allow MCP server", timeout=30) + for ch in ("\x1b", "[", "B"): # arrow-down as individual bytes for raw-mode TUI + child.send(ch) + time.sleep(0.1) + time.sleep(0.4) + child.send("\r") + time.sleep(4) + child.close(force=True) + + +def no_prompt(child: pexpect.spawn) -> bool: + """Return True if forge exits without showing the MCP permission prompt.""" + idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) + child.close(force=True) + return idx != 0 + + +def show_perms(cfg: str, before: dict, after: dict): + def dump(d): + if not d: + print(" (empty — no permissions.yaml)") + return + for line in yaml.dump(d, default_flow_style=False, sort_keys=False).splitlines(): + print(f" {line}") + print(" ┌─ before ─────────────────────────────────") + dump(before) + print(" ├─ after ──────────────────────────────────") + dump(after) + print(" └──────────────────────────────────────────") + + +def run(name: str, fn): + print(f"\n{'─'*60}\nScenario: {name}\n{'─'*60}") try: fn() print(f"Result: {PASS}") @@ -119,379 +138,102 @@ def run_scenario(name: str, fn): results.append((name, False, str(e))) -def spawn_forge_with_prompt(tmpdir: str, forge_config: str, timeout: int = 30) -> pexpect.spawn: - """ - Spawn `forge -p hello` inside tmpdir with a PTY. - - FORGE_CONFIG is set to an isolated temp dir so permissions.yaml is written - there, not into the real user config directory. - - The MCP permission TUI guards on `stderr().is_terminal()` before rendering. - pexpect.spawn() only allocates a PTY for stdout; stderr is a plain pipe so - is_terminal() returns false and the prompt is skipped entirely. - - Fix: wrap the call in `sh -c 'forge ... 2>&1'` so both stdout and stderr - share the same PTY file descriptor. With that, is_terminal() sees a TTY - and the crossterm TUI renders and accepts keyboard input normally. - """ - cmd = f"{FORGE_BIN} -p hello" - env = { - **os.environ, - "TERM": "xterm-256color", - "COLUMNS": "120", - "LINES": "40", - "FORGE_CONFIG": forge_config, - } - child = pexpect.spawn( - "/bin/sh", - args=["-c", f"exec {cmd} 2>&1"], - cwd=tmpdir, - timeout=timeout, - encoding="utf-8", - codec_errors="replace", - env=env, - ) - return child - - -def mcp_json(command: str, args=None) -> dict: - server: dict = {"command": command} - if args: - server["args"] = args - return {"mcpServers": {"test-server": server}} +def mcp_rules(perms: dict, permission: str) -> list: + return [ + p for p in perms.get("policies", []) + if isinstance(p, dict) and p.get("permission") == permission + and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] + ] # --------------------------------------------------------------------------- -# Scenario implementations +# Scenarios # --------------------------------------------------------------------------- -def make_dirs() -> "tuple[str, str]": - """Return (tmpdir, forge_config) — two fresh isolated temp directories. - - forge_config is seeded with the real forge config files (toml, credentials, - provider) so forge starts without a first-time setup prompt, but with no - permissions.yaml so the MCP gate fires normally. - """ - tmpdir = tempfile.mkdtemp(prefix="forge_mcp_cwd_") - forge_config = tempfile.mkdtemp(prefix="forge_mcp_cfg_") - - # Copy config files from the real forge base dir so forge starts configured. - real_base = None - for candidate in [ - os.path.expanduser("~/forge"), - os.path.expanduser("~/.forge"), - ]: - if os.path.isdir(candidate): - real_base = candidate - break - - if real_base: - for name in [".forge.toml", ".config.json", ".credentials.json", "provider.json"]: - src = os.path.join(real_base, name) - if os.path.exists(src): - shutil.copy2(src, os.path.join(forge_config, name)) - - return tmpdir, forge_config - - -def scenario_accept_writes_allow_rule(): - """ - No permissions.yaml + local MCP server → prompt fires → user picks Accept - → permissions.yaml written with allow rule for the server. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - # Write a local .mcp.json - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(mcp_json("echo", ["hello"]), f) - - before = read_permissions() - log("Spawning forge (no permissions.yaml) ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - - log("Waiting for permission prompt ...") - child.expect("Allow MCP server", timeout=30) - log("Prompt detected. Sending Enter (Accept) ...") - child.send("\r") - - # Wait for forge to persist the decision - time.sleep(4) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - policies = after.get("policies", []) - assert_true(len(policies) > 0, "Expected at least one policy to be written") - allow_policies = [p for p in policies if isinstance(p, dict) and p.get("permission") == "allow"] - assert_true(len(allow_policies) > 0, "Expected an 'allow' policy to be written") - mcp_rules = [p for p in allow_policies if isinstance(p.get("rule"), dict) and "mcp" in p["rule"]] - assert_true(len(mcp_rules) > 0, "Expected an MCP rule in the allow policy") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_reject_writes_deny_rule(): - """ - No permissions.yaml + local MCP server → prompt fires → user picks Reject - → permissions.yaml written with deny rule for the server. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(mcp_json("echo", ["hello"]), f) - - before = read_permissions() - log("Spawning forge (no permissions.yaml) ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - - log("Waiting for permission prompt ...") - child.expect("Allow MCP server", timeout=30) - log("Prompt detected. Sending Down then Enter (Reject) ...") - # Send arrow-down as individual bytes to ensure raw-mode TUI receives it - child.send("\x1b") - time.sleep(0.1) - child.send("[") - time.sleep(0.1) - child.send("B") - time.sleep(0.5) - child.send("\r") - - time.sleep(4) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - policies = after.get("policies", []) - deny_policies = [p for p in policies if isinstance(p, dict) and p.get("permission") == "deny"] - assert_true(len(deny_policies) > 0, "Expected a 'deny' policy to be written") - mcp_rules = [p for p in deny_policies if isinstance(p.get("rule"), dict) and "mcp" in p["rule"]] - assert_true(len(mcp_rules) > 0, "Expected an MCP deny rule for the server") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_existing_allow_skips_prompt(): - """ - permissions.yaml already has an allow rule → forge starts without prompting. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - write_permissions({ - "policies": [ - {"permission": "allow", "rule": {"mcp": {"command": "echo"}}} - ] - }) - - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(mcp_json("echo", ["hello"]), f) - - before = read_permissions() - log("Spawning forge (allow rule pre-written) ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - - idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - assert_true(idx != 0, "Permission prompt appeared even though allow rule was pre-written") - log("No permission prompt shown — correct.") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_second_run_skips_prompt(): - """ - After accepting on first run (permissions.yaml written), a second forge - invocation must NOT show the prompt again. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(mcp_json("echo", ["hello"]), f) - - # First run — accept - before_run1 = read_permissions() - log("First run: accepting permission ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - child.expect("Allow MCP server", timeout=30) - child.send("\r") - time.sleep(4) - child.close(force=True) - - after_run1 = read_permissions() - log(" [run 1]") - log_permissions(before_run1, after_run1) - assert_true(os.path.exists(perm_file()), "permissions.yaml not created after first accept") - - # Second run — should NOT prompt - before_run2 = read_permissions() - log("Second run: verifying no prompt ...") - child2 = spawn_forge_with_prompt(tmpdir, forge_config) - idx = child2.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) - child2.close(force=True) - - after_run2 = read_permissions() - log(" [run 2]") - log_permissions(before_run2, after_run2) - assert_true(idx != 0, "Permission prompt appeared on second run — decision was not persisted") - log("No prompt on second run — decision persisted correctly.") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_custom_mcp_server_accept(): - """ - Add a custom realistic MCP server (npx-based filesystem server) to - .mcp.json, start forge, answer Accept in the TUI prompt, and verify - permissions.yaml contains an allow rule for that exact server. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - custom_server = { - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", tmpdir], - } - } - } - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(custom_server, f) - - before = read_permissions() - log("Spawning forge with custom filesystem MCP server ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - - log("Waiting for permission prompt for 'filesystem' server ...") - child.expect('Allow MCP server "filesystem"', timeout=30) - log("Prompt detected. Sending Enter (Accept) ...") - child.send("\r") - - time.sleep(4) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - policies = after.get("policies", []) - allow_mcp = [ - p for p in policies - if isinstance(p, dict) and p.get("permission") == "allow" - and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] - ] - assert_true(len(allow_mcp) > 0, "Expected an allow MCP rule in permissions.yaml") - rule_mcp = allow_mcp[0]["rule"]["mcp"] - assert_true(rule_mcp.get("command") == "npx", f"Expected command='npx' in rule, got: {rule_mcp}") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_custom_mcp_server_reject(): - """ - Add a custom MCP server, start forge, answer Reject in the TUI prompt, - and verify permissions.yaml contains a deny rule for that server. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - custom_server = { - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", tmpdir], - } - } - } - with open(os.path.join(tmpdir, ".mcp.json"), "w") as f: - json.dump(custom_server, f) - - before = read_permissions() - log("Spawning forge with custom filesystem MCP server ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - - log("Waiting for permission prompt for 'filesystem' server ...") - child.expect('Allow MCP server "filesystem"', timeout=30) - log("Prompt detected. Sending Down + Enter (Reject) ...") - # Send arrow-down as individual bytes to ensure raw-mode TUI receives it - child.send("\x1b") - time.sleep(0.1) - child.send("[") - time.sleep(0.1) - child.send("B") - time.sleep(0.5) - child.send("\r") - - time.sleep(4) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - policies = after.get("policies", []) - deny_mcp = [ - p for p in policies - if isinstance(p, dict) and p.get("permission") == "deny" - and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] - ] - assert_true(len(deny_mcp) > 0, "Expected a deny MCP rule in permissions.yaml") - rule_mcp = deny_mcp[0]["rule"]["mcp"] - assert_true(rule_mcp.get("command") == "npx", f"Expected command='npx' in deny rule, got: {rule_mcp}") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) - - -def scenario_user_scope_never_prompts(): - """ - A server in the user-scope config (~/.mcp.json relative to FORGE_CONFIG) - must never prompt, regardless of permissions.yaml. - - The user-scope MCP file is `/.mcp.json`. - """ - global FORGE_CONFIG_DIR - tmpdir, forge_config = make_dirs() - FORGE_CONFIG_DIR = forge_config - try: - # Write the server to the user-scope MCP path inside our isolated FORGE_CONFIG - user_mcp = os.path.join(forge_config, ".mcp.json") - with open(user_mcp, "w") as f: - json.dump(mcp_json("echo", ["user-scope"]), f) - - before = read_permissions() - log("Spawning forge (user-scope server, no permissions.yaml) ...") - child = spawn_forge_with_prompt(tmpdir, forge_config) - idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) - child.close(force=True) - - after = read_permissions() - log_permissions(before, after) - assert_true(idx != 0, "Permission prompt appeared for a user-scope server — should be trusted unconditionally") - log("No prompt for user-scope server — trusted unconditionally.") - - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - shutil.rmtree(forge_config, ignore_errors=True) +def test_accept_writes_allow(): + with scenario_dirs() as (cwd, cfg): + write_mcp(cwd, "echo", ["hello"]) + before = read_perms(cfg) + accept(spawn(cwd, cfg)) + after = read_perms(cfg) + show_perms(cfg, before, after) + assert mcp_rules(after, "allow"), "Expected an MCP allow rule in permissions.yaml" + + +def test_reject_writes_deny(): + with scenario_dirs() as (cwd, cfg): + write_mcp(cwd, "echo", ["hello"]) + before = read_perms(cfg) + reject(spawn(cwd, cfg)) + after = read_perms(cfg) + show_perms(cfg, before, after) + assert mcp_rules(after, "deny"), "Expected an MCP deny rule in permissions.yaml" + + +def test_existing_allow_skips_prompt(): + with scenario_dirs() as (cwd, cfg): + write_perms(cfg, {"policies": [{"permission": "allow", "rule": {"mcp": {"command": "echo"}}}]}) + write_mcp(cwd, "echo", ["hello"]) + before = read_perms(cfg) + assert no_prompt(spawn(cwd, cfg)), "Prompt appeared even though allow rule was pre-written" + after = read_perms(cfg) + show_perms(cfg, before, after) + print(" No permission prompt shown — correct.") + + +def test_second_run_skips_prompt(): + with scenario_dirs() as (cwd, cfg): + write_mcp(cwd, "echo", ["hello"]) + + before1 = read_perms(cfg) + accept(spawn(cwd, cfg)) + after1 = read_perms(cfg) + print(" [run 1]") + show_perms(cfg, before1, after1) + assert os.path.exists(perm_path(cfg)), "permissions.yaml not created after first accept" + + before2 = read_perms(cfg) + assert no_prompt(spawn(cwd, cfg)), "Prompt appeared on second run — decision was not persisted" + after2 = read_perms(cfg) + print(" [run 2]") + show_perms(cfg, before2, after2) + print(" No prompt on second run — decision persisted correctly.") + + +def test_npx_server_accept(): + with scenario_dirs() as (cwd, cfg): + write_mcp(cwd, "npx", ["-y", "@modelcontextprotocol/server-filesystem", cwd], key="filesystem") + before = read_perms(cfg) + accept(spawn(cwd, cfg)) + after = read_perms(cfg) + show_perms(cfg, before, after) + rules = mcp_rules(after, "allow") + assert rules, "Expected an MCP allow rule" + assert rules[0]["rule"]["mcp"].get("command") == "npx", "Expected command='npx'" + + +def test_npx_server_reject(): + with scenario_dirs() as (cwd, cfg): + write_mcp(cwd, "npx", ["-y", "@modelcontextprotocol/server-filesystem", cwd], key="filesystem") + before = read_perms(cfg) + reject(spawn(cwd, cfg)) + after = read_perms(cfg) + show_perms(cfg, before, after) + rules = mcp_rules(after, "deny") + assert rules, "Expected an MCP deny rule" + assert rules[0]["rule"]["mcp"].get("command") == "npx", "Expected command='npx'" + + +def test_user_scope_never_prompts(): + with scenario_dirs() as (cwd, cfg): + # User-scope MCP lives inside FORGE_CONFIG, not in cwd + with open(os.path.join(cfg, ".mcp.json"), "w") as f: + json.dump({"mcpServers": {"user-server": {"command": "echo", "args": ["user-scope"]}}}, f) + before = read_perms(cfg) + assert no_prompt(spawn(cwd, cfg)), "Prompt appeared for a user-scope server — should be trusted unconditionally" + after = read_perms(cfg) + show_perms(cfg, before, after) + print(" No prompt for user-scope server — trusted unconditionally.") # --------------------------------------------------------------------------- @@ -504,28 +246,23 @@ def main(): print("=" * 60) print(f" forge binary: {FORGE_BIN}") - run_scenario("Accept → allow rule written to permissions.yaml", scenario_accept_writes_allow_rule) - run_scenario("Reject → deny rule written to permissions.yaml", scenario_reject_writes_deny_rule) - run_scenario("Pre-existing allow rule → no prompt", scenario_existing_allow_skips_prompt) - run_scenario("Second run after accept → no prompt", scenario_second_run_skips_prompt) - run_scenario("Custom MCP server (npx) Accept → allow rule in permissions.yaml", scenario_custom_mcp_server_accept) - run_scenario("Custom MCP server (npx) Reject → deny rule in permissions.yaml", scenario_custom_mcp_server_reject) - run_scenario("User-scope server → never prompts", scenario_user_scope_never_prompts) - - # Summary - print(f"\n{'='*60}") - print("SUMMARY") - print(f"{'='*60}") + run("Accept → allow rule written to permissions.yaml", test_accept_writes_allow) + run("Reject → deny rule written to permissions.yaml", test_reject_writes_deny) + run("Pre-existing allow rule → no prompt", test_existing_allow_skips_prompt) + run("Second run after accept → no prompt", test_second_run_skips_prompt) + run("Custom MCP server (npx) Accept → allow rule", test_npx_server_accept) + run("Custom MCP server (npx) Reject → deny rule", test_npx_server_reject) + run("User-scope server → never prompts", test_user_scope_never_prompts) + + print(f"\n{'='*60}\nSUMMARY\n{'='*60}") passed = sum(1 for _, ok, _ in results if ok) - failed = sum(1 for _, ok, _ in results if not ok) for name, ok, err in results: - status = PASS if ok else FAIL - print(f" {status} {name}") + print(f" {PASS if ok else FAIL} {name}") if err: for line in textwrap.wrap(err, width=72): print(f" {line}") print(f"\n{passed}/{len(results)} passed") - sys.exit(0 if failed == 0 else 1) + sys.exit(0 if passed == len(results) else 1) if __name__ == "__main__": From dbb9eca825e43e468c236b329e2dbc6934b63df0 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 15:00:21 +0530 Subject: [PATCH 43/44] refactor(skills): clean up mcp permissions test script --- .../test-mcp-permissions/scripts/test.py | 138 +++++++----------- 1 file changed, 52 insertions(+), 86 deletions(-) diff --git a/.forge/skills/test-mcp-permissions/scripts/test.py b/.forge/skills/test-mcp-permissions/scripts/test.py index 9a8e4fc5d0..7ab8729e27 100644 --- a/.forge/skills/test-mcp-permissions/scripts/test.py +++ b/.forge/skills/test-mcp-permissions/scripts/test.py @@ -8,7 +8,6 @@ import subprocess import sys import tempfile -import textwrap import time try: @@ -23,25 +22,16 @@ subprocess.check_call([sys.executable, "-m", "pip", "install", "pyyaml"]) import yaml -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- FORGE_BIN = os.path.abspath(os.environ.get("FORGE_BIN", "forge")) PASS = "\033[32mPASS\033[0m" FAIL = "\033[31mFAIL\033[0m" results: list = [] -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - @contextlib.contextmanager def scenario_dirs(): - """Yield (cwd, forge_config) as isolated temp dirs, cleaned up on exit.""" cwd = tempfile.mkdtemp(prefix="forge_mcp_cwd_") cfg = tempfile.mkdtemp(prefix="forge_mcp_cfg_") - # Seed config so forge skips first-time setup for base in [os.path.expanduser("~/forge"), os.path.expanduser("~/.forge")]: if os.path.isdir(base): for name in [".forge.toml", ".config.json", ".credentials.json"]: @@ -56,38 +46,33 @@ def scenario_dirs(): shutil.rmtree(cfg, ignore_errors=True) -def perm_path(cfg: str) -> str: - return os.path.join(cfg, "permissions.yaml") - - def read_perms(cfg: str) -> dict: - p = perm_path(cfg) + p = os.path.join(cfg, "permissions.yaml") return yaml.safe_load(open(p)) or {} if os.path.exists(p) else {} def write_perms(cfg: str, data: dict): - with open(perm_path(cfg), "w") as f: + with open(os.path.join(cfg, "permissions.yaml"), "w") as f: yaml.dump(data, f, default_flow_style=False) -def write_mcp(cwd: str, command: str, args=None, *, key="test-server"): +def write_mcp(path: str, command: str, args=None, key="test-server"): server: dict = {"command": command} if args: server["args"] = args - with open(os.path.join(cwd, ".mcp.json"), "w") as f: + with open(os.path.join(path, ".mcp.json"), "w") as f: json.dump({"mcpServers": {key: server}}, f) -def spawn(cwd: str, cfg: str, timeout: int = 30) -> pexpect.spawn: +def spawn(cwd: str, cfg: str) -> pexpect.spawn: env = {**os.environ, "TERM": "xterm-256color", "COLUMNS": "120", "LINES": "40", "FORGE_CONFIG": cfg} return pexpect.spawn( "/bin/sh", args=["-c", f"exec {FORGE_BIN} -p hello 2>&1"], - cwd=cwd, timeout=timeout, encoding="utf-8", codec_errors="replace", env=env, + cwd=cwd, timeout=30, encoding="utf-8", codec_errors="replace", env=env, ) def accept(child: pexpect.spawn): - """Wait for the MCP permission prompt and press Enter (Accept).""" child.expect("Allow MCP server", timeout=30) child.send("\r") time.sleep(4) @@ -95,9 +80,8 @@ def accept(child: pexpect.spawn): def reject(child: pexpect.spawn): - """Wait for the MCP permission prompt and press Down+Enter (Reject).""" child.expect("Allow MCP server", timeout=30) - for ch in ("\x1b", "[", "B"): # arrow-down as individual bytes for raw-mode TUI + for ch in ("\x1b", "[", "B"): # arrow-down as separate bytes for raw-mode TUI child.send(ch) time.sleep(0.1) time.sleep(0.4) @@ -107,45 +91,44 @@ def reject(child: pexpect.spawn): def no_prompt(child: pexpect.spawn) -> bool: - """Return True if forge exits without showing the MCP permission prompt.""" idx = child.expect(["Allow MCP server", pexpect.TIMEOUT, pexpect.EOF], timeout=15) child.close(force=True) return idx != 0 -def show_perms(cfg: str, before: dict, after: dict): +def mcp_rules(perms: dict, permission: str) -> list: + return [ + p for p in perms.get("policies", []) + if isinstance(p, dict) and p.get("permission") == permission + and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] + ] + + +def show_perms(before: dict, after: dict): def dump(d): if not d: - print(" (empty — no permissions.yaml)") + print(" (empty)") return for line in yaml.dump(d, default_flow_style=False, sort_keys=False).splitlines(): print(f" {line}") - print(" ┌─ before ─────────────────────────────────") + print(" ┌─ before ────────────────────────") dump(before) - print(" ├─ after ──────────────────────────────────") + print(" ├─ after ────────────────────────") dump(after) - print(" └──────────────────────────────────────────") + print(" └─────────────────────────────────") def run(name: str, fn): - print(f"\n{'─'*60}\nScenario: {name}\n{'─'*60}") + print(f"\n{'─'*50}\n{name}\n{'─'*50}") try: fn() - print(f"Result: {PASS}") + print(f" {PASS}") results.append((name, True, None)) except Exception as e: - print(f"Result: {FAIL} — {e}") + print(f" {FAIL} — {e}") results.append((name, False, str(e))) -def mcp_rules(perms: dict, permission: str) -> list: - return [ - p for p in perms.get("policies", []) - if isinstance(p, dict) and p.get("permission") == permission - and isinstance(p.get("rule"), dict) and "mcp" in p["rule"] - ] - - # --------------------------------------------------------------------------- # Scenarios # --------------------------------------------------------------------------- @@ -156,8 +139,8 @@ def test_accept_writes_allow(): before = read_perms(cfg) accept(spawn(cwd, cfg)) after = read_perms(cfg) - show_perms(cfg, before, after) - assert mcp_rules(after, "allow"), "Expected an MCP allow rule in permissions.yaml" + show_perms(before, after) + assert mcp_rules(after, "allow"), "Expected an MCP allow rule" def test_reject_writes_deny(): @@ -166,8 +149,8 @@ def test_reject_writes_deny(): before = read_perms(cfg) reject(spawn(cwd, cfg)) after = read_perms(cfg) - show_perms(cfg, before, after) - assert mcp_rules(after, "deny"), "Expected an MCP deny rule in permissions.yaml" + show_perms(before, after) + assert mcp_rules(after, "deny"), "Expected an MCP deny rule" def test_existing_allow_skips_prompt(): @@ -176,9 +159,7 @@ def test_existing_allow_skips_prompt(): write_mcp(cwd, "echo", ["hello"]) before = read_perms(cfg) assert no_prompt(spawn(cwd, cfg)), "Prompt appeared even though allow rule was pre-written" - after = read_perms(cfg) - show_perms(cfg, before, after) - print(" No permission prompt shown — correct.") + show_perms(before, read_perms(cfg)) def test_second_run_skips_prompt(): @@ -187,53 +168,41 @@ def test_second_run_skips_prompt(): before1 = read_perms(cfg) accept(spawn(cwd, cfg)) - after1 = read_perms(cfg) - print(" [run 1]") - show_perms(cfg, before1, after1) - assert os.path.exists(perm_path(cfg)), "permissions.yaml not created after first accept" + print(" [run 1]"); show_perms(before1, read_perms(cfg)) before2 = read_perms(cfg) assert no_prompt(spawn(cwd, cfg)), "Prompt appeared on second run — decision was not persisted" - after2 = read_perms(cfg) - print(" [run 2]") - show_perms(cfg, before2, after2) - print(" No prompt on second run — decision persisted correctly.") + print(" [run 2]"); show_perms(before2, read_perms(cfg)) -def test_npx_server_accept(): +def test_npx_accept(): with scenario_dirs() as (cwd, cfg): write_mcp(cwd, "npx", ["-y", "@modelcontextprotocol/server-filesystem", cwd], key="filesystem") before = read_perms(cfg) accept(spawn(cwd, cfg)) after = read_perms(cfg) - show_perms(cfg, before, after) + show_perms(before, after) rules = mcp_rules(after, "allow") - assert rules, "Expected an MCP allow rule" - assert rules[0]["rule"]["mcp"].get("command") == "npx", "Expected command='npx'" + assert rules and rules[0]["rule"]["mcp"].get("command") == "npx", "Expected npx allow rule" -def test_npx_server_reject(): +def test_npx_reject(): with scenario_dirs() as (cwd, cfg): write_mcp(cwd, "npx", ["-y", "@modelcontextprotocol/server-filesystem", cwd], key="filesystem") before = read_perms(cfg) reject(spawn(cwd, cfg)) after = read_perms(cfg) - show_perms(cfg, before, after) + show_perms(before, after) rules = mcp_rules(after, "deny") - assert rules, "Expected an MCP deny rule" - assert rules[0]["rule"]["mcp"].get("command") == "npx", "Expected command='npx'" + assert rules and rules[0]["rule"]["mcp"].get("command") == "npx", "Expected npx deny rule" def test_user_scope_never_prompts(): with scenario_dirs() as (cwd, cfg): - # User-scope MCP lives inside FORGE_CONFIG, not in cwd - with open(os.path.join(cfg, ".mcp.json"), "w") as f: - json.dump({"mcpServers": {"user-server": {"command": "echo", "args": ["user-scope"]}}}, f) + write_mcp(cfg, "echo", ["user-scope"], key="user-server") # inside cfg = user scope before = read_perms(cfg) - assert no_prompt(spawn(cwd, cfg)), "Prompt appeared for a user-scope server — should be trusted unconditionally" - after = read_perms(cfg) - show_perms(cfg, before, after) - print(" No prompt for user-scope server — trusted unconditionally.") + assert no_prompt(spawn(cwd, cfg)), "Prompt appeared for user-scope server" + show_perms(before, read_perms(cfg)) # --------------------------------------------------------------------------- @@ -241,26 +210,23 @@ def test_user_scope_never_prompts(): # --------------------------------------------------------------------------- def main(): - print("=" * 60) - print("MCP Permission Policy — End-to-End Tests") - print("=" * 60) - print(f" forge binary: {FORGE_BIN}") - - run("Accept → allow rule written to permissions.yaml", test_accept_writes_allow) - run("Reject → deny rule written to permissions.yaml", test_reject_writes_deny) - run("Pre-existing allow rule → no prompt", test_existing_allow_skips_prompt) - run("Second run after accept → no prompt", test_second_run_skips_prompt) - run("Custom MCP server (npx) Accept → allow rule", test_npx_server_accept) - run("Custom MCP server (npx) Reject → deny rule", test_npx_server_reject) - run("User-scope server → never prompts", test_user_scope_never_prompts) - - print(f"\n{'='*60}\nSUMMARY\n{'='*60}") + print(f"{'='*50}\nMCP Permission Policy — E2E Tests\n{'='*50}") + print(f" binary: {FORGE_BIN}\n") + + run("Accept → allow rule written", test_accept_writes_allow) + run("Reject → deny rule written", test_reject_writes_deny) + run("Pre-existing allow → no prompt", test_existing_allow_skips_prompt) + run("Second run after accept → no prompt", test_second_run_skips_prompt) + run("npx server Accept → allow rule", test_npx_accept) + run("npx server Reject → deny rule", test_npx_reject) + run("User-scope server → never prompts", test_user_scope_never_prompts) + passed = sum(1 for _, ok, _ in results if ok) + print(f"\n{'='*50}\nSUMMARY\n{'='*50}") for name, ok, err in results: print(f" {PASS if ok else FAIL} {name}") if err: - for line in textwrap.wrap(err, width=72): - print(f" {line}") + print(f" {err}") print(f"\n{passed}/{len(results)} passed") sys.exit(0 if passed == len(results) else 1) From 311e202092be3ffa6ccc21ca399d5cc1e2715673 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Thu, 14 May 2026 15:09:30 +0530 Subject: [PATCH 44/44] refactor(mcp): cache McpApp instance in executor to avoid redundant recomputation --- crates/forge_app/src/mcp_executor.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/forge_app/src/mcp_executor.rs b/crates/forge_app/src/mcp_executor.rs index 94bc63a483..b7f82612ef 100644 --- a/crates/forge_app/src/mcp_executor.rs +++ b/crates/forge_app/src/mcp_executor.rs @@ -6,11 +6,15 @@ use crate::{EnvironmentInfra, McpApp, McpService, Services}; pub struct McpExecutor { services: Arc, + /// Shared `McpApp` instance so `permitted_mcp_config` is computed at most + /// once per executor lifetime rather than on every tool call. + mcp_app: McpApp, } impl> McpExecutor { pub fn new(services: Arc) -> Self { - Self { services } + let mcp_app = McpApp::new(services.clone()); + Self { services, mcp_app } } pub async fn execute( @@ -22,13 +26,12 @@ impl> McpExec .send_tool_input(TitleFormat::info("MCP").sub_title(input.name.as_str())) .await?; - let mcp_app = McpApp::new(self.services.clone()); - let cfg = mcp_app.permitted_mcp_config().await?; + let cfg = self.mcp_app.permitted_mcp_config().await?; self.services.execute_mcp(input, cfg).await } pub async fn contains_tool(&self, tool_name: &ToolName) -> anyhow::Result { - let mcp_servers = McpApp::new(self.services.clone()).get_mcp_servers().await?; + let mcp_servers = self.mcp_app.get_mcp_servers().await?; // Convert Claude Code format (mcp__{server}__{tool}) to the internal legacy // format (mcp_{server}_tool_{tool}) before checking, so both name styles match. let legacy = tool_name.to_legacy_mcp_name();