Files
codex/codex-rs/codex-mcp/src/tool_catalog_cache.rs
T
Charlie Marsh 7ccf78dec4 Preserve MCP sharing discovery when deferring cached stdio startup (#60037)
## Why

Subagents reusing cached MCP tool definitions were forced to use the legacy protocol, preventing connection sharing from being negotiated on first use.

## What changed

Keep stdio protocol discovery enabled during deferred startup while allowing dormant subagents to advertise cached legacy tools. Track modern discovery separately from catalog updates so discovery clients reject older legacy catalogs while legacy clients can still reuse them. Keep modern tools out of the legacy catalog when joining pooled connections.

## Testing

Add coverage for deferred subagents negotiating sharing on first use and reusing the same server process, while a legacy sibling retains cached definitions without starting its server. Extend cache coverage to ensure older legacy fetches cannot undo modern discovery.

GitOrigin-RevId: 5f8f4deb22cfb81a08c05ff3fcd7ba5c9199551c
2026-11-07 02:26:13 +00:00

492 lines
16 KiB
Rust

use std::collections::BTreeMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hash;
use std::hash::Hasher;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::Weak;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_config::McpServerAuth;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_exec_server::Environment;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_rmcp_client::ToolListRevision;
use lru::LruCache;
use rmcp::model::ElicitationCapability;
use sha1::Digest;
use sha1::Sha1;
use tokio::time::Instant;
use crate::McpProtocolMode;
use crate::McpRuntimeContext;
use crate::ToolInfo;
use crate::server::McpCredentialPolicy;
use crate::server::McpServerConnectionIdentity;
use crate::server::has_explicit_http_authorization;
const TOOL_CATALOG_CACHE_CAPACITY: usize = 32;
const TOOL_CATALOG_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
/// Process-scoped cache of recent reusable tool definitions for MCP servers.
#[derive(Clone)]
pub struct McpToolCatalogCache {
entries: Arc<Mutex<LruCache<ToolCatalogIdentity, Arc<ToolCatalogCacheEntry>>>>,
}
impl Default for McpToolCatalogCache {
fn default() -> Self {
Self {
entries: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(TOOL_CATALOG_CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN),
))),
}
}
}
struct ToolCatalogCacheEntry {
state: Mutex<ToolCatalogCacheState>,
next_fetch_generation: AtomicU64,
}
#[derive(Default)]
struct ToolCatalogCacheState {
snapshot: Option<ToolCatalogSnapshot>,
optional_startup_deadline: Option<OptionalStartupDeadline>,
last_catalog_generation: u64,
last_modern_discovery_generation: u64,
disabled_by_server: bool,
}
struct OptionalStartupDeadline {
grace: Duration,
deadline: Instant,
}
struct ToolCatalogSnapshot {
tools: Vec<ToolInfo>,
meta: Option<Arc<serde_json::Value>>,
published_at: Instant,
tool_list_revision: Option<ToolListRevision>,
}
#[derive(Clone)]
pub(crate) struct McpToolCatalogCacheContext {
pub(crate) track_tool_list_changes: bool,
pub(crate) before_stdio_discovery: bool,
entry: Arc<ToolCatalogCacheEntry>,
}
pub(crate) struct McpToolCatalogFetchTicket {
pub(crate) tool_list_revision: Option<ToolListRevision>,
generation: u64,
}
impl McpToolCatalogCache {
pub(crate) fn context(
&self,
server_name: &str,
config: &McpServerConfig,
runtime_context: &McpRuntimeContext,
resolved_environment: Option<&Arc<Environment>>,
client_context: (&ElicitationCapability, &ClientMcpExtensions),
connection_identity: Option<(&McpServerConnectionIdentity, McpProtocolMode, bool)>,
) -> Option<McpToolCatalogCacheContext> {
let identity = ToolCatalogIdentity::new(
server_name,
config,
runtime_context,
resolved_environment,
client_context,
connection_identity,
)?;
let entry = lock_unpoisoned(&self.entries)
.get_or_insert(identity, || Arc::new(ToolCatalogCacheEntry::default()))
.clone();
Some(McpToolCatalogCacheContext {
entry,
track_tool_list_changes: false,
before_stdio_discovery: false,
})
}
}
impl Default for ToolCatalogCacheEntry {
fn default() -> Self {
Self {
state: Mutex::new(ToolCatalogCacheState::default()),
next_fetch_generation: AtomicU64::new(0),
}
}
}
impl McpToolCatalogCacheContext {
fn accepts_snapshot(
&self,
state: &ToolCatalogCacheState,
snapshot: &ToolCatalogSnapshot,
) -> bool {
if self.before_stdio_discovery
&& state.last_modern_discovery_generation > state.last_catalog_generation
{
return false;
}
// Enabling refresh must not reuse a catalog published without notification tracking.
snapshot
.tool_list_revision
.as_ref()
.map_or(!self.track_tool_list_changes, |revision| {
revision.current() == 0
})
}
pub(crate) fn has_tools(&self) -> bool {
self.current_revision_if(|_| true).is_some()
}
/// Checks eligibility and reads the revision under one lock without cloning tools.
/// The predicate borrows the current catalog while the cache entry is locked.
pub(crate) fn current_revision_if(
&self,
accepts_tools: impl FnOnce(&[ToolInfo]) -> bool,
) -> Option<u64> {
let state = lock_unpoisoned(&self.entry.state);
let snapshot = state.snapshot.as_ref()?;
(!state.disabled_by_server
&& self.accepts_snapshot(&state, snapshot)
&& !snapshot.tools.is_empty()
&& snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL
&& accepts_tools(&snapshot.tools))
.then_some(state.last_catalog_generation)
}
pub(crate) fn optional_startup_deadline(
&self,
default_deadline: Instant,
startup_grace: Duration,
) -> Instant {
let mut state = lock_unpoisoned(&self.entry.state);
if state.disabled_by_server
|| state.snapshot.as_ref().is_some_and(|snapshot| {
self.accepts_snapshot(&state, snapshot)
&& snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL
})
{
return default_deadline;
}
let cached_deadline =
state
.optional_startup_deadline
.get_or_insert(OptionalStartupDeadline {
grace: startup_grace,
deadline: default_deadline,
});
if cached_deadline.grace != startup_grace {
*cached_deadline = OptionalStartupDeadline {
grace: startup_grace,
deadline: default_deadline,
};
}
cached_deadline.deadline
}
#[cfg(test)]
pub(crate) fn current_tools(&self) -> Option<Vec<ToolInfo>> {
self.current_tools_or(/*fallback*/ None)
}
/// Prefers the current catalog, retaining a capture's fallback across expiry but not opt-out.
pub(crate) fn current_tools_or(
&self,
fallback: Option<Vec<ToolInfo>>,
) -> Option<Vec<ToolInfo>> {
self.current_catalog_or(fallback.map(|tools| (tools, None)))
.map(|(tools, _)| tools)
}
pub(crate) fn current_catalog_or(
&self,
fallback: Option<(Vec<ToolInfo>, Option<Arc<serde_json::Value>>)>,
) -> Option<(Vec<ToolInfo>, Option<Arc<serde_json::Value>>)> {
let state = lock_unpoisoned(&self.entry.state);
if state.disabled_by_server
|| state
.snapshot
.as_ref()
.is_some_and(|snapshot| !self.accepts_snapshot(&state, snapshot))
{
return None;
}
state
.snapshot
.as_ref()
.filter(|snapshot| snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL)
.map(|snapshot| (snapshot.tools.clone(), snapshot.meta.clone()))
.or(fallback)
}
pub(crate) fn begin_fetch(&self) -> McpToolCatalogFetchTicket {
McpToolCatalogFetchTicket {
tool_list_revision: None,
generation: self
.entry
.next_fetch_generation
.fetch_add(1, Ordering::Relaxed)
+ 1,
}
}
pub(crate) fn disable(&self) {
let mut state = lock_unpoisoned(&self.entry.state);
state.disabled_by_server = true;
state.snapshot = None;
}
/// A modern handshake rules out older legacy catalogs for discovery. Clients forced to use
/// the legacy protocol can still use those catalogs, including legacy fetches in flight.
pub(crate) fn record_modern_stdio_discovery(&self, ticket: &McpToolCatalogFetchTicket) {
let mut state = lock_unpoisoned(&self.entry.state);
if ticket.generation
> state
.last_catalog_generation
.max(state.last_modern_discovery_generation)
{
state.last_modern_discovery_generation = ticket.generation;
state.optional_startup_deadline = None;
}
}
pub(crate) fn publish_if_newest(
&self,
ticket: McpToolCatalogFetchTicket,
tools: &[ToolInfo],
meta: Option<Arc<serde_json::Value>>,
) {
let mut state = lock_unpoisoned(&self.entry.state);
if state.disabled_by_server || ticket.generation <= state.last_catalog_generation {
return;
}
state.last_catalog_generation = ticket.generation;
state.optional_startup_deadline = None;
let mut tools = tools.to_vec();
for tool in &mut tools {
// Tool annotations affect approval and parallelism decisions, so only the live
// connection may supply them.
tool.tool.annotations = None;
}
state.snapshot = Some(ToolCatalogSnapshot {
tools,
meta,
published_at: Instant::now(),
tool_list_revision: ticket.tool_list_revision,
});
}
}
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct ToolCatalogIdentity {
server_name: String,
transport: ToolCatalogTransportIdentity,
environment: Option<Weak<Environment>>,
local_stdio_fallback_cwd: Option<PathBuf>,
}
impl PartialEq for ToolCatalogIdentity {
fn eq(&self, other: &Self) -> bool {
self.server_name == other.server_name
&& self.transport == other.transport
&& self.local_stdio_fallback_cwd == other.local_stdio_fallback_cwd
&& match (&self.environment, &other.environment) {
(Some(environment), Some(other)) => Weak::ptr_eq(environment, other),
(None, None) => true,
_ => false,
}
}
}
impl Eq for ToolCatalogIdentity {}
impl Hash for ToolCatalogIdentity {
fn hash<H: Hasher>(&self, state: &mut H) {
self.server_name.hash(state);
self.transport.hash(state);
self.local_stdio_fallback_cwd.hash(state);
self.environment
.as_ref()
.map(|environment| Weak::as_ptr(environment) as usize)
.hash(state);
}
}
impl ToolCatalogIdentity {
fn new(
server_name: &str,
config: &McpServerConfig,
runtime_context: &McpRuntimeContext,
environment: Option<&Arc<Environment>>,
client_context: (&ElicitationCapability, &ClientMcpExtensions),
connection_identity: Option<(&McpServerConnectionIdentity, McpProtocolMode, bool)>,
) -> Option<Self> {
let transport =
ToolCatalogTransportIdentity::new(config, client_context, connection_identity)?;
Some(Self {
server_name: server_name.to_string(),
transport,
environment: environment.map(Arc::downgrade),
local_stdio_fallback_cwd: matches!(
&config.transport,
McpServerTransportConfig::Stdio { cwd: None, .. }
)
.then(|| runtime_context.local_process_cwd()),
})
}
}
#[derive(PartialEq, Eq, Hash)]
enum ToolCatalogTransportIdentity {
Stdio { fingerprint: [u8; 20] },
StreamableHttp { fingerprint: [u8; 20] },
}
impl ToolCatalogTransportIdentity {
fn new(
config: &McpServerConfig,
client_context: (&ElicitationCapability, &ClientMcpExtensions),
connection_identity: Option<(&McpServerConnectionIdentity, McpProtocolMode, bool)>,
) -> Option<Self> {
let (client_elicitation_capability, client_mcp_extensions) = client_context;
if let McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
http_headers_helper,
} = &config.transport
{
// Helper output is a dynamic credential identity that cannot be represented by config.
if http_headers_helper.is_some() {
return None;
}
let (connection_identity, protocol_mode, agent_plugin) = connection_identity?;
if config.oauth.is_some()
|| config.scopes.is_some()
|| config.oauth_resource.is_some()
|| (matches!(config.auth, McpServerAuth::ChatGpt)
&& !has_explicit_http_authorization(config))
|| (!has_explicit_http_authorization(config)
&& connection_identity.oauth_credentials().ok()?.is_some())
{
return None;
}
let mut hasher = Sha1::new();
hasher.update(match connection_identity.credential_policy {
McpCredentialPolicy::HostFallbackAllowed => b"host-fallback".as_slice(),
McpCredentialPolicy::ExecutorOnly => b"executor-only".as_slice(),
});
hasher.update(
serde_json::to_vec(&(
url,
bearer_token_env_var,
http_headers
.as_ref()
.map(|headers| headers.iter().collect::<BTreeMap<_, _>>()),
env_http_headers
.as_ref()
.map(|headers| headers.iter().collect::<BTreeMap<_, _>>()),
&config.auth,
&config.environment_id,
agent_plugin,
protocol_mode.preferred_protocol_version().as_str(),
client_elicitation_capability,
client_mcp_extensions.iter().collect::<BTreeMap<_, _>>(),
))
.ok()?,
);
let mut env_vars = bearer_token_env_var
.iter()
.chain(env_http_headers.iter().flat_map(|headers| headers.values()))
.collect::<Vec<_>>();
env_vars.sort_unstable();
env_vars.dedup();
for name in env_vars {
hasher.update(name.as_bytes());
if connection_identity.credential_policy == McpCredentialPolicy::HostFallbackAllowed
{
let mut value_hasher = DefaultHasher::new();
std::env::var_os(name).hash(&mut value_hasher);
hasher.update(value_hasher.finish().to_le_bytes());
}
}
return Some(Self::StreamableHttp {
fingerprint: hasher.finalize().into(),
});
}
let McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} = &config.transport
else {
return None;
};
// An unmarked helper can expose different tools after a legacy fallback.
if matches!(
connection_identity,
Some((_, McpProtocolMode::DiscoverLocalStdio, _))
) {
return None;
}
if env_vars
.iter()
.any(codex_config::McpServerEnvVar::is_remote_source)
{
return None;
}
let mut hasher = Sha1::new();
let env = env.as_ref().map(|env| {
env.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<BTreeMap<_, _>>()
});
hasher.update(
serde_json::to_vec(&(
command,
args,
env,
env_vars,
cwd,
&config.environment_id,
connection_identity.map(|(_, mode, _)| mode.preferred_protocol_version()),
client_elicitation_capability,
client_mcp_extensions.iter().collect::<BTreeMap<_, _>>(),
))
.ok()?,
);
for env_var in env_vars {
hasher.update(env_var.name().as_bytes());
let mut value_hasher = DefaultHasher::new();
std::env::var_os(env_var.name()).hash(&mut value_hasher);
hasher.update(value_hasher.finish().to_le_bytes());
}
Some(Self::Stdio {
fingerprint: hasher.finalize().into(),
})
}
}