Skip to content
Draft
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
11 changes: 6 additions & 5 deletions crates/forge_app/src/mcp_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ impl<S: McpService> McpExecutor<S> {
self.services.execute_mcp(input).await
}

/// Check whether `tool_name` belongs to any MCP server.
///
/// This is a pure in-memory check that does NOT connect to any server.
/// Tool names are known either because the server connected during a
/// previous call, or because they were declared statically in the config.
pub async fn contains_tool(&self, tool_name: &ToolName) -> anyhow::Result<bool> {
let mcp_servers = self.services.get_mcp_servers().await?;
Ok(mcp_servers
.get_servers()
.values()
.any(|tools| tools.iter().any(|tool| tool.name == *tool_name)))
self.services.contains_mcp_tool(tool_name).await
}
}
13 changes: 12 additions & 1 deletion crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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,
ResultStream, Scope, SearchParams, SyncProgress, SyntaxError, Template, ToolCallFull, ToolName,
ToolOutput, WorkspaceAuth, WorkspaceId, WorkspaceInfo,
};
use reqwest::Response;
Expand Down Expand Up @@ -250,6 +250,13 @@ 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<()>;
/// Check whether a tool name belongs to any configured MCP server.
///
/// This is intentionally a pure in-memory check: it does NOT establish
/// a live connection to any server. Tool names are known either because
/// the server already connected during a previous call, or because they
/// were declared statically in the MCP config.
async fn contains_mcp_tool(&self, tool_name: &ToolName) -> anyhow::Result<bool>;
}

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

async fn contains_mcp_tool(&self, tool_name: &ToolName) -> anyhow::Result<bool> {
self.mcp_service().contains_mcp_tool(tool_name).await
}
}

#[async_trait::async_trait]
Expand Down
40 changes: 40 additions & 0 deletions crates/forge_domain/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ impl McpServerConfig {
env: env.unwrap_or_default(),
timeout: None,
disable: false,
tools: Vec::new(),
})
}

Expand All @@ -45,6 +46,7 @@ impl McpServerConfig {
headers: BTreeMap::new(),
timeout: None,
disable: false,
tools: Vec::new(),
})
}

Expand All @@ -62,6 +64,22 @@ impl McpServerConfig {
McpServerConfig::Http(_) => "HTTP",
}
}

/// Returns the statically-declared tool names for this server, if any.
///
/// Returns `None` when no tools have been declared in the config, meaning
/// the real tool list is only known after a live connection is established.
pub fn declared_tools(&self) -> Option<&[String]> {
let tools = match self {
McpServerConfig::Stdio(s) => &s.tools,
McpServerConfig::Http(h) => &h.tools,
};
if tools.is_empty() {
None
} else {
Some(tools.as_slice())
}
}
}

#[derive(Default, Debug, Clone, Serialize, Deserialize, Setters, PartialEq, Hash)]
Expand All @@ -88,6 +106,17 @@ pub struct McpStdioServer {
/// remove it from the config.
#[serde(default)]
pub disable: bool,

/// Optional static declaration of tool names exposed by this server.
///
/// When present, Forge populates the system-prompt tool list from these
/// names **without** establishing a live connection. The server is only
/// connected to when one of its tools is actually invoked.
///
/// When absent, the server's tools are unknown until first use and will
/// not appear in the system prompt until a tool from this server is called.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
}

#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
Expand All @@ -110,6 +139,17 @@ pub struct McpHttpServer {
/// remove it from the config.
#[serde(default)]
pub disable: bool,

/// Optional static declaration of tool names exposed by this server.
///
/// When present, Forge populates the system-prompt tool list from these
/// names **without** establishing a live connection. The server is only
/// connected to when one of its tools is actually invoked.
///
/// When absent, the server's tools are unknown until first use and will
/// not appear in the system prompt until a tool from this server is called.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
}

impl McpHttpServer {}
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_main/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ impl Section {
/// # Output Format
///
/// ```text
///
///
/// CONFIGURATION
/// model gpt-4
/// provider openai
Expand Down
113 changes: 113 additions & 0 deletions crates/forge_services/src/mcp/lazy_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Lazy MCP client that defers connection until a tool is actually called.
//!
//! During discovery (building the tool list for the system prompt) Forge only
//! needs to know *which* tools a server exposes, not to hold a live connection
//! to it. `LazyMcpClient` separates these two concerns:
//!
//! - **Discovery**: the client is constructed from config without any network
//! I/O. Tool names and schemas come either from statically-declared tools in
//! the MCP config, or are left unknown until the first call.
//! - **Execution**: on the first `list()` or `call()`, the real
//! `McpServerInfra::Client` is initialised via `connect()`. Subsequent
//! calls reuse the same underlying client.
//!
//! Thread-safety is guaranteed by [`tokio::sync::OnceCell`]: even if two
//! concurrent callers race to initialise the connection, only one
//! initialisation will run.

use std::collections::BTreeMap;
use std::sync::Arc;

use forge_app::{McpClientInfra, McpServerInfra};
use forge_domain::{McpServerConfig, ToolDefinition, ToolName, ToolOutput};
use tokio::sync::OnceCell;

/// A lazily-initialised MCP client.
///
/// Holds the configuration needed to connect to an MCP server and defers
/// the actual connection until [`list`] or [`call`] is first invoked.
pub(crate) struct LazyMcpClient<I: McpServerInfra> {
config: McpServerConfig,
env_vars: BTreeMap<String, String>,
infra: Arc<I>,
/// The real client, initialised on first use.
inner: Arc<OnceCell<I::Client>>,
}

impl<I: McpServerInfra> Clone for LazyMcpClient<I> {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
env_vars: self.env_vars.clone(),
infra: self.infra.clone(),
// Share the same OnceCell so all clones see the same connection.
inner: self.inner.clone(),
}
}
}

impl<I: McpServerInfra> LazyMcpClient<I> {
pub(crate) fn new(
config: McpServerConfig,
env_vars: BTreeMap<String, String>,
infra: Arc<I>,
) -> Self {
Self { config, env_vars, infra, inner: Arc::new(OnceCell::new()) }
}

/// Ensure the inner client is initialised and return a reference to it.
async fn client(&self) -> anyhow::Result<&I::Client> {
self.inner
.get_or_try_init(|| async {
self.infra
.connect(self.config.clone(), &self.env_vars)
.await
})
.await
}

/// Consume the lazy client and return the initialised inner client.
///
/// Prefers taking sole ownership via `Arc::try_unwrap` when this is the
/// last holder of the inner `Arc`. When other holders still exist (e.g.,
/// a clone kept alive in `pending_servers` at call time), it falls back to
/// cloning the already-initialised inner value — the two resulting handles
/// will share the same underlying transport.
///
/// # Errors
/// Returns an error if the inner client has not yet been initialised (i.e.,
/// neither `list()` nor `call()` has been called).
pub(crate) async fn into_inner(self) -> anyhow::Result<I::Client>
where
I::Client: Clone + Send + Sync + 'static,
{
// Take ownership of the Arc; if we hold the only reference, we can
// unwrap it without Clone. Otherwise clone the inner value.
match Arc::try_unwrap(self.inner) {
Ok(once_cell) => once_cell
.into_inner()
.ok_or_else(|| anyhow::anyhow!("LazyMcpClient: inner client not yet initialised")),
Err(arc) => arc
.get()
.cloned()
.ok_or_else(|| anyhow::anyhow!("LazyMcpClient: inner client not yet initialised")),
}
}
}

#[async_trait::async_trait]
impl<I: McpServerInfra + Send + Sync + 'static> McpClientInfra for LazyMcpClient<I> {
/// List tools — connects on first call, reuses the connection thereafter.
async fn list(&self) -> anyhow::Result<Vec<ToolDefinition>> {
self.client().await?.list().await
}

/// Execute a tool call — connects on first call, reuses thereafter.
async fn call(
&self,
tool_name: &ToolName,
input: serde_json::Value,
) -> anyhow::Result<ToolOutput> {
self.client().await?.call(tool_name, input).await
}
}
1 change: 1 addition & 0 deletions crates/forge_services/src/mcp/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod lazy_client;
mod manager;
mod service;
mod tool;
Expand Down
Loading
Loading