Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ pub trait API: Sync + Send {
/// Refresh MCP caches by fetching fresh data
async fn reload_mcp(&self) -> Result<()>;

/// Applies the interactive trust gate for any project-local MCP config.
/// Servers are NOT connected here — connections remain lazy and happen on
/// first tool use. Must be called once at startup.
async fn init_mcp(&self) -> Result<()>;

/// List of commands defined in .md file(s)
async fn get_commands(&self) -> Result<Vec<Command>>;

Expand Down
4 changes: 4 additions & 0 deletions crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ impl<
async fn reload_mcp(&self) -> Result<()> {
self.services.mcp_service().reload_mcp().await
}

async fn init_mcp(&self) -> Result<()> {
self.services.mcp_service().init_mcp().await
}
async fn get_commands(&self) -> Result<Vec<Command>> {
self.services.get_commands().await
}
Expand Down
17 changes: 17 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ pub trait McpConfigManager: Send + Sync {

/// Responsible for writing the McpConfig on disk.
async fn write_mcp_config(&self, config: &McpConfig, scope: &Scope) -> anyhow::Result<()>;

/// Returns the trusted subset of MCP servers, prompting interactively for
/// any project-local config file not yet approved. Must be called once at
/// the startup boundary, never on pure config-read paths.
async fn filter_trusted(&self, raw: McpConfig) -> anyhow::Result<McpConfig>;
}

#[async_trait::async_trait]
Expand All @@ -222,6 +227,10 @@ pub trait McpService: Send + Sync {
async fn execute_mcp(&self, call: ToolCallFull) -> anyhow::Result<ToolOutput>;
/// Refresh the MCP cache by fetching fresh data
async fn reload_mcp(&self) -> anyhow::Result<()>;
/// Applies the interactive trust gate for any project-local MCP config.
/// Servers are NOT connected here — connections remain lazy and happen on
/// first tool use. Must be called once at startup.
async fn init_mcp(&self) -> anyhow::Result<()>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -680,6 +689,10 @@ impl<I: Services> McpConfigManager for I {
.write_mcp_config(config, scope)
.await
}

async fn filter_trusted(&self, raw: McpConfig) -> anyhow::Result<McpConfig> {
self.mcp_config_manager().filter_trusted(raw).await
}
}

#[async_trait::async_trait]
Expand All @@ -695,6 +708,10 @@ impl<I: Services> McpService for I {
async fn reload_mcp(&self) -> anyhow::Result<()> {
self.mcp_service().reload_mcp().await
}

async fn init_mcp(&self) -> anyhow::Result<()> {
self.mcp_service().init_mcp().await
}
}

#[async_trait::async_trait]
Expand Down
5 changes: 5 additions & 0 deletions crates/forge_domain/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ impl Environment {
self.base_path.join(".mcp.json")
}

/// Returns the path where the MCP trust store is persisted across restarts.
pub fn mcp_trust_path(&self) -> PathBuf {
self.base_path.join(".mcp_trust.json")
}

pub fn agent_path(&self) -> PathBuf {
self.base_path.join("agents")
}
Expand Down
73 changes: 67 additions & 6 deletions crates/forge_domain/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use derive_more::{Deref, Display, From};
use derive_setters::Setters;
use merge::Merge;
use serde::{Deserialize, Serialize};
use strum_macros::{Display as StrumDisplay, EnumIter, EnumString};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Scope {
Expand Down Expand Up @@ -288,21 +289,81 @@ impl From<BTreeMap<ServerName, McpServerConfig>> for McpConfig {
}

impl McpConfig {
/// Compute a deterministic u64 identifier for this config
/// Compute a deterministic u64 identifier for this config.
///
/// Uses Rust's built-in `Hash` trait (derived) to compute a stable hash
/// and converts it to a hex u64 for use as a cache key.
/// BTreeMap ensures consistent ordering regardless of insertion order.
/// Uses FNV-64 (a non-cryptographic but stable, seed-free hasher) so the
/// same config always produces the same key across process restarts.
/// This is required for persisted trust-store lookups: `DefaultHasher`
/// uses a random seed per-process and would produce a different value on
/// every restart, causing "Trust and remember" to be ignored.
/// `BTreeMap` ensures consistent field ordering regardless of insertion
/// order.
pub fn cache_key(&self) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

let mut hasher = DefaultHasher::new();
let mut hasher = fnv_rs::Fnv64::default();
Hash::hash(self, &mut hasher);
hasher.finish()
}
}

/// The two choices presented to the user when an untrusted project-local
/// `.mcp.json` is detected at startup.
#[derive(Debug, Clone, PartialEq, Eq, StrumDisplay, EnumIter, EnumString)]
pub enum McpTrustResponse {
/// Allow the servers and remember this decision across future sessions.
/// The config hash is persisted so the prompt is skipped on next startup
/// as long as the file has not changed.
#[strum(to_string = "Accept")]
Accept,
/// Reject all servers from this config file.
#[strum(to_string = "Reject")]
Reject,
}

/// Persists accepted and rejected MCP config hashes across restarts. A path
/// maps to its content hash so that any modification to the file revokes the
/// stored decision and triggers a new prompt.
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct McpTrustStore {
#[serde(default)]
trusted: std::collections::HashMap<String, u64>,
#[serde(default)]
rejected: std::collections::HashMap<String, u64>,
}

impl McpTrustStore {
/// Returns true if the given path+hash pair has been previously accepted.
pub fn is_trusted(&self, path: &std::path::Path, content_hash: u64) -> bool {
self.trusted
.get(&path.to_string_lossy().into_owned())
.is_some_and(|&stored| stored == content_hash)
}

/// Returns true if the given path+hash pair has been previously rejected.
pub fn is_rejected(&self, path: &std::path::Path, content_hash: u64) -> bool {
self.rejected
.get(&path.to_string_lossy().into_owned())
.is_some_and(|&stored| stored == content_hash)
}

/// Records an accepted trust decision for the given path and content hash.
/// Clears any prior rejection for the same path.
pub fn remember(&mut self, path: std::path::PathBuf, content_hash: u64) {
let key = path.to_string_lossy().into_owned();
self.rejected.remove(&key);
self.trusted.insert(key, content_hash);
}

/// Records a rejected trust decision for the given path and content hash.
/// Clears any prior acceptance for the same path.
pub fn reject(&mut self, path: std::path::PathBuf, content_hash: u64) {
let key = path.to_string_lossy().into_owned();
self.trusted.remove(&key);
self.rejected.insert(key, content_hash);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3820,6 +3820,9 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
.await?;
// only call on_update if this is the first initialization
on_update(self.api.clone(), self.config.updates.as_ref()).await;
// Apply the MCP trust gate. Servers are NOT connected here —
// connections remain lazy and happen on first tool use.
self.api.init_mcp().await?;
}

// Execute independent operations in parallel to improve performance
Expand Down
123 changes: 120 additions & 3 deletions crates/forge_services/src/mcp/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ use std::sync::Arc;

use anyhow::Context;
use bytes::Bytes;
use forge_app::domain::{McpConfig, Scope};
use forge_app::domain::{McpConfig, McpTrustResponse, McpTrustStore, Scope};
use forge_app::{
EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra, KVStore, McpConfigManager,
McpServerInfra,
McpServerInfra, UserInfra,
};
use merge::Merge;

Expand All @@ -18,6 +18,7 @@ impl<I> ForgeMcpManager<I>
where
I: McpServerInfra + FileReaderInfra + FileInfoInfra + EnvironmentInfra + KVStore,
{
/// Creates a new [`ForgeMcpManager`] wrapping the provided infrastructure.
pub fn new(infra: Arc<I>) -> Self {
Self { infra }
}
Expand All @@ -36,6 +37,89 @@ where
}
}

impl<I> ForgeMcpManager<I>
where
I: McpServerInfra
+ FileReaderInfra
+ FileInfoInfra
+ EnvironmentInfra
+ FileWriterInfra
+ KVStore
+ UserInfra,
{
/// Reads the persisted trust store from disk, returning an empty store if
/// the file is absent or unreadable.
async fn read_trust_store(&self) -> anyhow::Result<McpTrustStore> {
let path = self.infra.get_environment().mcp_trust_path();
if !self.infra.is_file(&path).await.unwrap_or(false) {
return Ok(McpTrustStore::default());
}
let content = self.infra.read_utf8(&path).await?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}

/// Writes the trust store to disk at the environment's `mcp_trust_path`.
async fn write_trust_store(&self, store: &McpTrustStore) -> anyhow::Result<()> {
let path = self.infra.get_environment().mcp_trust_path();
let content = serde_json::to_string_pretty(store)?;
self.infra.write(&path, Bytes::from(content)).await
}

/// Applies the interactive trust gate for a project-local MCP config.
///
/// Checks the persisted trust store first: if the config hash is already
/// recorded as accepted or rejected, the prompt is skipped entirely.
/// Otherwise the user is asked to Accept (persists the hash as trusted) or
/// Reject (persists the hash as rejected and returns an empty config).
async fn apply_trust_gate(
&self,
local: McpConfig,
local_path: &Path,
) -> anyhow::Result<McpConfig> {
if local.mcp_servers.is_empty() {
return Ok(local);
}

let hash = local.cache_key();
let mut store = self.read_trust_store().await?;

// Skip the prompt if this exact config was previously accepted.
if store.is_trusted(local_path, hash) {
return Ok(local);
}

// Skip the prompt if this exact config was previously rejected.
if store.is_rejected(local_path, hash) {
return Ok(McpConfig::default());
}

let prompt = format_trust_prompt(local_path);
match self
.infra
.select_one_enum::<McpTrustResponse>(&prompt)
.await?
{
Some(McpTrustResponse::Accept) => {
store.remember(local_path.to_path_buf(), hash);
self.write_trust_store(&store).await?;
Ok(local)
}
Some(McpTrustResponse::Reject) | None => {
store.reject(local_path.to_path_buf(), hash);
self.write_trust_store(&store).await?;
Ok(McpConfig::default())
}
}
}
}

/// Builds the interactive prompt string shown to the user when an untrusted
/// project-local `.mcp.json` is found. Lists the file path and every server
/// name together with its command or URL.
fn format_trust_prompt(path: &Path) -> String {
format!("Untrusted MCP config was found at {}", path.display())
}

#[async_trait::async_trait]
impl<I> McpConfigManager for ForgeMcpManager<I>
where
Expand All @@ -44,7 +128,8 @@ where
+ FileInfoInfra
+ EnvironmentInfra
+ FileWriterInfra
+ KVStore,
+ KVStore
+ UserInfra,
{
async fn read_mcp_config(&self, scope: Option<&Scope>) -> anyhow::Result<McpConfig> {
match scope {
Expand Down Expand Up @@ -95,4 +180,36 @@ where

Ok(())
}

async fn filter_trusted(&self, raw: McpConfig) -> anyhow::Result<McpConfig> {
let env = self.infra.get_environment();

// User-scope config is always implicitly trusted.
let user_path = env.mcp_user_config();
let user_config = if self.infra.is_file(&user_path).await.unwrap_or(false) {
self.read_config(&user_path).await?
} else {
McpConfig::default()
};

// Local-scope config must pass the trust gate.
let local_path = env.mcp_local_config();
let local_config = if self.infra.is_file(&local_path).await.unwrap_or(false) {
let local_raw = self.read_config(&local_path).await?;
self.apply_trust_gate(local_raw, &local_path).await?
} else {
McpConfig::default()
};

// Merge: user first, then local (local takes precedence as in read_mcp_config).
let mut merged = user_config;
merged.merge(local_config);

// Retain only servers that exist in the merged trusted set.
let trusted_keys: std::collections::BTreeSet<_> =
merged.mcp_servers.keys().cloned().collect();
let mut result = raw;
result.mcp_servers.retain(|k, _| trusted_keys.contains(k));
Ok(result)
}
}
Loading
Loading