Skip to content
10 changes: 8 additions & 2 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::path::PathBuf;
use std::path::{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, McpTrustStatus, ModelId, ProviderModels};
use forge_stream::MpscStream;
use futures::stream::BoxStream;
use url::Url;
Expand Down Expand Up @@ -175,6 +175,12 @@ pub trait API: Sync + Send {
/// Refresh MCP caches by fetching fresh data
async fn reload_mcp(&self) -> Result<()>;

/// Queries the trust status of the given MCP config file.
async fn get_mcp_trust_status(&self, path: &Path) -> Result<McpTrustStatus>;

/// Persists a trust decision for the given MCP config file.
async fn set_mcp_trust(&self, path: &Path, status: McpTrustStatus) -> Result<()>;

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

Expand Down
17 changes: 16 additions & 1 deletion crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

Expand Down Expand Up @@ -306,6 +306,21 @@ impl<
async fn reload_mcp(&self) -> Result<()> {
self.services.mcp_service().reload_mcp().await
}

async fn get_mcp_trust_status(&self, path: &Path) -> Result<McpTrustStatus> {
self.services
.mcp_config_manager()
.get_mcp_trust_status(path)
.await
}

async fn set_mcp_trust(&self, path: &Path, status: McpTrustStatus) -> Result<()> {
self.services
.mcp_config_manager()
.set_mcp_trust(path, status)
.await
}

async fn get_commands(&self) -> Result<Vec<Command>> {
self.services.get_commands().await
}
Expand Down
45 changes: 42 additions & 3 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ use derive_setters::Setters;
use forge_domain::{
AgentId, AnyProvider, Attachment, AuthContextRequest, AuthContextResponse, AuthMethod,
ChatCompletionMessage, CommandOutput, Context, Conversation, ConversationId, File, FileInfo,
FileStatus, Image, McpConfig, McpServers, Model, ModelId, Node, Provider, ProviderId,
ResultStream, Scope, SearchParams, SyncProgress, SyntaxError, Template, ToolCallFull,
ToolOutput, WorkspaceAuth, WorkspaceId, WorkspaceInfo,
FileStatus, Image, McpConfig, McpServers, McpTrustStatus, Model, ModelId, Node, Provider,
ProviderId, ResultStream, Scope, SearchParams, SyncProgress, SyntaxError, Template,
ToolCallFull, ToolOutput, WorkspaceAuth, WorkspaceId, WorkspaceInfo,
};
use forge_eventsource::EventSource;
use reqwest::Response;
Expand Down Expand Up @@ -214,6 +214,33 @@ pub trait McpConfigManager: Send + Sync {

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

/// Queries the trust status of the given MCP config file.
///
/// Returns the persisted status (`Trusted`, `Rejected`) for the file's
/// current contents, or `Unknown` if no decision has been recorded for
/// this file at its current hash.
///
/// # Errors
/// Returns an error if the file cannot be read or parsed.
async fn get_mcp_trust_status(&self, path: &Path) -> anyhow::Result<McpTrustStatus>;

/// Persists a trust decision for the given MCP config file.
///
/// Passing `McpTrustStatus::Unknown` clears any previously recorded
/// decision for the path.
///
/// # Errors
/// Returns an error if the file cannot be read or the trust store cannot
/// be persisted.
async fn set_mcp_trust(&self, path: &Path, status: McpTrustStatus) -> anyhow::Result<()>;

/// Drops untrusted servers from `raw`.
///
/// User-scope servers are always retained. Project-local servers are
/// retained only when the local config has been explicitly trusted at
/// its current content hash.
async fn filter_trusted(&self, raw: McpConfig) -> anyhow::Result<McpConfig>;
}

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

async fn get_mcp_trust_status(&self, path: &Path) -> anyhow::Result<McpTrustStatus> {
self.mcp_config_manager().get_mcp_trust_status(path).await
}

async fn set_mcp_trust(&self, path: &Path, status: McpTrustStatus) -> anyhow::Result<()> {
self.mcp_config_manager().set_mcp_trust(path, status).await
}

async fn filter_trusted(&self, raw: McpConfig) -> anyhow::Result<McpConfig> {
self.mcp_config_manager().filter_trusted(raw).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
193 changes: 187 additions & 6 deletions crates/forge_domain/src/mcp.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
//!
//! Follows the design specifications of Claude's [.mcp.json](https://docs.anthropic.com/en/docs/claude-code/tutorials#set-up-model-context-protocol-mcp)

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::ops::Deref;
use std::path::Path;

use derive_more::{Deref, Display, From};
use derive_setters::Setters;
Expand Down Expand Up @@ -288,23 +290,123 @@ 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 decisions 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 trust status of a single MCP config file (identified by path + content
/// hash).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTrustStatus {
/// The config has been explicitly accepted by the user.
Trusted,
/// The config has been explicitly rejected by the user.
Rejected,
/// The config has not yet been decided by the user.
Unknown,
}

/// A persisted trust decision: stores the user's choice together with the
/// content hash it was made against so that any modification to the file
/// invalidates the decision.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct McpTrustEntry {
hash: u64,
decision: McpTrustDecision,
}

/// The two terminal (persisted) states for a trust decision.
///
/// Distinct from `McpTrustStatus` so that the on-disk representation cannot
/// encode the `Unknown` variant (which simply means "no entry").
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum McpTrustDecision {
Trusted,
Rejected,
}

impl From<McpTrustDecision> for McpTrustStatus {
fn from(decision: McpTrustDecision) -> Self {
match decision {
McpTrustDecision::Trusted => McpTrustStatus::Trusted,
McpTrustDecision::Rejected => McpTrustStatus::Rejected,
}
}
}

/// 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)]
entries: BTreeMap<String, McpTrustEntry>,
}

impl McpTrustStore {
/// Derives the JSON-map key for a path. Returns a borrowed `&str` when
/// the path is already valid UTF-8, allocating only for paths containing
/// non-UTF-8 bytes.
fn key(path: &Path) -> Cow<'_, str> {
path.to_string_lossy()
}

/// Returns the trust status for the given path+hash pair.
///
/// Returns `Unknown` both when no decision has been persisted and when a
/// decision exists but was made against a different content hash (i.e.
/// the file has been modified since).
pub fn get_status(&self, path: &Path, content_hash: u64) -> McpTrustStatus {
match self.entries.get(Self::key(path).as_ref()) {
Some(entry) if entry.hash == content_hash => entry.decision.into(),
_ => McpTrustStatus::Unknown,
}
}

/// Records an accepted trust decision for the given path and content hash.
pub fn trust(&mut self, path: &Path, content_hash: u64) {
self.insert(path, content_hash, McpTrustDecision::Trusted);
}

/// Records a rejected trust decision for the given path and content hash.
pub fn reject(&mut self, path: &Path, content_hash: u64) {
self.insert(path, content_hash, McpTrustDecision::Rejected);
}

/// Clears any trust decision (accepted or rejected) for the given path.
pub fn clear(&mut self, path: &Path) {
self.entries.remove(Self::key(path).as_ref());
}

fn insert(&mut self, path: &Path, hash: u64, decision: McpTrustDecision) {
self.entries.insert(
Self::key(path).into_owned(),
McpTrustEntry { hash, decision },
);
}
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use pretty_assertions::assert_eq;

use super::*;

#[test]
Expand Down Expand Up @@ -593,6 +695,85 @@ mod tests {
}
}

#[test]
fn test_trust_store_unknown_when_empty() {
let fixture = McpTrustStore::default();
let actual = fixture.get_status(&PathBuf::from("/tmp/.mcp.json"), 42);
let expected = McpTrustStatus::Unknown;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_remembers_trust_decision() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.trust(&path, 42);

let actual = fixture.get_status(&path, 42);
let expected = McpTrustStatus::Trusted;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_remembers_reject_decision() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.reject(&path, 42);

let actual = fixture.get_status(&path, 42);
let expected = McpTrustStatus::Rejected;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_invalidates_on_hash_change() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.trust(&path, 42);

let actual = fixture.get_status(&path, 43);
let expected = McpTrustStatus::Unknown;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_trust_overwrites_prior_rejection() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.reject(&path, 42);
fixture.trust(&path, 42);

let actual = fixture.get_status(&path, 42);
let expected = McpTrustStatus::Trusted;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_clear_removes_decision() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.trust(&path, 42);
fixture.clear(&path);

let actual = fixture.get_status(&path, 42);
let expected = McpTrustStatus::Unknown;
assert_eq!(actual, expected);
}

#[test]
fn test_trust_store_roundtrips_through_json() {
let path = PathBuf::from("/tmp/.mcp.json");
let mut fixture = McpTrustStore::default();
fixture.trust(&path, 42);

let json = serde_json::to_string(&fixture).unwrap();
let restored: McpTrustStore = serde_json::from_str(&json).unwrap();

let actual = restored.get_status(&path, 42);
let expected = McpTrustStatus::Trusted;
assert_eq!(actual, expected);
}

#[test]
fn test_stdio_server_without_timeout() {
use pretty_assertions::assert_eq;
Expand Down
Loading
Loading