mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Centralize thread MCP state in McpRuntime (#34930)
## What changed - Make `McpRuntime` own the published MCP configuration, connections, elicitation routing, and selected capability roots for a thread. - Capture immutable MCP bindings for model steps and tool calls so in-flight work keeps a consistent connection set and approval authority while refreshed state is published atomically. - Mark MCP state dirty when relevant configuration, plugins, environments, authentication, or elicitation capabilities change, then rebuild it before the next sampling request or out-of-band MCP operation. - Separate config reloads from server invalidation: `ReloadMcpConfig` applies resolved MCP inputs, while `RefreshMcpServers` requests reinitialization from the thread's latest state. ## Testing - Cover refreshed state visibility for existing turns, stable step bindings, current approval authority, cancelled refresh retries, resource-client reconciliation, and Apps recovery between sampling requests. GitOrigin-RevId: 59eabb1aa8dc083426bd18ef4d3630508f376401
This commit is contained in:
@@ -32,16 +32,11 @@ pub(crate) fn effective_plugins_changed_callback(
|
||||
thread_manager.skills_service().clear_cache();
|
||||
|
||||
let refresh_thread_manager = Arc::clone(&thread_manager);
|
||||
let refresh_config_manager = config_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if refresh_thread_manager.list_thread_ids().await.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::mcp_refresh::queue_best_effort_refresh(
|
||||
&refresh_thread_manager,
|
||||
&refresh_config_manager,
|
||||
)
|
||||
.await;
|
||||
crate::mcp_refresh::invalidate_loaded_threads(&refresh_thread_manager).await;
|
||||
});
|
||||
|
||||
if change.materialized_remote_plugins.is_empty() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config_manager::ConfigManager;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -8,7 +9,7 @@ use std::io;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) async fn queue_strict_refresh(
|
||||
pub(crate) async fn reload_mcp_config(
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
config_manager: &ConfigManager,
|
||||
) -> io::Result<()> {
|
||||
@@ -25,15 +26,19 @@ pub(crate) async fn queue_strict_refresh(
|
||||
refreshes.push((thread_id, thread, config));
|
||||
}
|
||||
for (thread_id, thread, config) in refreshes {
|
||||
queue_refresh(thread_id, thread, config).await?;
|
||||
thread
|
||||
.submit(Op::ReloadMcpConfig { config })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to queue MCP config reload for thread {thread_id}: {err}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_best_effort_refresh(
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
config_manager: &ConfigManager,
|
||||
) {
|
||||
pub(crate) async fn invalidate_loaded_threads(thread_manager: &Arc<ThreadManager>) {
|
||||
for thread_id in thread_manager.list_thread_ids().await {
|
||||
let thread = match thread_manager.get_thread(thread_id).await {
|
||||
Ok(thread) => thread,
|
||||
@@ -42,31 +47,29 @@ pub(crate) async fn queue_best_effort_refresh(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config = match build_refresh_config(thread.as_ref(), config_manager).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
warn!("failed to build MCP refresh config for thread {thread_id}: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(err) = queue_refresh(thread_id, thread, config).await {
|
||||
if let Err(err) = queue_invalidation(thread_id, thread).await {
|
||||
warn!("{err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_refresh_config(
|
||||
thread: &CodexThread,
|
||||
config_manager: &ConfigManager,
|
||||
) -> io::Result<Config> {
|
||||
let thread_config = thread.config().await;
|
||||
config_manager
|
||||
.load_latest_config_for_thread(thread_config.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_refresh_config(
|
||||
thread: &CodexThread,
|
||||
config_manager: &ConfigManager,
|
||||
) -> io::Result<McpServerRefreshConfig> {
|
||||
let thread_config = thread.config().await;
|
||||
let config = config_manager
|
||||
.load_latest_config_for_thread(thread_config.as_ref())
|
||||
.await?;
|
||||
let mcp_config = thread.runtime_mcp_config(&config).await;
|
||||
let mcp_servers = codex_mcp::configured_mcp_servers(&mcp_config);
|
||||
let config = load_refresh_config(thread, config_manager).await?;
|
||||
Ok(McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?,
|
||||
mcp_servers: serde_json::to_value(config.mcp_servers.get()).map_err(io::Error::other)?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
)
|
||||
@@ -76,13 +79,9 @@ async fn build_refresh_config(
|
||||
})
|
||||
}
|
||||
|
||||
async fn queue_refresh(
|
||||
thread_id: ThreadId,
|
||||
thread: Arc<CodexThread>,
|
||||
config: McpServerRefreshConfig,
|
||||
) -> io::Result<()> {
|
||||
async fn queue_invalidation(thread_id: ThreadId, thread: Arc<CodexThread>) -> io::Result<()> {
|
||||
thread
|
||||
.submit(Op::RefreshMcpServers { config })
|
||||
.submit(Op::RefreshMcpServers)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
@@ -127,35 +126,50 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> {
|
||||
let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?;
|
||||
|
||||
let err = queue_strict_refresh(&thread_manager, &config_manager)
|
||||
.await
|
||||
.expect_err("strict refresh should fail");
|
||||
|
||||
assert_eq!(err.to_string(), "failed to load refresh config");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> {
|
||||
let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?;
|
||||
|
||||
queue_best_effort_refresh(&thread_manager, &config_manager).await;
|
||||
|
||||
assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_config_uses_latest_auth_keyring_backend() -> anyhow::Result<()> {
|
||||
let (temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?;
|
||||
std::fs::write(
|
||||
temp_dir.path().join(codex_config::CONFIG_TOML_FILE),
|
||||
"[features]\nsecret_auth_storage = true\n",
|
||||
)?;
|
||||
|
||||
let err = reload_mcp_config(&thread_manager, &config_manager)
|
||||
.await
|
||||
.expect_err("strict refresh should fail");
|
||||
|
||||
assert_eq!(err.to_string(), "failed to load refresh config");
|
||||
for thread_id in thread_manager.list_thread_ids().await {
|
||||
assert_eq!(
|
||||
thread_manager
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.config()
|
||||
.await
|
||||
.auth_keyring_backend_kind(),
|
||||
AuthKeyringBackendKind::Direct
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidation_does_not_reload_thread_config() -> anyhow::Result<()> {
|
||||
let (_temp_dir, thread_manager, _config_manager, loader) = refresh_test_state().await?;
|
||||
|
||||
invalidate_loaded_threads(&thread_manager).await;
|
||||
|
||||
assert_eq!(loader.good_loads.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_config_reload_only_applies_mcp_inputs() -> anyhow::Result<()> {
|
||||
let (temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?;
|
||||
std::fs::write(
|
||||
temp_dir.path().join(codex_config::CONFIG_TOML_FILE),
|
||||
"model = \"unrelated-model-change\"\n[features]\nsecret_auth_storage = true\n",
|
||||
)?;
|
||||
|
||||
let mut good_thread = None;
|
||||
for thread_id in thread_manager.list_thread_ids().await {
|
||||
let thread = thread_manager.get_thread(thread_id).await?;
|
||||
@@ -166,17 +180,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let thread = good_thread.expect("good test thread should exist");
|
||||
let original_model = thread.config().await.model.clone();
|
||||
|
||||
let refresh_config = build_refresh_config(thread.as_ref(), &config_manager).await?;
|
||||
let backend = serde_json::from_value::<AuthKeyringBackendKind>(
|
||||
let keyring_backend_kind = serde_json::from_value::<AuthKeyringBackendKind>(
|
||||
refresh_config.auth_keyring_backend_kind,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
thread.config().await.auth_keyring_backend_kind(),
|
||||
AuthKeyringBackendKind::Direct
|
||||
);
|
||||
assert_eq!(backend, AuthKeyringBackendKind::Secrets);
|
||||
assert_eq!(keyring_backend_kind, AuthKeyringBackendKind::Secrets);
|
||||
assert_eq!(thread.config().await.model, original_model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -253,7 +265,7 @@ enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
queue_strict_refresh(&thread_manager, &config_manager).await?;
|
||||
reload_mcp_config(&thread_manager, &config_manager).await?;
|
||||
|
||||
assert_eq!(
|
||||
thread.config().await.mcp_servers.get(),
|
||||
|
||||
@@ -258,7 +258,12 @@ impl AccountRequestProcessor {
|
||||
if thread_manager.list_thread_ids().await.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await;
|
||||
if let Err(err) =
|
||||
crate::mcp_refresh::reload_mcp_config(&thread_manager, &config_manager).await
|
||||
{
|
||||
warn!(%err, "failed to reload MCP configuration after account or plugin change");
|
||||
crate::mcp_refresh::invalidate_loaded_threads(&thread_manager).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ use codex_connectors::connector_tool_is_synthetic;
|
||||
use codex_connectors::installed_connector_runtime;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
use codex_mcp::host_owned_codex_apps_enabled;
|
||||
use codex_mcp::tool_is_model_visible;
|
||||
use codex_mcp::tool_plugin_provenance;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
|
||||
const CONNECTOR_RUNTIME_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -52,7 +52,10 @@ impl AppsRequestProcessor {
|
||||
let runtime_enabled = apps_enabled && workspace_enabled;
|
||||
|
||||
let mcp_manager = self.thread_manager.mcp_manager();
|
||||
let mcp_config = mcp_manager.runtime_config(&config).await;
|
||||
let mut mcp_config = mcp_manager.runtime_config(&config).await;
|
||||
// Installed-app discovery has no active turn or reviewer.
|
||||
mcp_config.permission_profile = PermissionProfile::default();
|
||||
let mcp_config = Arc::new(mcp_config);
|
||||
let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME);
|
||||
let cache_key = connector_runtime_context_key(auth.as_ref());
|
||||
@@ -78,34 +81,31 @@ impl AppsRequestProcessor {
|
||||
let codex_apps_auth_manager =
|
||||
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.auth_manager));
|
||||
let connection_manager = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
&config.permissions.approval_policy,
|
||||
APPS_INSTALLED_SUBMIT_ID.to_string(),
|
||||
/*tx_event*/ None,
|
||||
cancellation_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
let runtime = McpRuntime::new(McpRuntimeInput {
|
||||
config: Arc::clone(&mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
mcp_servers,
|
||||
submit_id: APPS_INSTALLED_SUBMIT_ID.to_string(),
|
||||
tx_event: None,
|
||||
startup_cancellation_token: cancellation_token.clone(),
|
||||
runtime_context,
|
||||
mcp_config.codex_home.clone(),
|
||||
mcp_manager.codex_apps_tools_cache(),
|
||||
mcp_manager.tool_catalog_cache(),
|
||||
cache_key.clone(),
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability.clone(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
tool_plugin_provenance(&mcp_config),
|
||||
auth.as_ref(),
|
||||
codex_apps_tools_cache: mcp_manager.codex_apps_tools_cache(),
|
||||
tool_catalog_cache: mcp_manager.tool_catalog_cache(),
|
||||
codex_apps_tools_cache_key: cache_key.clone(),
|
||||
supports_openai_form_elicitation: false,
|
||||
auth: auth.clone(),
|
||||
codex_apps_auth_manager,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let result = if connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, startup_timeout)
|
||||
let result = if runtime
|
||||
.latest_wait_for_server_ready(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
startup_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
mcp_manager
|
||||
@@ -122,7 +122,7 @@ impl AppsRequestProcessor {
|
||||
))
|
||||
};
|
||||
cancellation_token.cancel();
|
||||
connection_manager.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
result
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -276,8 +276,8 @@ impl ConfigRequestProcessor {
|
||||
}
|
||||
|
||||
async fn reload_user_config(&self) {
|
||||
let next_config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to rebuild user config for runtime refresh: {}",
|
||||
@@ -291,7 +291,19 @@ impl ConfigRequestProcessor {
|
||||
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
thread.refresh_runtime_config(next_config.clone()).await;
|
||||
let current_config = thread.config().await;
|
||||
let next_config = match self
|
||||
.config_manager
|
||||
.load_latest_config_for_thread(current_config.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
tracing::warn!(%thread_id, %err, "failed to reload thread configuration");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
thread.refresh_runtime_config(next_config).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ impl McpRequestProcessor {
|
||||
&self,
|
||||
_params: Option<()>,
|
||||
) -> Result<McpServerRefreshResponse, JSONRPCErrorError> {
|
||||
crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager)
|
||||
crate::mcp_refresh::reload_mcp_config(&self.thread_manager, &self.config_manager)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?;
|
||||
Ok(McpServerRefreshResponse {})
|
||||
@@ -125,8 +125,9 @@ impl McpRequestProcessor {
|
||||
let (mcp_config, runtime_context) = match thread_id.as_deref() {
|
||||
Some(thread_id) => {
|
||||
let (_, thread) = self.load_thread(thread_id).await?;
|
||||
let runtime = thread.current_mcp_runtime().await;
|
||||
(runtime.config().clone(), runtime.runtime_context().clone())
|
||||
let (config, runtime_context) =
|
||||
thread.current_mcp_config_and_runtime_context().await;
|
||||
((*config).clone(), runtime_context)
|
||||
}
|
||||
None => {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
@@ -249,8 +250,8 @@ impl McpRequestProcessor {
|
||||
let (mcp_config, runtime_context) = match thread {
|
||||
Some(thread) => {
|
||||
let mcp_config = thread.runtime_mcp_config(&config).await;
|
||||
let runtime = thread.current_mcp_runtime().await;
|
||||
(mcp_config, runtime.runtime_context().clone())
|
||||
let (_, runtime_context) = thread.current_mcp_config_and_runtime_context().await;
|
||||
(mcp_config, runtime_context)
|
||||
}
|
||||
None => {
|
||||
let mcp_config = mcp_manager.runtime_config(&config).await;
|
||||
|
||||
@@ -148,6 +148,7 @@ impl fmt::Debug for McpBinding {
|
||||
pub struct PreparedMcpCall {
|
||||
_connections: Arc<McpConnectionSet>,
|
||||
client: Arc<ManagedClient>,
|
||||
config: Arc<McpConfig>,
|
||||
catalog_revision: u64,
|
||||
catalog_revision_source: Arc<RwLock<u64>>,
|
||||
tool_info: ToolInfo,
|
||||
@@ -165,6 +166,7 @@ impl PreparedMcpCall {
|
||||
pub(crate) fn new(
|
||||
connections: Arc<McpConnectionSet>,
|
||||
client: Arc<ManagedClient>,
|
||||
config: Arc<McpConfig>,
|
||||
catalog_revision: u64,
|
||||
catalog_revision_source: Arc<RwLock<u64>>,
|
||||
tool_info: ToolInfo,
|
||||
@@ -176,6 +178,7 @@ impl PreparedMcpCall {
|
||||
Self {
|
||||
_connections: connections,
|
||||
client,
|
||||
config,
|
||||
catalog_revision,
|
||||
catalog_revision_source,
|
||||
tool_info,
|
||||
@@ -190,6 +193,11 @@ impl PreparedMcpCall {
|
||||
&self.tool_info
|
||||
}
|
||||
|
||||
/// Returns the configuration and approval authority captured with this client.
|
||||
pub fn config(&self) -> &McpConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn server_name(&self) -> &str {
|
||||
&self.server_name
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_config::AppToolApproval;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_protocol::mcp::McpServerInfo;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -97,15 +98,20 @@ async fn test_step(
|
||||
SERVER_NAME.to_string(),
|
||||
Arc::clone(&managed_client),
|
||||
)])));
|
||||
let connections = Arc::new(McpConnectionSet::new_uninitialized_with_permission_profile(
|
||||
&Constrained::allow_any(AskForApproval::OnRequest),
|
||||
&PermissionProfile::default(),
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
));
|
||||
let connections = Arc::new(McpConnectionSet::empty(/*prefix_mcp_tool_names*/ true));
|
||||
let tool_catalog_revision = Arc::new(tokio::sync::RwLock::new(0));
|
||||
let mut config = crate::mcp::tests::test_mcp_config(std::env::temp_dir());
|
||||
if label == "old" {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
config.permission_profile = PermissionProfile::Disabled;
|
||||
} else {
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
}
|
||||
let config = Arc::new(config);
|
||||
let prepared = PreparedMcpCall::new(
|
||||
Arc::clone(&connections),
|
||||
managed_client,
|
||||
Arc::clone(&config),
|
||||
/*catalog_revision*/ 0,
|
||||
Arc::clone(&tool_catalog_revision),
|
||||
tool.clone(),
|
||||
@@ -128,7 +134,7 @@ async fn test_step(
|
||||
step: Arc::new(McpBinding::new(
|
||||
connections,
|
||||
clients,
|
||||
Arc::new(crate::mcp::tests::test_mcp_config(std::env::temp_dir())),
|
||||
config,
|
||||
/*plugins_available*/ false,
|
||||
vec![tool],
|
||||
calls,
|
||||
@@ -206,6 +212,25 @@ async fn prepared_call_keeps_captured_connection_and_authority_after_refresh() -
|
||||
);
|
||||
assert!(Arc::ptr_eq(&old_call.client.client, &old.client));
|
||||
assert!(!Arc::ptr_eq(&old.client, &new.client));
|
||||
assert_eq!(
|
||||
(
|
||||
old_call.config().approval_policy.value(),
|
||||
&old_call.config().permission_profile,
|
||||
old_call.config().approvals_reviewer,
|
||||
),
|
||||
(
|
||||
AskForApproval::Never,
|
||||
&PermissionProfile::Disabled,
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
new_call.config().approval_policy.value(),
|
||||
new_call.config().approvals_reviewer,
|
||||
),
|
||||
(AskForApproval::OnRequest, ApprovalsReviewer::AutoReview)
|
||||
);
|
||||
|
||||
drop(old.step);
|
||||
assert!(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Aggregates MCP server connections for Codex.
|
||||
//!
|
||||
//! [`McpConnectionSet`] owns the set of running async RMCP clients keyed by
|
||||
//! MCP server name. It coordinates startup status events, keeps server origin
|
||||
//! metadata, aggregates tools/resources/templates across servers, routes tool
|
||||
//! calls to the right client, and exposes the public manager API used by
|
||||
//! `codex-core`.
|
||||
//! [`McpConnectionSet`] is the private connection set behind
|
||||
//! [`crate::McpRuntime`] and [`crate::McpBinding`]. It coordinates startup status
|
||||
//! events, keeps server metadata, and aggregates tools and resources across
|
||||
//! running RMCP clients.
|
||||
|
||||
#[path = "connection_manager/required.rs"]
|
||||
mod required;
|
||||
@@ -27,6 +26,7 @@ use crate::rmcp_client::AsyncManagedClient;
|
||||
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT;
|
||||
use crate::rmcp_client::ManagedClient;
|
||||
use crate::rmcp_client::StartupOutcomeError;
|
||||
use crate::runtime::McpPublicationGate;
|
||||
use crate::runtime::McpRuntimeContext;
|
||||
use crate::server::EffectiveMcpServer;
|
||||
use crate::server::McpServerMetadata;
|
||||
@@ -58,19 +58,14 @@ use codex_protocol::protocol::McpStartupFailure;
|
||||
use codex_protocol::protocol::McpStartupFailureReason;
|
||||
use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::McpAuthState;
|
||||
use codex_rmcp_client::McpLoginRequirement;
|
||||
use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::ListResourceTemplatesResult;
|
||||
use rmcp::model::ListResourcesResult;
|
||||
use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use rmcp::model::RequestId;
|
||||
use rmcp::model::Resource;
|
||||
use rmcp::model::ResourceTemplate;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -107,7 +102,7 @@ pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
|
||||
}
|
||||
|
||||
/// A thin wrapper around a set of running [`RmcpClient`] instances.
|
||||
pub struct McpConnectionSet {
|
||||
pub(crate) struct McpConnectionSet {
|
||||
clients: HashMap<String, AsyncManagedClient>,
|
||||
server_metadata: HashMap<String, McpServerMetadata>,
|
||||
required_servers: Vec<String>,
|
||||
@@ -116,7 +111,6 @@ pub struct McpConnectionSet {
|
||||
codex_apps_refresh_lock: Mutex<()>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
prefix_mcp_tool_names: bool,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
startup_cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -147,15 +141,16 @@ impl McpConnectionSet {
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
publication_gate: McpPublicationGate,
|
||||
) -> Self {
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_metadata = HashMap::new();
|
||||
let mut required_servers = mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, server)| server.enabled() && server.required())
|
||||
.map(|(name, _)| name.clone())
|
||||
.map(|(server_name, _)| server_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
required_servers.sort();
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_metadata = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
let elicitation_requests = ElicitationRequestManager::new(
|
||||
approval_policy.value(),
|
||||
@@ -165,7 +160,8 @@ impl McpConnectionSet {
|
||||
elicitation_router,
|
||||
);
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
let startup_submit_id = submit_id.clone();
|
||||
let startup_submit_id = submit_id;
|
||||
let startup_publication_gate = publication_gate.clone();
|
||||
let static_chatgpt_auth_provider = auth
|
||||
.filter(|auth| auth.uses_codex_backend())
|
||||
.map(codex_model_provider::auth_provider_from_auth);
|
||||
@@ -181,17 +177,6 @@ impl McpConnectionSet {
|
||||
{
|
||||
server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server));
|
||||
let cancel_token = startup_cancellation_token.child_token();
|
||||
if let Some(tx_event) = tx_event.as_ref() {
|
||||
let _ = emit_update(
|
||||
startup_submit_id.as_str(),
|
||||
tx_event,
|
||||
McpStartupUpdateEvent {
|
||||
server: server_name.clone(),
|
||||
status: McpStartupStatus::Starting,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let configured_config = server.configured_config().cloned();
|
||||
let resolved_environment = configured_config.as_ref().map_or_else(
|
||||
|| Ok(None),
|
||||
@@ -273,7 +258,22 @@ impl McpConnectionSet {
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
let submit_id = startup_submit_id.clone();
|
||||
let publication_gate = publication_gate.clone();
|
||||
join_set.spawn(async move {
|
||||
if !publication_gate.wait().await {
|
||||
return (server_name, Err(StartupOutcomeError::Cancelled));
|
||||
}
|
||||
if let Some(tx_event) = tx_event.as_ref() {
|
||||
let _ = emit_update(
|
||||
submit_id.as_str(),
|
||||
tx_event,
|
||||
McpStartupUpdateEvent {
|
||||
server: server_name.clone(),
|
||||
status: McpStartupStatus::Starting,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut outcome = async_managed_client.client().await;
|
||||
if cancel_token.is_cancelled() {
|
||||
outcome = Err(StartupOutcomeError::Cancelled);
|
||||
@@ -362,11 +362,13 @@ impl McpConnectionSet {
|
||||
codex_apps_refresh_lock: Mutex::new(()),
|
||||
tool_plugin_provenance,
|
||||
prefix_mcp_tool_names,
|
||||
elicitation_requests: elicitation_requests.clone(),
|
||||
startup_cancellation_token: startup_cancellation_token.clone(),
|
||||
startup_cancellation_token,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let outcomes = join_set.join_all().await;
|
||||
if !startup_publication_gate.wait().await {
|
||||
return;
|
||||
}
|
||||
if let Some(tx_event) = tx_event {
|
||||
let mut summary = McpStartupCompleteEvent::default();
|
||||
for (server_name, outcome) in outcomes {
|
||||
@@ -392,11 +394,7 @@ impl McpConnectionSet {
|
||||
manager
|
||||
}
|
||||
|
||||
pub fn new_uninitialized_with_permission_profile(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &PermissionProfile,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
pub fn empty(prefix_mcp_tool_names: bool) -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
server_metadata: HashMap::new(),
|
||||
@@ -406,29 +404,18 @@ impl McpConnectionSet {
|
||||
codex_apps_refresh_lock: Mutex::new(()),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
prefix_mcp_tool_names,
|
||||
elicitation_requests: ElicitationRequestManager::new(
|
||||
approval_policy.value(),
|
||||
permission_profile.clone(),
|
||||
/*reviewer*/ None,
|
||||
/*lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
),
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty(prefix_mcp_tool_names: bool) -> Self {
|
||||
Self::new_uninitialized_with_permission_profile(
|
||||
&Constrained::allow_any(AskForApproval::OnRequest),
|
||||
&PermissionProfile::default(),
|
||||
prefix_mcp_tool_names,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn has_servers(&self) -> bool {
|
||||
!self.clients.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_startup(&self) {
|
||||
self.startup_cancellation_token.cancel();
|
||||
}
|
||||
|
||||
pub(crate) fn contains_server(&self, server_name: &str) -> bool {
|
||||
self.clients.contains_key(server_name)
|
||||
}
|
||||
@@ -448,25 +435,6 @@ impl McpConnectionSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_origin(&self, server_name: &str) -> Option<&str> {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.and_then(|metadata| metadata.origin.as_ref())
|
||||
.map(super::server::McpServerOrigin::as_str)
|
||||
}
|
||||
|
||||
pub fn server_environment_id(&self, server_name: &str) -> Option<&str> {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.map(|metadata| metadata.environment_id.as_str())
|
||||
}
|
||||
|
||||
pub fn server_pollutes_memory(&self, server_name: &str) -> bool {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.is_none_or(|metadata| metadata.pollutes_memory)
|
||||
}
|
||||
|
||||
pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
|
||||
self.tool_plugin_provenance
|
||||
.plugin_id_for_mcp_server_name(server_name)
|
||||
@@ -477,56 +445,6 @@ impl McpConnectionSet {
|
||||
.is_selected_plugin_mcp_server(server_name)
|
||||
}
|
||||
|
||||
pub fn tool_approval_mode(
|
||||
&self,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
) -> codex_config::AppToolApproval {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.map(|metadata| metadata.tool_approval_mode(tool_name))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
|
||||
server_name == CODEX_APPS_MCP_SERVER_NAME && self.server_metadata.contains_key(server_name)
|
||||
}
|
||||
|
||||
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
|
||||
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
|
||||
*policy = approval_policy.value();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_permission_profile(&self, permission_profile: PermissionProfile) {
|
||||
if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() {
|
||||
*profile = permission_profile;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elicitations_auto_deny(&self) -> bool {
|
||||
self.elicitation_requests.auto_deny()
|
||||
}
|
||||
|
||||
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
|
||||
self.elicitation_requests.set_auto_deny(auto_deny);
|
||||
}
|
||||
|
||||
pub fn elicitation_router(&self) -> ElicitationRequestRouter {
|
||||
self.elicitation_requests.router()
|
||||
}
|
||||
|
||||
pub async fn resolve_elicitation(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
response: ElicitationResponse,
|
||||
) -> Result<()> {
|
||||
self.elicitation_requests
|
||||
.resolve(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool {
|
||||
let Some(async_managed_client) = self.clients.get(server_name) else {
|
||||
return false;
|
||||
@@ -538,24 +456,20 @@ impl McpConnectionSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns resources from servers selected by `include_server`. Each key
|
||||
/// is the server name and the value is a vector of resources.
|
||||
pub async fn list_all_resources(
|
||||
&self,
|
||||
include_server: impl Fn(&str) -> bool,
|
||||
) -> HashMap<String, Vec<Resource>> {
|
||||
) -> HashMap<String, Vec<rmcp::model::Resource>> {
|
||||
self.ready_clients_matching(&include_server)
|
||||
.await
|
||||
.list_all_resources(|_| true)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns resource templates from servers selected by `include_server`.
|
||||
/// Each key is the server name and the value is a vector of templates.
|
||||
pub async fn list_all_resource_templates(
|
||||
&self,
|
||||
include_server: impl Fn(&str) -> bool,
|
||||
) -> HashMap<String, Vec<ResourceTemplate>> {
|
||||
) -> HashMap<String, Vec<rmcp::model::ResourceTemplate>> {
|
||||
self.ready_clients_matching(&include_server)
|
||||
.await
|
||||
.list_all_resource_templates(|_| true)
|
||||
@@ -617,16 +531,6 @@ impl McpConnectionSet {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn server_supports_sandbox_state_meta_capability(
|
||||
&self,
|
||||
server: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(self
|
||||
.client_by_name(server)
|
||||
.await?
|
||||
.server_supports_sandbox_state_meta_capability)
|
||||
}
|
||||
|
||||
/// List resources from the specified server.
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
@@ -643,22 +547,6 @@ impl McpConnectionSet {
|
||||
.with_context(|| format!("resources/list failed for `{server}`"))
|
||||
}
|
||||
|
||||
/// List resource templates from the specified server.
|
||||
pub async fn list_resource_templates(
|
||||
&self,
|
||||
server: &str,
|
||||
params: Option<PaginatedRequestParams>,
|
||||
) -> Result<ListResourceTemplatesResult> {
|
||||
let managed = self.client_by_name(server).await?;
|
||||
let client = managed.client.clone();
|
||||
let timeout = managed.tool_timeout;
|
||||
|
||||
client
|
||||
.list_resource_templates(params, timeout)
|
||||
.await
|
||||
.with_context(|| format!("resources/templates/list failed for `{server}`"))
|
||||
}
|
||||
|
||||
/// Read a resource from the specified server.
|
||||
pub async fn read_resource(
|
||||
&self,
|
||||
@@ -713,15 +601,11 @@ impl McpConnectionSet {
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_uninitialized(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &Constrained<PermissionProfile>,
|
||||
_approval_policy: &Constrained<AskForApproval>,
|
||||
_permission_profile: &Constrained<PermissionProfile>,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
Self::new_uninitialized_with_permission_profile(
|
||||
approval_policy,
|
||||
permission_profile.get(),
|
||||
prefix_mcp_tool_names,
|
||||
)
|
||||
Self::empty(prefix_mcp_tool_names)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ use crate::rmcp_client::StartupOutcomeError;
|
||||
impl McpConnectionSet {
|
||||
/// Waits for every required server and reports their startup failures together.
|
||||
///
|
||||
/// Callers must make the manager reachable to request handlers before awaiting this method,
|
||||
/// because server initialization may require client elicitation.
|
||||
pub async fn validate_required_servers(&self) -> Result<()> {
|
||||
/// The manager must already be reachable through [`crate::McpRuntime`] so
|
||||
/// startup-time elicitation can resolve while validation waits.
|
||||
pub(crate) async fn validate_required_servers(&self) -> Result<()> {
|
||||
let failures = async {
|
||||
let mut failures = Vec::new();
|
||||
for server_name in &self.required_servers {
|
||||
@@ -55,7 +55,7 @@ impl McpConnectionSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn startup_outcome_error_message(error: StartupOutcomeError) -> String {
|
||||
fn startup_outcome_error_message(error: StartupOutcomeError) -> String {
|
||||
match error {
|
||||
StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(),
|
||||
StartupOutcomeError::Failed { error, .. } => error,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -35,18 +36,25 @@ impl McpConnectionSet {
|
||||
for (server_name, managed_client) in &self.clients {
|
||||
managed_client.reconnect_failed_startup().await;
|
||||
let has_cached_tools = managed_client.has_cached_tools();
|
||||
let startup_complete = managed_client
|
||||
.startup_complete
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let Some(server_tools) = managed_client
|
||||
.listed_tools()
|
||||
.instrument(trace_span!(
|
||||
"list_tools_for_server",
|
||||
server_name = %server_name,
|
||||
has_cached_tools,
|
||||
startup_complete
|
||||
))
|
||||
.await
|
||||
let startup_complete = managed_client.startup_complete.load(Ordering::Acquire);
|
||||
let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
self.codex_apps_tools_override.read().await.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(server_tools) = async {
|
||||
match catalog_override {
|
||||
Some(tools) => Some(managed_client.prepare_tools(tools)),
|
||||
None => managed_client.listed_tools().await,
|
||||
}
|
||||
}
|
||||
.instrument(trace_span!(
|
||||
"list_tools_for_server",
|
||||
server_name = %server_name,
|
||||
has_cached_tools,
|
||||
startup_complete
|
||||
))
|
||||
.await
|
||||
else {
|
||||
unavailable_server_count += 1;
|
||||
trace!(
|
||||
@@ -74,23 +82,11 @@ impl McpConnectionSet {
|
||||
tools
|
||||
}
|
||||
|
||||
/// Returns one tool from the current live connection.
|
||||
pub async fn tool_info(&self, server: &str, tool: &str) -> Option<ToolInfo> {
|
||||
let client = self.clients.get(server)?;
|
||||
let managed_client = client.client().await.ok()?;
|
||||
let tool = client
|
||||
.prepare_tools(managed_client.listed_tools())
|
||||
.into_iter()
|
||||
.find(|tool_info| tool_info.tool.name == tool)?;
|
||||
Some(self.with_server_metadata(tool))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "catalog capture must remain serialized with catalog replacement"
|
||||
)]
|
||||
/// Captures the ready clients, their exact tools, and the supplied runtime metadata.
|
||||
pub async fn capture_binding_with_metadata(
|
||||
pub(crate) async fn capture_binding_with_metadata(
|
||||
self: &Arc<Self>,
|
||||
config: Arc<crate::McpConfig>,
|
||||
plugins_available: bool,
|
||||
@@ -127,10 +123,14 @@ impl McpConnectionSet {
|
||||
let mut tools = Vec::with_capacity(listed_tools.len());
|
||||
let mut calls = std::collections::HashMap::with_capacity(listed_tools.len());
|
||||
for tool_info in listed_tools {
|
||||
if !crate::tool_is_model_visible(&tool_info) {
|
||||
continue;
|
||||
}
|
||||
let Some(client) = clients.client(&tool_info.server_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(call) = self.prepare_call(&tool_info, client, *revision) else {
|
||||
let Some(call) = self.prepare_call(&tool_info, client, Arc::clone(&config), *revision)
|
||||
else {
|
||||
trace!(
|
||||
server_name = %tool_info.server_name,
|
||||
tool_name = %tool_info.tool.name,
|
||||
@@ -161,12 +161,14 @@ impl McpConnectionSet {
|
||||
self: &Arc<Self>,
|
||||
tool_info: &ToolInfo,
|
||||
client: Arc<ManagedClient>,
|
||||
config: Arc<crate::McpConfig>,
|
||||
tool_catalog_revision: u64,
|
||||
) -> Option<PreparedMcpCall> {
|
||||
let server_name = &tool_info.server_name;
|
||||
Some(PreparedMcpCall::new(
|
||||
Arc::clone(self),
|
||||
client,
|
||||
config,
|
||||
tool_catalog_revision,
|
||||
Arc::clone(&self.tool_catalog_revision),
|
||||
tool_info.clone(),
|
||||
@@ -177,16 +179,12 @@ impl McpConnectionSet {
|
||||
))
|
||||
}
|
||||
|
||||
/// Force-refresh codex apps tools by bypassing the in-process cache.
|
||||
///
|
||||
/// On success, the refreshed tools replace shared cache contents when the
|
||||
/// cache is enabled and the latest filtered tools are returned directly to
|
||||
/// the caller. On failure, existing shared cache contents remain unchanged.
|
||||
/// Force-refresh Codex Apps tools and publish one new exact catalog revision.
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "catalog publication must remain serialized with captured tool calls"
|
||||
)]
|
||||
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> {
|
||||
pub(crate) async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> {
|
||||
let _refresh = self.codex_apps_refresh_lock.lock().await;
|
||||
let refresh_start = Instant::now();
|
||||
let managed_client = self
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::required::startup_outcome_error_message;
|
||||
use super::*;
|
||||
use crate::McpBinding;
|
||||
use crate::elicitation::ElicitationLifecycle;
|
||||
@@ -34,6 +33,7 @@ use codex_protocol::ToolName;
|
||||
use codex_protocol::mcp::McpServerInfo;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::InProcessTransportFactory;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use futures::FutureExt;
|
||||
@@ -508,7 +508,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
PermissionProfile::default(),
|
||||
/*reviewer*/ None,
|
||||
Some(lifecycle),
|
||||
router,
|
||||
router.clone(),
|
||||
);
|
||||
let (tx_event, rx_event) = async_channel::bounded(2);
|
||||
let sender_a = manager_a.make_sender("server".to_string(), Some(tx_event.clone()));
|
||||
@@ -552,7 +552,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
content: Some(serde_json::json!({"runtime": "a"})),
|
||||
meta: None,
|
||||
};
|
||||
manager_b
|
||||
router
|
||||
.resolve(
|
||||
"server".to_string(),
|
||||
NumberOrString::String(request_a_id.into()),
|
||||
@@ -565,7 +565,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
content: Some(serde_json::json!({"runtime": "b"})),
|
||||
meta: None,
|
||||
};
|
||||
manager_a
|
||||
router
|
||||
.resolve(
|
||||
"server".to_string(),
|
||||
NumberOrString::String(request_b_id.into()),
|
||||
@@ -1962,42 +1962,6 @@ fn server_metadata_preserves_tool_approval_policy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_owned_codex_apps_requires_server_metadata() {
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
||||
let manager = McpConnectionSet::new_uninitialized(
|
||||
&approval_policy,
|
||||
&permission_profile,
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
);
|
||||
|
||||
assert!(!manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() {
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
||||
let mut manager = McpConnectionSet::new_uninitialized(
|
||||
&approval_policy,
|
||||
&permission_profile,
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
);
|
||||
let server = EffectiveMcpServer::configured(crate::codex_apps_mcp_server_config(
|
||||
"https://chatgpt.com",
|
||||
/*apps_mcp_product_sku*/ None,
|
||||
/*originator*/ None,
|
||||
));
|
||||
manager.server_metadata.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
McpServerMetadata::from(&server),
|
||||
);
|
||||
|
||||
assert!(manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME));
|
||||
assert!(!manager.is_host_owned_codex_apps_server("docs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
@@ -2087,6 +2051,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
crate::runtime::McpPublicationGate::already_published(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2108,8 +2073,11 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
Ok(_) => panic!("local stdio MCP startup should fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let StartupOutcomeError::Failed { error, .. } = error else {
|
||||
panic!("local stdio MCP startup should fail rather than be cancelled");
|
||||
};
|
||||
assert_eq!(
|
||||
startup_outcome_error_message(error),
|
||||
error,
|
||||
"local stdio MCP server `stdio` requires a local environment"
|
||||
);
|
||||
cancel_token.cancel();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -85,12 +85,21 @@ struct ActiveElicitation {
|
||||
/// generated by Codex rather than copied from the MCP connection, so separate runtimes may reuse
|
||||
/// the same server request ID without colliding.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ElicitationRequestRouter {
|
||||
pub(crate) struct ElicitationRequestRouter {
|
||||
requests: Arc<Mutex<ResponderMap>>,
|
||||
auto_deny: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ElicitationRequestRouter {
|
||||
async fn resolve(
|
||||
pub(crate) fn auto_deny(&self) -> bool {
|
||||
self.auto_deny.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
|
||||
self.auto_deny.store(auto_deny, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
@@ -109,9 +118,8 @@ impl ElicitationRequestRouter {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ElicitationRequestManager {
|
||||
router: ElicitationRequestRouter,
|
||||
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
|
||||
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
|
||||
auto_deny: Arc<StdMutex<bool>>,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
reviewer: Option<ElicitationReviewerHandle>,
|
||||
lifecycle: Option<ElicitationLifecycle>,
|
||||
}
|
||||
@@ -126,66 +134,33 @@ impl ElicitationRequestManager {
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
approval_policy: Arc::new(StdMutex::new(approval_policy)),
|
||||
permission_profile: Arc::new(StdMutex::new(permission_profile)),
|
||||
auto_deny: Arc::new(StdMutex::new(false)),
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
reviewer,
|
||||
lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auto_deny(&self) -> bool {
|
||||
self.auto_deny
|
||||
.lock()
|
||||
.map(|auto_deny| *auto_deny)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
|
||||
if let Ok(mut current) = self.auto_deny.lock() {
|
||||
*current = auto_deny;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
response: ElicitationResponse,
|
||||
) -> Result<()> {
|
||||
self.router.resolve(server_name, id, response).await
|
||||
}
|
||||
|
||||
pub(crate) fn router(&self) -> ElicitationRequestRouter {
|
||||
self.router.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn make_sender(
|
||||
&self,
|
||||
server_name: String,
|
||||
tx_event: Option<Sender<Event>>,
|
||||
) -> SendElicitation {
|
||||
let router = self.router.clone();
|
||||
let approval_policy = self.approval_policy.clone();
|
||||
let approval_policy = self.approval_policy;
|
||||
let permission_profile = self.permission_profile.clone();
|
||||
let auto_deny = self.auto_deny.clone();
|
||||
let reviewer = self.reviewer.clone();
|
||||
let lifecycle = self.lifecycle.clone();
|
||||
Box::new(move |id, elicitation| {
|
||||
let router = router.clone();
|
||||
let tx_event = tx_event.clone();
|
||||
let server_name = server_name.clone();
|
||||
let approval_policy = approval_policy.clone();
|
||||
let approval_policy = approval_policy;
|
||||
let permission_profile = permission_profile.clone();
|
||||
let auto_deny = auto_deny.clone();
|
||||
let reviewer = reviewer.clone();
|
||||
let lifecycle = lifecycle.clone();
|
||||
async move {
|
||||
let auto_deny = auto_deny
|
||||
.lock()
|
||||
.map(|auto_deny| *auto_deny)
|
||||
.unwrap_or(false);
|
||||
if auto_deny {
|
||||
if router.auto_deny() {
|
||||
return Ok(ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
@@ -193,14 +168,6 @@ impl ElicitationRequestManager {
|
||||
});
|
||||
}
|
||||
|
||||
let approval_policy = approval_policy
|
||||
.lock()
|
||||
.map(|policy| *policy)
|
||||
.unwrap_or(AskForApproval::Never);
|
||||
let permission_profile = permission_profile
|
||||
.lock()
|
||||
.map(|profile| profile.clone())
|
||||
.unwrap_or_default();
|
||||
if mcp_permission_prompt_is_auto_approved(
|
||||
approval_policy,
|
||||
&permission_profile,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
pub use binding::McpBinding;
|
||||
pub use binding::PreparedMcpCall;
|
||||
pub use connection_manager::McpConnectionSet;
|
||||
pub use connection_manager::tool_is_model_visible;
|
||||
pub use elicitation::ElicitationLifecycle;
|
||||
pub use elicitation::ElicitationRequestRouter;
|
||||
pub use elicitation::ElicitationReviewRequest;
|
||||
pub use elicitation::ElicitationReviewer;
|
||||
pub use elicitation::ElicitationReviewerHandle;
|
||||
@@ -14,13 +12,11 @@ pub use resource_client::McpResourceReadResult;
|
||||
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
pub use runtime::McpRuntime;
|
||||
pub use runtime::McpRuntimeContext;
|
||||
pub use runtime::McpRuntimeInput;
|
||||
pub use runtime::SandboxState;
|
||||
pub use tool_catalog_cache::McpToolCatalogCache;
|
||||
pub use tools::ToolInfo;
|
||||
|
||||
/// Backward-compatible name for the MCP connection set.
|
||||
pub type McpConnectionManager = McpConnectionSet;
|
||||
|
||||
/// Backward-compatible name for the shared Codex Apps tools runtime.
|
||||
pub type CodexAppsToolsCache = codex_connectors::ConnectorRuntimeManager<ToolInfo>;
|
||||
/// Backward-compatible name for the Codex Apps runtime context key.
|
||||
|
||||
@@ -19,11 +19,13 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::McpServerAuth;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_connectors::ConnectorRuntimeManager;
|
||||
@@ -38,6 +40,7 @@ use codex_protocol::mcp::Tool;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
@@ -105,13 +108,10 @@ pub struct McpPermissionPromptAutoApproveContext {
|
||||
|
||||
/// MCP runtime settings derived from `codex_core::config::Config`.
|
||||
///
|
||||
/// This struct should contain only long-lived configuration values that the
|
||||
/// `codex-mcp` crate needs to construct server transports, enforce MCP
|
||||
/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP
|
||||
/// servers. Request-scoped or auth-scoped state should not be stored here;
|
||||
/// thread those values explicitly into runtime entry points such as
|
||||
/// [`effective_mcp_servers`] and snapshot collection helpers so config objects
|
||||
/// do not go stale when auth changes.
|
||||
/// Each published runtime and prepared call owns one immutable copy of these
|
||||
/// settings, so its connection, approval policy, and sandbox authority cannot
|
||||
/// change independently. Auth remains separate and is supplied explicitly to
|
||||
/// runtime entry points such as [`effective_mcp_servers`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpConfig {
|
||||
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
|
||||
@@ -132,6 +132,14 @@ pub struct McpConfig {
|
||||
pub skill_mcp_dependency_install_enabled: bool,
|
||||
/// Approval policy used for MCP tool calls and MCP elicitation requests.
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
/// Permission profile captured with the connections and approval policy.
|
||||
pub permission_profile: PermissionProfile,
|
||||
/// Configuration layers used to evaluate Apps tool policy and reviewer selection.
|
||||
pub config_layer_stack: ConfigLayerStack,
|
||||
/// Default reviewer used when an Apps tool has no reviewer override.
|
||||
pub approvals_reviewer: ApprovalsReviewer,
|
||||
/// Working directories for the exact environment handles used by this runtime.
|
||||
pub environment_cwds: HashMap<String, PathUri>,
|
||||
/// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution.
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
/// Whether to use legacy Landlock behavior in the MCP sandbox state.
|
||||
@@ -334,6 +342,7 @@ pub async fn read_mcp_resource(
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
crate::runtime::McpPublicationGate::already_published(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -388,7 +397,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
let server_names = mcp_servers.keys().cloned().collect();
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mcp_connection_manager = McpConnectionSet::new(
|
||||
let mcp_connection_set = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind,
|
||||
@@ -411,11 +420,12 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
crate::runtime::McpPublicationGate::already_published(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let snapshot = collect_mcp_server_status_snapshot_from_manager(
|
||||
&mcp_connection_manager,
|
||||
&mcp_connection_set,
|
||||
auth_status_entries,
|
||||
server_names,
|
||||
detail,
|
||||
@@ -642,27 +652,27 @@ fn convert_mcp_resource_templates(
|
||||
}
|
||||
|
||||
async fn collect_mcp_server_status_snapshot_from_manager(
|
||||
mcp_connection_manager: &McpConnectionSet,
|
||||
mcp_connection_set: &McpConnectionSet,
|
||||
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
|
||||
server_names: Vec<String>,
|
||||
detail: McpSnapshotDetail,
|
||||
) -> McpServerStatusSnapshot {
|
||||
let ((server_infos, tools), resources, resource_templates) = tokio::join!(
|
||||
async {
|
||||
let server_infos = mcp_connection_manager.list_available_server_infos().await;
|
||||
let tools = mcp_connection_manager.list_all_tools().await;
|
||||
let server_infos = mcp_connection_set.list_available_server_infos().await;
|
||||
let tools = mcp_connection_set.list_all_tools().await;
|
||||
(server_infos, tools)
|
||||
},
|
||||
async {
|
||||
if detail.include_resources() {
|
||||
mcp_connection_manager.list_all_resources(|_| true).await
|
||||
mcp_connection_set.list_all_resources(|_| true).await
|
||||
} else {
|
||||
HashMap::new()
|
||||
}
|
||||
},
|
||||
async {
|
||||
if detail.include_resources() {
|
||||
mcp_connection_manager
|
||||
mcp_connection_set
|
||||
.list_all_resource_templates(|_| true)
|
||||
.await
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,10 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
|
||||
mcp_oauth_callback_url: None,
|
||||
skill_mcp_dependency_install_enabled: true,
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnRequest),
|
||||
permission_profile: PermissionProfile::default(),
|
||||
config_layer_stack: codex_config::ConfigLayerStack::default(),
|
||||
approvals_reviewer: codex_config::types::ApprovalsReviewer::default(),
|
||||
environment_cwds: HashMap::new(),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
apps_enabled: false,
|
||||
|
||||
@@ -61,14 +61,14 @@ impl McpResourceClient {
|
||||
|
||||
/// Returns the identity of the connection set used by this client.
|
||||
pub fn cache_key(&self) -> McpResourceClientCacheKey {
|
||||
McpResourceClientCacheKey(Arc::downgrade(&self.runtime.snapshot()))
|
||||
McpResourceClientCacheKey(Arc::downgrade(&self.runtime.latest_connections()))
|
||||
}
|
||||
|
||||
/// Returns whether this client can address the named server.
|
||||
///
|
||||
/// This does not wait for server startup.
|
||||
pub async fn has_server(&self, server: &str) -> bool {
|
||||
self.runtime.snapshot().contains_server(server)
|
||||
self.runtime.latest_connections().contains_server(server)
|
||||
}
|
||||
|
||||
/// Lists one resource page from the named server.
|
||||
@@ -81,7 +81,7 @@ impl McpResourceClient {
|
||||
cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
|
||||
let result = self
|
||||
.runtime
|
||||
.snapshot()
|
||||
.latest_connections()
|
||||
.list_resources(server, params)
|
||||
.await?;
|
||||
let resources = result
|
||||
@@ -100,7 +100,7 @@ impl McpResourceClient {
|
||||
let params = ReadResourceRequestParams::new(uri.to_string());
|
||||
let result = self
|
||||
.runtime
|
||||
.snapshot()
|
||||
.latest_connections()
|
||||
.read_resource(server, params)
|
||||
.await?;
|
||||
let contents = result
|
||||
|
||||
@@ -478,19 +478,6 @@ impl AsyncManagedClient {
|
||||
),
|
||||
)
|
||||
});
|
||||
if codex_apps_tools_cache_context
|
||||
.as_ref()
|
||||
.is_some_and(ConnectorRuntimeContext::has_current_tools)
|
||||
|| tool_catalog_cache_context
|
||||
.as_ref()
|
||||
.is_some_and(McpToolCatalogCacheContext::has_tools)
|
||||
{
|
||||
let startup_task = client.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = startup_task.await;
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
client,
|
||||
is_codex_apps_mcp_server,
|
||||
|
||||
@@ -5,49 +5,301 @@
|
||||
//! [`crate::rmcp_client`] and connection-set behavior lives in
|
||||
//! [`crate::connection_manager`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use async_channel::Sender;
|
||||
use codex_connectors::ConnectorRuntimeContextKey;
|
||||
use codex_connectors::ConnectorRuntimeManager;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::ReqwestHttpClient;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::McpConnectionSet;
|
||||
use crate::McpConfig;
|
||||
use crate::binding::McpBinding;
|
||||
use crate::connection_manager::McpConnectionSet;
|
||||
use crate::elicitation::ElicitationLifecycle;
|
||||
use crate::elicitation::ElicitationRequestRouter;
|
||||
use crate::elicitation::ElicitationReviewerHandle;
|
||||
use crate::server::EffectiveMcpServer;
|
||||
use crate::tool_catalog_cache::McpToolCatalogCache;
|
||||
use crate::tools::ToolInfo;
|
||||
|
||||
/// Owns the currently published MCP connection set for one Codex thread.
|
||||
/// Everything needed to materialize one exact MCP configuration.
|
||||
pub struct McpRuntimeInput {
|
||||
pub config: Arc<McpConfig>,
|
||||
pub plugins_available: bool,
|
||||
pub ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
pub mcp_servers: HashMap<String, EffectiveMcpServer>,
|
||||
pub submit_id: String,
|
||||
pub tx_event: Option<Sender<Event>>,
|
||||
pub startup_cancellation_token: CancellationToken,
|
||||
pub runtime_context: McpRuntimeContext,
|
||||
pub codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
|
||||
pub tool_catalog_cache: McpToolCatalogCache,
|
||||
pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey,
|
||||
pub supports_openai_form_elicitation: bool,
|
||||
pub auth: Option<CodexAuth>,
|
||||
pub codex_apps_auth_manager: Option<Arc<AuthManager>>,
|
||||
pub elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
pub elicitation_lifecycle: Option<ElicitationLifecycle>,
|
||||
}
|
||||
|
||||
/// Owns all mutable MCP state for one Codex thread.
|
||||
///
|
||||
/// Replacements are published atomically. Callers that already hold a snapshot
|
||||
/// keep the previous connection set alive until their work completes.
|
||||
/// Publication replaces the latest state atomically. Existing bindings retain
|
||||
/// their exact connections and configuration for as long as they are needed.
|
||||
pub struct McpRuntime {
|
||||
connections: ArcSwap<McpConnectionSet>,
|
||||
current: ArcSwap<PublishedMcpRuntime>,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
}
|
||||
|
||||
struct PublishedMcpRuntime {
|
||||
connections: Arc<McpConnectionSet>,
|
||||
config: Option<Arc<McpConfig>>,
|
||||
plugins_available: bool,
|
||||
ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct McpPublicationGate {
|
||||
published: Option<watch::Receiver<bool>>,
|
||||
}
|
||||
|
||||
impl McpPublicationGate {
|
||||
fn pending() -> (watch::Sender<bool>, Self) {
|
||||
let (publish, published) = watch::channel(false);
|
||||
(
|
||||
publish,
|
||||
Self {
|
||||
published: Some(published),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn already_published() -> Self {
|
||||
Self { published: None }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait(mut self) -> bool {
|
||||
let Some(published) = self.published.as_mut() else {
|
||||
return true;
|
||||
};
|
||||
loop {
|
||||
if *published.borrow() {
|
||||
return true;
|
||||
}
|
||||
if published.changed().await.is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpRuntime {
|
||||
pub fn new(connections: Arc<McpConnectionSet>) -> Self {
|
||||
/// Creates a runtime with no configured servers.
|
||||
///
|
||||
/// This is useful while constructing a thread that must publish a stable
|
||||
/// runtime handle before its full MCP inputs are available.
|
||||
pub fn empty(prefix_mcp_tool_names: bool) -> Self {
|
||||
Self {
|
||||
connections: ArcSwap::from(connections),
|
||||
current: ArcSwap::from_pointee(PublishedMcpRuntime {
|
||||
connections: Arc::new(McpConnectionSet::empty(prefix_mcp_tool_names)),
|
||||
config: None,
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
}),
|
||||
elicitation_router: ElicitationRequestRouter::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Arc<McpConnectionSet> {
|
||||
self.connections.load_full()
|
||||
pub async fn new(input: McpRuntimeInput) -> Self {
|
||||
let runtime = Self::empty(input.config.prefix_mcp_tool_names);
|
||||
runtime.replace(input).await;
|
||||
runtime
|
||||
}
|
||||
|
||||
pub fn replace(&self, connections: McpConnectionSet) -> Arc<McpConnectionSet> {
|
||||
let connections = Arc::new(connections);
|
||||
self.connections.store(Arc::clone(&connections));
|
||||
connections
|
||||
/// Rebuilds configured servers and publishes their immutable runtime snapshot.
|
||||
pub async fn replace(&self, input: McpRuntimeInput) {
|
||||
let (publish, publication_gate) = McpPublicationGate::pending();
|
||||
let config = Arc::clone(&input.config);
|
||||
let plugins_available = input.plugins_available;
|
||||
let ready_selected_capability_roots = input.ready_selected_capability_roots.clone();
|
||||
let connections = Arc::new(
|
||||
Self::materialize(input, self.elicitation_router.clone(), publication_gate).await,
|
||||
);
|
||||
self.current.store(Arc::new(PublishedMcpRuntime {
|
||||
connections,
|
||||
config: Some(config),
|
||||
plugins_available,
|
||||
ready_selected_capability_roots,
|
||||
}));
|
||||
let _ = publish.send(true);
|
||||
}
|
||||
|
||||
/// Captures the latest published configuration and live client handles.
|
||||
pub async fn current_binding(&self) -> Option<Arc<McpBinding>> {
|
||||
let current = self.current.load_full();
|
||||
let config = Arc::clone(current.config.as_ref()?);
|
||||
Some(Arc::new(
|
||||
current
|
||||
.connections
|
||||
.capture_binding_with_metadata(config, current.plugins_available)
|
||||
.await,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn current_ready_selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
|
||||
self.current.load().ready_selected_capability_roots.clone()
|
||||
}
|
||||
|
||||
pub fn elicitations_auto_deny(&self) -> bool {
|
||||
self.elicitation_router.auto_deny()
|
||||
}
|
||||
|
||||
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
|
||||
self.elicitation_router.set_auto_deny(auto_deny);
|
||||
}
|
||||
|
||||
pub async fn resolve_elicitation(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
response: ElicitationResponse,
|
||||
) -> anyhow::Result<()> {
|
||||
self.elicitation_router
|
||||
.resolve(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn latest_hard_refresh_codex_apps_tools_cache(
|
||||
&self,
|
||||
) -> anyhow::Result<Vec<ToolInfo>> {
|
||||
self.latest_connections()
|
||||
.hard_refresh_codex_apps_tools_cache()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Lists the latest known tools for non-model discovery surfaces.
|
||||
///
|
||||
/// Unlike [`Self::current_binding`], this may return cached tools while their
|
||||
/// client reconnects because callers only inspect tool metadata.
|
||||
pub async fn latest_list_all_tools(&self) -> Vec<ToolInfo> {
|
||||
self.latest_connections().list_all_tools().await
|
||||
}
|
||||
|
||||
pub async fn latest_call_tool(
|
||||
&self,
|
||||
server: &str,
|
||||
tool: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.latest_connections()
|
||||
.call_tool(server, tool, arguments, meta)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn latest_read_resource(
|
||||
&self,
|
||||
server: &str,
|
||||
params: ReadResourceRequestParams,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
self.latest_connections()
|
||||
.read_resource(server, params)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn latest_wait_for_server_ready(&self, server: &str, timeout: Duration) -> bool {
|
||||
self.latest_connections()
|
||||
.wait_for_server_ready(server, timeout)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn validate_required_servers(&self) -> anyhow::Result<()> {
|
||||
self.latest_connections().validate_required_servers().await
|
||||
}
|
||||
|
||||
pub fn cancel_startup(&self) {
|
||||
self.current.load().connections.cancel_startup();
|
||||
}
|
||||
|
||||
pub(crate) fn latest_connections(&self) -> Arc<McpConnectionSet> {
|
||||
Arc::clone(&self.current.load().connections)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.snapshot().shutdown().await;
|
||||
self.latest_connections().shutdown().await;
|
||||
}
|
||||
|
||||
async fn materialize(
|
||||
input: McpRuntimeInput,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
publication_gate: McpPublicationGate,
|
||||
) -> McpConnectionSet {
|
||||
let McpRuntimeInput {
|
||||
config,
|
||||
plugins_available: _,
|
||||
ready_selected_capability_roots: _,
|
||||
mcp_servers,
|
||||
submit_id,
|
||||
tx_event,
|
||||
startup_cancellation_token,
|
||||
runtime_context,
|
||||
codex_apps_tools_cache,
|
||||
tool_catalog_cache,
|
||||
codex_apps_tools_cache_key,
|
||||
supports_openai_form_elicitation,
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle,
|
||||
} = input;
|
||||
McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind,
|
||||
&config.approval_policy,
|
||||
submit_id,
|
||||
tx_event,
|
||||
startup_cancellation_token,
|
||||
config.permission_profile.clone(),
|
||||
runtime_context,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache,
|
||||
tool_catalog_cache,
|
||||
codex_apps_tools_cache_key,
|
||||
config.prefix_mcp_tool_names,
|
||||
config.client_elicitation_capability.clone(),
|
||||
supports_openai_form_elicitation,
|
||||
crate::mcp::tool_plugin_provenance(&config),
|
||||
auth.as_ref(),
|
||||
codex_apps_auth_manager,
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle,
|
||||
elicitation_router,
|
||||
publication_gate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +431,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_gate_opens_only_for_the_winning_candidate() {
|
||||
let (publish, gate) = McpPublicationGate::pending();
|
||||
let wait = tokio::spawn(gate.wait());
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!wait.is_finished());
|
||||
|
||||
publish.send(true).expect("publish candidate");
|
||||
assert!(wait.await.expect("gate task"));
|
||||
|
||||
let (publish, gate) = McpPublicationGate::pending();
|
||||
drop(publish);
|
||||
assert!(!gate.wait().await);
|
||||
}
|
||||
|
||||
fn http_server(environment_id: &str) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
auth: Default::default(),
|
||||
|
||||
@@ -102,7 +102,6 @@ pub use codex_protocol::openai_models::ModelPreset;
|
||||
pub use codex_protocol::protocol::AskForApproval;
|
||||
pub use codex_protocol::protocol::EventMsg;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
pub use codex_protocol::protocol::Op;
|
||||
pub use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
pub use codex_protocol::protocol::SessionSource;
|
||||
|
||||
@@ -44,7 +44,6 @@ use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
|
||||
use crate::mcp_tool_call::McpToolApprovalMetadata;
|
||||
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
|
||||
use crate::mcp_tool_call::is_mcp_tool_approval_question_id;
|
||||
use crate::mcp_tool_call::lookup_mcp_tool_metadata;
|
||||
use crate::mcp_tool_call::mcp_approvals_reviewer;
|
||||
use crate::session::GitEnrichmentPolicy;
|
||||
use crate::session::SUBMISSION_CHANNEL_CAPACITY;
|
||||
@@ -372,24 +371,12 @@ async fn forward_events(
|
||||
id,
|
||||
msg: EventMsg::McpToolCallBegin(event),
|
||||
} => {
|
||||
// Runtime refreshes are published before a request step is captured, so
|
||||
// the child runtime at call begin is the one executing this invocation.
|
||||
// Cache its metadata now; the later approval event has only a call ID.
|
||||
let metadata = if let Some(turn_context) =
|
||||
session.turn_context_for_sub_id(&id).await
|
||||
{
|
||||
let mcp = session.services.latest_mcp_runtime();
|
||||
lookup_mcp_tool_metadata(
|
||||
session.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
mcp.manager(),
|
||||
&event.invocation.server,
|
||||
&event.invocation.tool,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The later approval event has only a call ID. Retain the exact facts
|
||||
// captured before this begin event instead of consulting the latest
|
||||
// runtime after a refresh.
|
||||
let metadata = session
|
||||
.mcp_tool_approval_metadata(&id, &event.call_id)
|
||||
.await;
|
||||
pending_mcp_invocations
|
||||
.lock()
|
||||
.await
|
||||
|
||||
@@ -606,31 +606,14 @@ impl CodexThread {
|
||||
self.session.runtime_mcp_config(config).await
|
||||
}
|
||||
|
||||
/// Returns the exact MCP config, environment bindings, and manager most recently published.
|
||||
pub async fn current_mcp_runtime(&self) -> Arc<crate::session::McpRuntimeSnapshot> {
|
||||
let turn_context = self.session.new_default_turn().await;
|
||||
let environments = turn_context.environments.refresh_readiness();
|
||||
let selected_capability_roots = self
|
||||
.session
|
||||
.resolve_selected_capability_roots_for_step(&environments)
|
||||
.await;
|
||||
let ready_selected_capability_roots =
|
||||
Session::ready_selected_capability_roots(&selected_capability_roots);
|
||||
let executor_capability_discovery = self
|
||||
.session
|
||||
.executor_capability_discovery_for_step(
|
||||
&turn_context.config,
|
||||
&ready_selected_capability_roots,
|
||||
)
|
||||
.await;
|
||||
self.session
|
||||
.mcp_runtime_for_step(
|
||||
turn_context.as_ref(),
|
||||
&environments,
|
||||
&selected_capability_roots,
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await
|
||||
/// Captures the exact MCP config and environment bindings for the current thread state.
|
||||
pub async fn current_mcp_config_and_runtime_context(
|
||||
&self,
|
||||
) -> (Arc<codex_mcp::McpConfig>, codex_mcp::McpRuntimeContext) {
|
||||
let config = self.session.get_config().await;
|
||||
let (mcp_config, runtime_context) =
|
||||
self.session.runtime_mcp_config_and_context(&config).await;
|
||||
(Arc::new(mcp_config), runtime_context)
|
||||
}
|
||||
|
||||
pub fn multi_agent_version(&self) -> Option<MultiAgentVersion> {
|
||||
@@ -662,11 +645,12 @@ impl CodexThread {
|
||||
server: &str,
|
||||
uri: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
self.session.refresh_mcp_if_dirty().await;
|
||||
let result = self
|
||||
.current_mcp_runtime()
|
||||
.await
|
||||
.manager_arc()
|
||||
.read_resource(server, ReadResourceRequestParams::new(uri))
|
||||
.session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.latest_read_resource(server, ReadResourceRequestParams::new(uri))
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
@@ -679,10 +663,11 @@ impl CodexThread {
|
||||
arguments: Option<serde_json::Value>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.current_mcp_runtime()
|
||||
.await
|
||||
.manager_arc()
|
||||
.call_tool(server, tool, arguments, meta)
|
||||
self.session.refresh_mcp_if_dirty().await;
|
||||
self.session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.latest_call_tool(server, tool, arguments, meta)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -92,10 +92,9 @@ pub(crate) async fn build_compaction_initial_context(
|
||||
step_context,
|
||||
} => {
|
||||
let items = sess
|
||||
.build_initial_context_with_world_state_and_mcp(
|
||||
.build_initial_context_with_world_state(
|
||||
step_context.turn.as_ref(),
|
||||
world_state.as_ref(),
|
||||
step_context.mcp.as_ref(),
|
||||
)
|
||||
.await;
|
||||
(items, Some(Arc::clone(world_state)))
|
||||
|
||||
@@ -20,11 +20,7 @@ async fn process_compacted_history_with_test_session(
|
||||
crate::session::step_context::StepContext::for_test(Arc::clone(&turn_context));
|
||||
let world_state = Arc::new(session.build_world_state_for_step(&step_context).await);
|
||||
let initial_context = session
|
||||
.build_initial_context_with_world_state_and_mcp(
|
||||
&turn_context,
|
||||
world_state.as_ref(),
|
||||
step_context.mcp.as_ref(),
|
||||
)
|
||||
.build_initial_context_with_world_state(&turn_context, world_state.as_ref())
|
||||
.await;
|
||||
let initial_context_injection = InitialContextInjection::BeforeLastUserMessage {
|
||||
world_state,
|
||||
|
||||
@@ -1632,6 +1632,10 @@ impl Config {
|
||||
.features
|
||||
.enabled(Feature::SkillMcpDependencyInstall),
|
||||
approval_policy: self.permissions.approval_policy.clone(),
|
||||
permission_profile: self.permissions.permission_profile().clone(),
|
||||
config_layer_stack: self.config_layer_stack.clone(),
|
||||
approvals_reviewer: self.approvals_reviewer,
|
||||
environment_cwds: HashMap::new(),
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: self.features.use_legacy_landlock(),
|
||||
apps_enabled: self.features.enabled(Feature::Apps),
|
||||
|
||||
@@ -15,7 +15,6 @@ use codex_connectors::apps_config_from_layer_stack;
|
||||
use codex_connectors::connector_runtime_context_key;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::instrument;
|
||||
@@ -33,12 +32,14 @@ use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
use codex_mcp::tool_plugin_provenance;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
|
||||
@@ -211,7 +212,10 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
});
|
||||
}
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
let mcp_config = mcp_manager.runtime_config(config).await;
|
||||
let mut mcp_config = mcp_manager.runtime_config(config).await;
|
||||
// Discovery has no active turn or reviewer and must never inherit execution authority.
|
||||
mcp_config.permission_profile = PermissionProfile::default();
|
||||
let mcp_config = Arc::new(mcp_config);
|
||||
let tool_plugin_provenance = tool_plugin_provenance(&mcp_config);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
@@ -238,37 +242,31 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&auth_manager));
|
||||
let mcp_connection_manager = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
&config.permissions.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
/*tx_event*/ None,
|
||||
cancel_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
let mcp_runtime = McpRuntime::new(McpRuntimeInput {
|
||||
config: Arc::clone(&mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
mcp_servers: mcp_servers.clone(),
|
||||
submit_id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event: None,
|
||||
startup_cancellation_token: cancel_token.clone(),
|
||||
// Connector discovery is threadless. Use an actually configured env if
|
||||
// one exists, but do not reintroduce the old hidden-local fallback.
|
||||
runtime_context,
|
||||
config.codex_home.to_path_buf(),
|
||||
mcp_manager.codex_apps_tools_cache(),
|
||||
mcp_manager.tool_catalog_cache(),
|
||||
connector_runtime_context_key(auth.as_ref()),
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
codex_apps_tools_cache: mcp_manager.codex_apps_tools_cache(),
|
||||
tool_catalog_cache: mcp_manager.tool_catalog_cache(),
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||
supports_openai_form_elicitation: false,
|
||||
auth: auth.clone(),
|
||||
codex_apps_auth_manager,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let refreshed_tools = if force_refetch {
|
||||
match mcp_connection_manager
|
||||
.hard_refresh_codex_apps_tools_cache()
|
||||
match mcp_runtime
|
||||
.latest_hard_refresh_codex_apps_tools_cache()
|
||||
.await
|
||||
{
|
||||
Ok(tools) => Some(tools),
|
||||
@@ -287,14 +285,14 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
let mut tools = if let Some(tools) = refreshed_tools {
|
||||
tools
|
||||
} else {
|
||||
mcp_connection_manager.list_all_tools().await
|
||||
mcp_runtime.latest_list_all_tools().await
|
||||
};
|
||||
let mut should_reload_tools = false;
|
||||
let codex_apps_ready = if refreshed_tools_succeeded {
|
||||
true
|
||||
} else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let immediate_ready = mcp_connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
|
||||
let immediate_ready = mcp_runtime
|
||||
.latest_wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
|
||||
.await;
|
||||
if immediate_ready {
|
||||
true
|
||||
@@ -303,8 +301,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
.configured_config()
|
||||
.and_then(|config| config.startup_timeout_sec)
|
||||
.unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS);
|
||||
let ready = mcp_connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
|
||||
let ready = mcp_runtime
|
||||
.latest_wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
|
||||
.await;
|
||||
should_reload_tools = ready;
|
||||
ready
|
||||
@@ -315,7 +313,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
false
|
||||
};
|
||||
if should_reload_tools {
|
||||
tools = mcp_connection_manager.list_all_tools().await;
|
||||
tools = mcp_runtime.latest_list_all_tools().await;
|
||||
}
|
||||
if codex_apps_ready {
|
||||
cancel_token.cancel();
|
||||
@@ -327,7 +325,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
}
|
||||
let accessible_connectors =
|
||||
with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance);
|
||||
mcp_connection_manager.shutdown().await;
|
||||
mcp_runtime.shutdown().await;
|
||||
Ok(AccessibleConnectorsStatus {
|
||||
connectors: accessible_connectors,
|
||||
codex_apps_ready,
|
||||
@@ -530,9 +528,23 @@ pub(crate) fn mcp_approvals_reviewer(
|
||||
config: &Config,
|
||||
server_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> ApprovalsReviewer {
|
||||
mcp_approvals_reviewer_from_layers(
|
||||
&config.config_layer_stack,
|
||||
config.approvals_reviewer,
|
||||
server_name,
|
||||
connector_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_approvals_reviewer_from_layers(
|
||||
config_layer_stack: &codex_config::ConfigLayerStack,
|
||||
default_reviewer: ApprovalsReviewer,
|
||||
server_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> ApprovalsReviewer {
|
||||
let app_reviewer = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
apps_config_from_layer_stack(&config.config_layer_stack).and_then(|apps_config| {
|
||||
apps_config_from_layer_stack(config_layer_stack).and_then(|apps_config| {
|
||||
connector_id
|
||||
.and_then(|connector_id| apps_config.apps.get(connector_id))
|
||||
.and_then(|app| app.approvals_reviewer)
|
||||
@@ -547,8 +559,7 @@ pub(crate) fn mcp_approvals_reviewer(
|
||||
};
|
||||
|
||||
if let Some(reviewer) = app_reviewer
|
||||
&& config
|
||||
.config_layer_stack
|
||||
&& config_layer_stack
|
||||
.requirements()
|
||||
.approvals_reviewer
|
||||
.can_set(&reviewer)
|
||||
@@ -557,7 +568,7 @@ pub(crate) fn mcp_approvals_reviewer(
|
||||
return reviewer;
|
||||
}
|
||||
|
||||
config.approvals_reviewer
|
||||
default_reviewer
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps";
|
||||
|
||||
/// MCP configuration and capability availability derived from the same inputs.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct McpRuntimeProjection {
|
||||
pub(crate) config: McpConfig,
|
||||
pub(crate) plugins_available: bool,
|
||||
|
||||
+274
-276
@@ -10,7 +10,6 @@ use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianMcpAnnotations;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian_with_reviewer;
|
||||
use crate::hook_runtime::run_permission_request_hooks;
|
||||
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
||||
@@ -34,13 +33,12 @@ use codex_features::Feature;
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpPermissionPromptAutoApproveContext;
|
||||
use codex_mcp::PreparedMcpCall;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::auth_elicitation_completed_result;
|
||||
use codex_mcp::build_auth_elicitation_plan;
|
||||
use codex_mcp::mcp_permission_prompt_is_auto_approved;
|
||||
use codex_mcp::tool_is_model_visible;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
use codex_protocol::items::McpToolCallError;
|
||||
use codex_protocol::items::McpToolCallItem;
|
||||
@@ -119,7 +117,6 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
arguments: String,
|
||||
) -> HandledMcpToolCall {
|
||||
let turn_context = &step_context.turn;
|
||||
let manager = step_context.mcp.manager();
|
||||
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
|
||||
// is not.
|
||||
let arguments_value = if arguments.trim().is_empty() {
|
||||
@@ -143,16 +140,14 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
arguments: arguments_value.clone(),
|
||||
};
|
||||
|
||||
let metadata = lookup_mcp_tool_metadata(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
manager,
|
||||
&server,
|
||||
&tool_name,
|
||||
)
|
||||
.await;
|
||||
let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, metadata.as_ref());
|
||||
if metadata.is_none() {
|
||||
sess.refresh_mcp_if_dirty().await;
|
||||
let current_binding = sess.services.mcp_runtime.current_binding().await;
|
||||
let Some(prepared_call) = current_binding
|
||||
.as_ref()
|
||||
.and_then(|binding| binding.prepare_call(&server, &tool_name))
|
||||
else {
|
||||
let item_metadata =
|
||||
McpToolCallItemMetadata::from_tool_metadata(&server, /*metadata*/ None);
|
||||
let result = notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -168,48 +163,30 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
tool_input: arguments_value
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
||||
};
|
||||
}
|
||||
};
|
||||
let metadata = mcp_tool_metadata(&prepared_call);
|
||||
let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, Some(&metadata));
|
||||
let runtime_config = prepared_call.config();
|
||||
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
let annotations = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.annotations.as_ref());
|
||||
AppToolPolicyEvaluator::new(&turn_context.config.config_layer_stack).policy(
|
||||
AppToolPolicyInput {
|
||||
connector_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
tool_name: &tool_name,
|
||||
tool_title: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.tool_title.as_deref()),
|
||||
destructive_hint: annotations.and_then(|annotations| annotations.destructive_hint),
|
||||
open_world_hint: annotations.and_then(|annotations| annotations.open_world_hint),
|
||||
},
|
||||
)
|
||||
let annotations = metadata.annotations.as_ref();
|
||||
AppToolPolicyEvaluator::new(&runtime_config.config_layer_stack).policy(AppToolPolicyInput {
|
||||
connector_id: metadata.connector_id.as_deref(),
|
||||
tool_name: &tool_name,
|
||||
tool_title: metadata.tool_title.as_deref(),
|
||||
destructive_hint: annotations.and_then(|annotations| annotations.destructive_hint),
|
||||
open_world_hint: annotations.and_then(|annotations| annotations.open_world_hint),
|
||||
})
|
||||
} else {
|
||||
AppToolPolicy::default()
|
||||
};
|
||||
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
app_tool_policy.approval
|
||||
} else if let Some(approval_mode) = {
|
||||
// Selected-plugin registrations are absent from config.toml and the legacy plugin manager,
|
||||
// so their resolved catalog entry is the authoritative source for tool approval policy.
|
||||
manager
|
||||
.is_selected_plugin_mcp_server(&server)
|
||||
.then(|| manager.tool_approval_mode(&server, &tool_name))
|
||||
} {
|
||||
approval_mode
|
||||
} else {
|
||||
custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name)
|
||||
.await
|
||||
prepared_call.tool_approval_mode()
|
||||
};
|
||||
|
||||
let connector_id = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.connector_id.clone());
|
||||
let connector_name = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.connector_name.clone());
|
||||
let connector_id = metadata.connector_id.clone();
|
||||
let connector_name = metadata.connector_name.clone();
|
||||
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
|
||||
let result = notify_mcp_tool_call_skip(
|
||||
@@ -239,6 +216,8 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
||||
};
|
||||
}
|
||||
sess.register_mcp_tool_approval_metadata(turn_context, &call_id, metadata.clone())
|
||||
.await;
|
||||
notify_mcp_tool_call_started(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -248,28 +227,39 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
)
|
||||
.await;
|
||||
|
||||
let approval_policy = if prepared_call.is_selected_plugin_server() {
|
||||
McpToolApprovalPolicy::for_selected_plugin(approval_mode)
|
||||
} else {
|
||||
McpToolApprovalPolicy::for_server(approval_mode)
|
||||
};
|
||||
if let Some(decision) = maybe_request_mcp_tool_approval(
|
||||
&sess,
|
||||
step_context,
|
||||
&call_id,
|
||||
&invocation,
|
||||
&hook_tool_name,
|
||||
metadata.as_ref(),
|
||||
approval_mode,
|
||||
&metadata,
|
||||
prepared_call.config(),
|
||||
approval_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let result = match decision {
|
||||
McpToolApprovalDecision::Accept
|
||||
decision @ (McpToolApprovalDecision::Accept
|
||||
| McpToolApprovalDecision::AcceptForSession
|
||||
| McpToolApprovalDecision::AcceptAndRemember => {
|
||||
| McpToolApprovalDecision::AcceptAndRemember) => {
|
||||
return handle_approved_mcp_tool_call(
|
||||
sess.as_ref(),
|
||||
&sess,
|
||||
step_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
metadata.as_ref(),
|
||||
prepared_call,
|
||||
metadata,
|
||||
item_metadata,
|
||||
McpToolApprovalApplication::Apply {
|
||||
decision,
|
||||
policy: approval_policy,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -321,12 +311,14 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
}
|
||||
|
||||
handle_approved_mcp_tool_call(
|
||||
sess.as_ref(),
|
||||
&sess,
|
||||
step_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
metadata.as_ref(),
|
||||
prepared_call,
|
||||
metadata,
|
||||
item_metadata,
|
||||
McpToolApprovalApplication::NotRequired,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -372,53 +364,116 @@ impl McpToolCallItemMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "MCP approval must be applied inside the prepared call's catalog lease"
|
||||
)]
|
||||
async fn handle_approved_mcp_tool_call(
|
||||
sess: &Session,
|
||||
sess: &Arc<Session>,
|
||||
step_context: &StepContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
prepared_call: PreparedMcpCall,
|
||||
metadata: McpToolApprovalMetadata,
|
||||
item_metadata: McpToolCallItemMetadata,
|
||||
approval_application: McpToolApprovalApplication,
|
||||
) -> HandledMcpToolCall {
|
||||
let turn_context = step_context.turn.as_ref();
|
||||
let manager = step_context.mcp.manager();
|
||||
let server = invocation.server.clone();
|
||||
maybe_mark_thread_memory_mode_polluted(sess, turn_context, manager, &server).await;
|
||||
let tool_name = invocation.tool.clone();
|
||||
let arguments_value = invocation.arguments.clone();
|
||||
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
|
||||
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
|
||||
let server_origin = manager.server_origin(&server).map(str::to_string);
|
||||
let connector_id = metadata.connector_id.as_deref();
|
||||
let connector_name = metadata.connector_name.as_deref();
|
||||
let server_origin = prepared_call.server_origin().map(str::to_string);
|
||||
|
||||
let start = Instant::now();
|
||||
let rewrite = rewrite_mcp_tool_arguments_for_openai_files(
|
||||
sess,
|
||||
turn_context,
|
||||
arguments_value.clone(),
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_optional_fields.as_ref()),
|
||||
)
|
||||
.await;
|
||||
let tool_input = match &rewrite {
|
||||
Ok(Some(rewritten_arguments)) => rewritten_arguments.clone(),
|
||||
Ok(None) | Err(_) => arguments_value
|
||||
.clone()
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
||||
};
|
||||
let mut tool_input = arguments_value
|
||||
.clone()
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
|
||||
let result = async {
|
||||
let result = async {
|
||||
let rewritten_arguments = rewrite?;
|
||||
let request_meta =
|
||||
build_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata);
|
||||
execute_mcp_tool_call(
|
||||
let result = prepared_call
|
||||
.call_with_preparation(|| async {
|
||||
if let McpToolApprovalApplication::Apply { decision, policy } =
|
||||
&approval_application
|
||||
{
|
||||
let session_approval_key = session_mcp_tool_approval_key(
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
policy.mode,
|
||||
);
|
||||
let persistent_approval_key = if policy.allow_persistent {
|
||||
persistent_mcp_tool_approval_key(
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
policy.mode,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
apply_mcp_tool_approval_decision(
|
||||
sess,
|
||||
turn_context,
|
||||
decision,
|
||||
session_approval_key,
|
||||
persistent_approval_key,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &prepared_call)
|
||||
.await;
|
||||
let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files(
|
||||
sess,
|
||||
turn_context,
|
||||
arguments_value,
|
||||
metadata.openai_file_input_optional_fields.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if let Some(rewritten_arguments) = rewritten_arguments.as_ref() {
|
||||
tool_input = rewritten_arguments.clone();
|
||||
}
|
||||
let request_meta = build_mcp_tool_call_request_meta(
|
||||
turn_context,
|
||||
&server,
|
||||
call_id,
|
||||
Some(&metadata),
|
||||
);
|
||||
let request_meta = with_mcp_tool_call_thread_id_meta(
|
||||
request_meta,
|
||||
&sess.thread_id.to_string(),
|
||||
);
|
||||
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
step_context,
|
||||
&prepared_call,
|
||||
request_meta,
|
||||
)
|
||||
.await?;
|
||||
let mcp_call_trace = sess
|
||||
.services
|
||||
.rollout_thread_trace
|
||||
.start_mcp_call_trace(call_id);
|
||||
Ok((
|
||||
rewritten_arguments,
|
||||
mcp_call_trace.add_request_meta(request_meta),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("tool call error: {error:?}"))?;
|
||||
let result = sanitize_mcp_tool_result_for_model(
|
||||
&turn_context.model_info.input_modalities,
|
||||
Ok(result),
|
||||
)?;
|
||||
Ok(maybe_request_codex_apps_auth_elicitation(
|
||||
sess,
|
||||
step_context,
|
||||
turn_context,
|
||||
prepared_call.config().approval_policy.value(),
|
||||
call_id,
|
||||
&invocation,
|
||||
rewritten_arguments,
|
||||
metadata,
|
||||
request_meta,
|
||||
&invocation.server,
|
||||
Some(&metadata),
|
||||
result,
|
||||
)
|
||||
.await
|
||||
.await)
|
||||
}
|
||||
.await;
|
||||
record_mcp_result_span_telemetry(&Span::current(), &result);
|
||||
@@ -451,7 +506,7 @@ async fn handle_approved_mcp_tool_call(
|
||||
truncate_mcp_tool_result_for_event(&result),
|
||||
)
|
||||
.await;
|
||||
maybe_track_codex_app_used(sess, turn_context, manager, &server, &tool_name).await;
|
||||
maybe_track_codex_app_used(sess, turn_context, &server, &metadata).await;
|
||||
|
||||
let outcome = mcp_call_metric_outcome(&result);
|
||||
emit_mcp_call_metrics(
|
||||
@@ -576,64 +631,16 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_mcp_tool_call(
|
||||
sess: &Session,
|
||||
step_context: &StepContext,
|
||||
call_id: &str,
|
||||
invocation: &McpInvocation,
|
||||
rewritten_arguments: Option<JsonValue>,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
request_meta: Option<JsonValue>,
|
||||
) -> Result<CallToolResult, String> {
|
||||
let turn_context = step_context.turn.as_ref();
|
||||
let manager = step_context.mcp.manager();
|
||||
let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.thread_id.to_string());
|
||||
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
step_context,
|
||||
manager,
|
||||
&invocation.server,
|
||||
request_meta,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
|
||||
let mcp_call_trace = sess
|
||||
.services
|
||||
.rollout_thread_trace
|
||||
.start_mcp_call_trace(call_id);
|
||||
let request_meta = mcp_call_trace.add_request_meta(request_meta);
|
||||
let result = manager
|
||||
.call_tool(
|
||||
&invocation.server,
|
||||
&invocation.tool,
|
||||
rewritten_arguments,
|
||||
request_meta,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("tool call error: {e:?}"))?;
|
||||
let result =
|
||||
sanitize_mcp_tool_result_for_model(&turn_context.model_info.input_modalities, Ok(result))?;
|
||||
Ok(maybe_request_codex_apps_auth_elicitation(
|
||||
sess,
|
||||
turn_context,
|
||||
manager,
|
||||
call_id,
|
||||
&invocation.server,
|
||||
metadata,
|
||||
result,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn maybe_request_codex_apps_auth_elicitation(
|
||||
sess: &Session,
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &TurnContext,
|
||||
manager: &McpConnectionSet,
|
||||
approval_policy: AskForApproval,
|
||||
call_id: &str,
|
||||
server: &str,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
if !manager.is_host_owned_codex_apps_server(server) {
|
||||
if server != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -645,7 +652,7 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
return result;
|
||||
}
|
||||
|
||||
match turn_context.approval_policy.value() {
|
||||
match approval_policy {
|
||||
AskForApproval::Never => return result,
|
||||
AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => {
|
||||
return result;
|
||||
@@ -691,16 +698,12 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
return result;
|
||||
}
|
||||
|
||||
refresh_codex_apps_after_connector_auth(sess, turn_context, manager).await;
|
||||
refresh_codex_apps_after_connector_auth(sess, turn_context).await;
|
||||
auth_elicitation_completed_result(&plan.auth_failure, result.meta)
|
||||
}
|
||||
|
||||
async fn refresh_codex_apps_after_connector_auth(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
manager: &McpConnectionSet,
|
||||
) {
|
||||
let mcp_tools_result = manager.hard_refresh_codex_apps_tools_cache().await;
|
||||
async fn refresh_codex_apps_after_connector_auth(sess: &Arc<Session>, turn_context: &TurnContext) {
|
||||
let mcp_tools_result = sess.hard_refresh_latest_codex_apps_tools().await;
|
||||
|
||||
match mcp_tools_result {
|
||||
Ok(mcp_tools) => {
|
||||
@@ -719,31 +722,33 @@ async fn refresh_codex_apps_after_connector_auth(
|
||||
|
||||
async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
step_context: &StepContext,
|
||||
manager: &McpConnectionSet,
|
||||
server: &str,
|
||||
prepared_call: &PreparedMcpCall,
|
||||
mut meta: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let turn_context = step_context.turn.as_ref();
|
||||
let supports_sandbox_state_meta = manager
|
||||
.server_supports_sandbox_state_meta_capability(server)
|
||||
let supports_sandbox_state_meta = prepared_call
|
||||
.server_supports_sandbox_state_meta_capability()
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !supports_sandbox_state_meta {
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
let server_environment_id = manager
|
||||
.server_environment_id(server)
|
||||
.unwrap_or(codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID);
|
||||
let Some(sandbox_cwd) = sandbox_cwd_for_mcp_server(step_context, server_environment_id) else {
|
||||
let server_environment_id = prepared_call.server_environment_id();
|
||||
let Some(sandbox_cwd) = prepared_call
|
||||
.config()
|
||||
.environment_cwds
|
||||
.get(server_environment_id)
|
||||
.cloned()
|
||||
.or_else(|| sandbox_cwd_for_mcp_server(step_context, server_environment_id))
|
||||
else {
|
||||
return Ok(meta);
|
||||
};
|
||||
let permission_profile = turn_context.permission_profile();
|
||||
let permission_profile = prepared_call.config().permission_profile.clone();
|
||||
let sandbox_state = serde_json::to_value(SandboxState {
|
||||
permission_profile,
|
||||
codex_linux_sandbox_exe: step_context.mcp.config().codex_linux_sandbox_exe.clone(),
|
||||
codex_linux_sandbox_exe: prepared_call.config().codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd,
|
||||
use_legacy_landlock: step_context.mcp.config().use_legacy_landlock,
|
||||
use_legacy_landlock: prepared_call.config().use_legacy_landlock,
|
||||
})?;
|
||||
|
||||
match meta.as_mut() {
|
||||
@@ -787,14 +792,12 @@ fn sandbox_cwd_for_mcp_server(step_context: &StepContext, environment_id: &str)
|
||||
async fn maybe_mark_thread_memory_mode_polluted(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
manager: &McpConnectionSet,
|
||||
server: &str,
|
||||
prepared_call: &PreparedMcpCall,
|
||||
) {
|
||||
if !turn_context.config.memories.disable_on_external_context {
|
||||
return;
|
||||
}
|
||||
let pollutes_memory = manager.server_pollutes_memory(server);
|
||||
if !pollutes_memory {
|
||||
if !prepared_call.server_pollutes_memory() {
|
||||
return;
|
||||
}
|
||||
state_db::mark_thread_memory_mode_polluted(
|
||||
@@ -963,25 +966,17 @@ async fn notify_mcp_tool_call_completed(
|
||||
sess.emit_turn_item_completed(turn_context, item).await;
|
||||
}
|
||||
|
||||
struct McpAppUsageMetadata {
|
||||
connector_id: Option<String>,
|
||||
app_name: Option<String>,
|
||||
}
|
||||
|
||||
async fn maybe_track_codex_app_used(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
manager: &McpConnectionSet,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
metadata: &McpToolApprovalMetadata,
|
||||
) {
|
||||
if server != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return;
|
||||
}
|
||||
let metadata = lookup_mcp_app_usage_metadata(manager, server, tool_name).await;
|
||||
let (connector_id, app_name) = metadata
|
||||
.map(|metadata| (metadata.connector_id, metadata.app_name))
|
||||
.unwrap_or((None, None));
|
||||
let connector_id = metadata.connector_id.clone();
|
||||
let app_name = metadata.connector_name.clone();
|
||||
let invocation_type = if let Some(connector_id) = connector_id.as_deref() {
|
||||
let mentioned_connector_ids = sess.get_connector_selection().await;
|
||||
if mentioned_connector_ids.contains(connector_id) {
|
||||
@@ -1018,6 +1013,36 @@ enum McpToolApprovalDecision {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct McpToolApprovalPolicy {
|
||||
mode: AppToolApproval,
|
||||
allow_persistent: bool,
|
||||
}
|
||||
|
||||
enum McpToolApprovalApplication {
|
||||
NotRequired,
|
||||
Apply {
|
||||
decision: McpToolApprovalDecision,
|
||||
policy: McpToolApprovalPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpToolApprovalPolicy {
|
||||
fn for_server(mode: AppToolApproval) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
allow_persistent: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn for_selected_plugin(mode: AppToolApproval) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
allow_persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct McpToolApprovalMetadata {
|
||||
annotations: Option<ToolAnnotations>,
|
||||
@@ -1034,6 +1059,40 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
openai_file_input_optional_fields: Option<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
async fn register_mcp_tool_approval_metadata(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
metadata: McpToolApprovalMetadata,
|
||||
) {
|
||||
let Some(turn_state) = self
|
||||
.input_queue
|
||||
.turn_state_for_sub_id(&self.active_turn, &turn_context.sub_id)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
turn_state
|
||||
.lock()
|
||||
.await
|
||||
.insert_mcp_tool_approval_metadata(call_id.to_string(), metadata);
|
||||
}
|
||||
|
||||
pub(crate) async fn mcp_tool_approval_metadata(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
call_id: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
let turn_state = self
|
||||
.input_queue
|
||||
.turn_state_for_sub_id(&self.active_turn, sub_id)
|
||||
.await?;
|
||||
|
||||
turn_state.lock().await.mcp_tool_approval_metadata(call_id)
|
||||
}
|
||||
}
|
||||
|
||||
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
const MCP_TOOL_LINK_ID_META_KEY: &str = "link_id";
|
||||
@@ -1042,6 +1101,7 @@ const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
||||
const MCP_TOOL_CONNECTED_ACCOUNT_EMAIL_META_KEY: &str = "connected_account_email";
|
||||
const MCP_TOOL_RESOURCE_URI_META_KEY: &str = "resource_uri";
|
||||
|
||||
#[cfg(test)]
|
||||
async fn custom_mcp_tool_approval_mode(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -1211,38 +1271,45 @@ fn mcp_tool_approval_prompt_options(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn maybe_request_mcp_tool_approval(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
call_id: &str,
|
||||
invocation: &McpInvocation,
|
||||
hook_tool_name: &HookToolName,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
metadata: &McpToolApprovalMetadata,
|
||||
config: &codex_mcp::McpConfig,
|
||||
policy: McpToolApprovalPolicy,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
let turn_context = &step_context.turn;
|
||||
let manager = step_context.mcp.manager();
|
||||
let approvals_reviewer = mcp_approvals_reviewer(turn_context, &invocation.server, metadata);
|
||||
let approvals_reviewer = connectors::mcp_approvals_reviewer_from_layers(
|
||||
&config.config_layer_stack,
|
||||
config.approvals_reviewer,
|
||||
&invocation.server,
|
||||
metadata.connector_id.as_deref(),
|
||||
);
|
||||
if mcp_permission_prompt_is_auto_approved(
|
||||
turn_context.approval_policy.value(),
|
||||
&turn_context.permission_profile(),
|
||||
config.approval_policy.value(),
|
||||
&config.permission_profile,
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
tool_approval_mode: Some(approval_mode),
|
||||
tool_approval_mode: Some(policy.mode),
|
||||
},
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
if !requires_mcp_tool_approval_for_mode(annotations, approval_mode) {
|
||||
let annotations = metadata.annotations.as_ref();
|
||||
if !requires_mcp_tool_approval_for_mode(annotations, policy.mode) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
|
||||
let persistent_approval_key = if manager.is_selected_plugin_mcp_server(&invocation.server) {
|
||||
None
|
||||
let session_approval_key =
|
||||
session_mcp_tool_approval_key(invocation, Some(metadata), policy.mode);
|
||||
let persistent_approval_key = if policy.allow_persistent {
|
||||
persistent_mcp_tool_approval_key(invocation, Some(metadata), policy.mode)
|
||||
} else {
|
||||
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode)
|
||||
None
|
||||
};
|
||||
if let Some(key) = session_approval_key.as_ref()
|
||||
&& mcp_tool_approval_is_remembered(sess, key).await
|
||||
@@ -1280,25 +1347,21 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.features
|
||||
.enabled(Feature::ToolCallMcpElicitation);
|
||||
|
||||
if routes_approval_to_guardian_with_reviewer(turn_context, approvals_reviewer) {
|
||||
if matches!(
|
||||
config.approval_policy.value(),
|
||||
AskForApproval::OnRequest | AskForApproval::Granular(_)
|
||||
) && approvals_reviewer == ApprovalsReviewer::AutoReview
|
||||
{
|
||||
let review_id = new_guardian_review_id();
|
||||
let decision = review_approval_request(
|
||||
sess,
|
||||
turn_context,
|
||||
review_id.clone(),
|
||||
build_guardian_mcp_tool_review_request(call_id, invocation, metadata),
|
||||
build_guardian_mcp_tool_review_request(call_id, invocation, Some(metadata)),
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
let decision = mcp_tool_approval_decision_from_guardian(decision);
|
||||
apply_mcp_tool_approval_decision(
|
||||
sess,
|
||||
turn_context,
|
||||
&decision,
|
||||
session_approval_key,
|
||||
persistent_approval_key,
|
||||
)
|
||||
.await;
|
||||
return Some(decision);
|
||||
}
|
||||
|
||||
@@ -1310,9 +1373,9 @@ async fn maybe_request_mcp_tool_approval(
|
||||
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
|
||||
let rendered_template = render_mcp_tool_approval_template(
|
||||
&invocation.server,
|
||||
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
|
||||
metadata.connector_id.as_deref(),
|
||||
metadata.connector_name.as_deref(),
|
||||
metadata.tool_title.as_deref(),
|
||||
invocation.arguments.as_ref(),
|
||||
);
|
||||
let tool_params_display = rendered_template
|
||||
@@ -1323,7 +1386,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
question_id.clone(),
|
||||
&invocation.server,
|
||||
&invocation.tool,
|
||||
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
|
||||
metadata.connector_name.as_deref(),
|
||||
prompt_options,
|
||||
rendered_template
|
||||
.as_ref()
|
||||
@@ -1336,7 +1399,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
let request =
|
||||
build_mcp_tool_approval_elicitation_request(McpToolApprovalElicitationRequest {
|
||||
server: &invocation.server,
|
||||
metadata,
|
||||
metadata: Some(metadata),
|
||||
tool_params: rendered_template
|
||||
.as_ref()
|
||||
.and_then(|rendered_template| rendered_template.tool_params.as_ref())
|
||||
@@ -1359,15 +1422,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.response,
|
||||
&question_id,
|
||||
);
|
||||
let decision = normalize_approval_decision_for_mode(decision, approval_mode);
|
||||
apply_mcp_tool_approval_decision(
|
||||
sess,
|
||||
turn_context,
|
||||
&decision,
|
||||
session_approval_key,
|
||||
persistent_approval_key,
|
||||
)
|
||||
.await;
|
||||
let decision = normalize_approval_decision_for_mode(decision, policy.mode);
|
||||
return Some(decision);
|
||||
}
|
||||
|
||||
@@ -1380,16 +1435,8 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.await;
|
||||
let decision = normalize_approval_decision_for_mode(
|
||||
parse_mcp_tool_approval_response(response, &question_id),
|
||||
approval_mode,
|
||||
policy.mode,
|
||||
);
|
||||
apply_mcp_tool_approval_decision(
|
||||
sess,
|
||||
turn_context,
|
||||
&decision,
|
||||
session_approval_key,
|
||||
persistent_approval_key,
|
||||
)
|
||||
.await;
|
||||
Some(decision)
|
||||
}
|
||||
|
||||
@@ -1478,49 +1525,12 @@ fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpTool
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
manager: &McpConnectionSet,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
let plugin_id = manager
|
||||
.plugin_id_for_mcp_server_name(server)
|
||||
.map(str::to_string);
|
||||
let tool_info = manager.tool_info(server, tool_name).await?;
|
||||
if !tool_is_model_visible(&tool_info) {
|
||||
return None;
|
||||
}
|
||||
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
|
||||
turn_context.config.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(connectors) => Some(connectors),
|
||||
None => {
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
turn_context.config.as_ref(),
|
||||
/*force_refetch*/ false,
|
||||
sess.services.turn_environments.environment_manager(),
|
||||
Arc::clone(&sess.services.mcp_manager),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|status| status.connectors)
|
||||
}
|
||||
};
|
||||
connectors.and_then(|connectors| {
|
||||
let connector_id = tool_info.connector_id.as_deref()?;
|
||||
connectors
|
||||
.into_iter()
|
||||
.find(|connector| connector.id == connector_id)
|
||||
.and_then(|connector| connector.description)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fn mcp_tool_metadata(prepared_call: &PreparedMcpCall) -> McpToolApprovalMetadata {
|
||||
let server = prepared_call.server_name();
|
||||
let tool_info = prepared_call.tool_info().clone();
|
||||
let connector_description = (server == CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then(|| tool_info.namespace_description.clone())
|
||||
.flatten();
|
||||
|
||||
let codex_apps_meta = tool_info
|
||||
.tool
|
||||
@@ -1541,7 +1551,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
None
|
||||
};
|
||||
|
||||
Some(McpToolApprovalMetadata {
|
||||
McpToolApprovalMetadata {
|
||||
annotations: tool_info.tool.annotations,
|
||||
connector_id: tool_info.connector_id,
|
||||
link_id: tool_info
|
||||
@@ -1554,7 +1564,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
connector_name: tool_info.connector_name,
|
||||
connector_description,
|
||||
connected_account_email,
|
||||
plugin_id,
|
||||
plugin_id: prepared_call.plugin_id().map(str::to_string),
|
||||
tool_title: tool_info.tool.title,
|
||||
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
||||
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
|
||||
@@ -1564,7 +1574,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
server,
|
||||
&tool_info.openai_file_input_optional_fields,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_file_input_optional_fields_for_server(
|
||||
@@ -1596,18 +1606,6 @@ fn get_mcp_app_resource_uri(
|
||||
})
|
||||
}
|
||||
|
||||
async fn lookup_mcp_app_usage_metadata(
|
||||
manager: &McpConnectionSet,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<McpAppUsageMetadata> {
|
||||
let tool_info = manager.tool_info(server, tool_name).await?;
|
||||
Some(McpAppUsageMetadata {
|
||||
connector_id: tool_info.connector_id,
|
||||
app_name: tool_info.connector_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_question(
|
||||
question_id: String,
|
||||
server: &str,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::environment_selection::TurnEnvironmentState;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session::tests::make_session_and_context_with_rx;
|
||||
use crate::session::tests::mcp_config_for_test;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::test_support::models_manager_with_provider;
|
||||
@@ -28,12 +29,6 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout_trace::ThreadStartedTraceMetadata;
|
||||
use codex_rollout_trace::ToolDispatchInvocation;
|
||||
use codex_rollout_trace::ToolDispatchPayload;
|
||||
use codex_rollout_trace::ToolDispatchRequester;
|
||||
use codex_rollout_trace::replay_bundle;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::hooks::trusted_config_layer_stack;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -45,12 +40,8 @@ use core_test_support::responses::start_mock_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Instrument;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
@@ -93,6 +84,13 @@ fn approval_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_config(turn_context: &TurnContext) -> codex_mcp::McpConfig {
|
||||
let mut config = (*mcp_config_for_test(&turn_context.config)).clone();
|
||||
config.approval_policy = turn_context.approval_policy.clone();
|
||||
config.permission_profile = turn_context.permission_profile.clone();
|
||||
config
|
||||
}
|
||||
|
||||
fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> {
|
||||
McpTurnMetadataContext {
|
||||
model: turn_context.model_info.slug.as_str(),
|
||||
@@ -134,62 +132,6 @@ fn prompt_options(
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_mcp_tool_call_records_replayable_correlation() -> anyhow::Result<()> {
|
||||
let temp = tempdir()?;
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
attach_trace_bundle(&mut session, &turn_context, temp.path())?;
|
||||
|
||||
let dispatch_trace = session
|
||||
.services
|
||||
.rollout_thread_trace
|
||||
.start_tool_dispatch_trace(|| {
|
||||
Some(ToolDispatchInvocation {
|
||||
thread_id: session.thread_id.to_string(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
tool_call_id: "mcp-call".to_string(),
|
||||
tool_name: "search".to_string(),
|
||||
tool_namespace: Some("mcp__docs__".to_string()),
|
||||
requester: ToolDispatchRequester::Model {
|
||||
model_visible_call_id: "mcp-call".to_string(),
|
||||
},
|
||||
payload: ToolDispatchPayload::Function {
|
||||
arguments: r#"{"query":"trace"}"#.to_string(),
|
||||
},
|
||||
})
|
||||
});
|
||||
assert!(dispatch_trace.is_enabled());
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
||||
|
||||
let result = execute_mcp_tool_call(
|
||||
&session,
|
||||
step_context.as_ref(),
|
||||
"mcp-call",
|
||||
&McpInvocation {
|
||||
server: "docs".to_string(),
|
||||
tool: "search".to_string(),
|
||||
arguments: Some(serde_json::json!({ "query": "trace" })),
|
||||
},
|
||||
/*rewritten_arguments*/ None,
|
||||
/*metadata*/ None,
|
||||
/*request_meta*/ None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"the synthetic backend is absent; only trace emission matters",
|
||||
);
|
||||
|
||||
let replayed = replay_bundle(single_bundle_dir(temp.path())?)?;
|
||||
assert!(
|
||||
replayed.tool_calls["mcp-call"].mcp_call_id.is_some(),
|
||||
"the real MCP execution path should emit a reducer-visible correlation ID",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_mcp_permission_request_hook(
|
||||
session: &mut Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -279,45 +221,6 @@ print({hook_output:?})
|
||||
log_path.to_path_buf()
|
||||
}
|
||||
|
||||
/// Attaches a replayable rollout bundle to one synthetic session under test.
|
||||
fn attach_trace_bundle(
|
||||
session: &mut Session,
|
||||
turn_context: &TurnContext,
|
||||
root: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let rollout_thread_trace =
|
||||
codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test(
|
||||
root,
|
||||
ThreadStartedTraceMetadata {
|
||||
thread_id: session.thread_id.to_string(),
|
||||
agent_path: "/root".to_string(),
|
||||
task_name: None,
|
||||
nickname: None,
|
||||
agent_role: None,
|
||||
session_source: SessionSource::Exec,
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
rollout_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
provider_name: "test-provider".to_string(),
|
||||
approval_policy: "never".to_string(),
|
||||
sandbox_policy: "danger-full-access".to_string(),
|
||||
},
|
||||
)?;
|
||||
rollout_thread_trace.record_codex_turn_started(turn_context.sub_id.as_str());
|
||||
session.services.rollout_thread_trace = rollout_thread_trace;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the sole bundle emitted under a temporary rollout trace root.
|
||||
fn single_bundle_dir(root: &Path) -> anyhow::Result<PathBuf> {
|
||||
let mut entries = fs::read_dir(root)?
|
||||
.map(|entry| entry.map(|entry| entry.path()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
entries.sort();
|
||||
assert_eq!(entries.len(), 1);
|
||||
Ok(entries.remove(0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_app_resource_uri_reads_known_tool_meta_keys() {
|
||||
let nested = serde_json::json!({
|
||||
@@ -1442,55 +1345,6 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
|
||||
)
|
||||
}
|
||||
|
||||
async fn host_owned_codex_apps_manager(
|
||||
session: &Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> Arc<codex_mcp::McpConnectionSet> {
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let startup_cancellation_token = CancellationToken::new();
|
||||
startup_cancellation_token.cancel();
|
||||
let mcp_servers = HashMap::from([(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_mcp::EffectiveMcpServer::configured(codex_mcp::codex_apps_mcp_server_config(
|
||||
"https://chatgpt.com",
|
||||
/*apps_mcp_product_sku*/ None,
|
||||
Some(&turn_context.originator),
|
||||
)),
|
||||
)]);
|
||||
let manager = codex_mcp::McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
turn_context.config.mcp_oauth_credentials_store_mode,
|
||||
turn_context.config.auth_keyring_backend_kind(),
|
||||
&turn_context.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
/*tx_event*/ None,
|
||||
startup_cancellation_token,
|
||||
turn_context.permission_profile(),
|
||||
codex_mcp::McpRuntimeContext::new(
|
||||
session.services.turn_environments.environment_manager(),
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf()
|
||||
},
|
||||
),
|
||||
turn_context.config.codex_home.to_path_buf(),
|
||||
session.services.mcp_manager.codex_apps_tools_cache(),
|
||||
session.services.mcp_manager.tool_catalog_cache(),
|
||||
codex_connectors::connector_runtime_context_key(auth.as_ref()),
|
||||
turn_context.config.prefix_mcp_tool_names(),
|
||||
rmcp::model::ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
codex_mcp::ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*codex_apps_auth_manager*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
Arc::new(manager)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
@@ -1498,40 +1352,13 @@ async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result()
|
||||
features.disable(Feature::AuthElicitation);
|
||||
let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features);
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
|
||||
let returned = maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
&turn_context,
|
||||
manager.as_ref(),
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(returned, result);
|
||||
assert!(rx_event.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::AuthElicitation);
|
||||
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features);
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
let manager = session.services.latest_mcp_runtime().manager_arc();
|
||||
|
||||
let returned = maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
turn_context,
|
||||
manager.as_ref(),
|
||||
turn_context.approval_policy.value(),
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
@@ -1546,22 +1373,17 @@ async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_resu
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::AuthElicitation);
|
||||
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features);
|
||||
turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features);
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
|
||||
let returned = maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
turn_context,
|
||||
manager.as_ref(),
|
||||
&turn_context,
|
||||
AskForApproval::Never,
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
@@ -1576,12 +1398,11 @@ async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_resul
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::AuthElicitation);
|
||||
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features);
|
||||
turn_context
|
||||
let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features);
|
||||
mutable_turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
@@ -1596,8 +1417,8 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu
|
||||
|
||||
let returned = maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
turn_context,
|
||||
manager.as_ref(),
|
||||
&turn_context,
|
||||
turn_context.approval_policy.value(),
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
@@ -1612,7 +1433,6 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() {
|
||||
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
@@ -1620,12 +1440,11 @@ async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() {
|
||||
let request_task = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let manager = Arc::clone(&manager);
|
||||
async move {
|
||||
maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
&turn_context,
|
||||
manager.as_ref(),
|
||||
turn_context.approval_policy.value(),
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
@@ -2522,8 +2341,9 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
"call-1",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Approve),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2598,8 +2418,9 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
"call-guardian",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Auto),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2657,8 +2478,9 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
"call-mcp-hook",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Auto),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2712,6 +2534,11 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
|
||||
tool: "create_entities".to_string(),
|
||||
arguments: Some(serde_json::json!({ "entities": [] })),
|
||||
};
|
||||
let metadata = approval_metadata(
|
||||
/*connector_id*/ None, /*connector_name*/ None,
|
||||
/*connector_description*/ None, /*tool_title*/ None,
|
||||
/*tool_description*/ None,
|
||||
);
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
@@ -2719,8 +2546,9 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
|
||||
"call-mcp-hook-no-metadata",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
/*metadata*/ None,
|
||||
AppToolApproval::Auto,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Auto),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2801,8 +2629,9 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
|
||||
"call-mcp-remembered",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__memory__create_entities"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Auto),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2884,8 +2713,9 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
"call-guardian-deny",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Auto),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2944,8 +2774,9 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
"call-prompt",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Prompt,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Prompt),
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -3002,8 +2833,9 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
|
||||
"call-2",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
approval_mode,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(approval_mode),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3091,8 +2923,9 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() {
|
||||
"call-3",
|
||||
&invocation,
|
||||
&HookToolName::new("mcp__test__tool"),
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
&metadata,
|
||||
&approval_config(&turn_context),
|
||||
McpToolApprovalPolicy::for_server(AppToolApproval::Approve),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentEvent;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RealtimeConversationListVoicesResponseEvent;
|
||||
use codex_protocol::protocol::RealtimeVoicesList;
|
||||
@@ -231,11 +230,6 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
.set_responsesapi_client_metadata(responsesapi_client_metadata);
|
||||
}
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
sess.refresh_mcp_servers_if_requested(
|
||||
¤t_context,
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
)
|
||||
.await;
|
||||
let additional_context_input = {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.additional_context.merge(additional_context)
|
||||
@@ -427,9 +421,8 @@ pub async fn dynamic_tool_response(sess: &Arc<Session>, id: String, response: Dy
|
||||
sess.notify_dynamic_tool_response(&id, response).await;
|
||||
}
|
||||
|
||||
pub async fn refresh_mcp_servers(sess: &Arc<Session>, refresh_config: McpServerRefreshConfig) {
|
||||
let mut guard = sess.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
pub fn refresh_mcp_servers(sess: &Session) {
|
||||
sess.mark_mcp_runtime_dirty();
|
||||
}
|
||||
|
||||
pub async fn reload_user_config(sess: &Arc<Session>) {
|
||||
@@ -592,6 +585,8 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
if let Err(err) = sess.services.code_mode_service.shutdown().await {
|
||||
warn!("failed to shutdown code mode session: {err}");
|
||||
}
|
||||
let _refresh = sess.mcp_refresh_lock.acquire().await;
|
||||
sess.mcp_refresh_lock.close();
|
||||
sess.services.mcp_runtime.shutdown().await;
|
||||
sess.guardian_review_session.shutdown().await;
|
||||
|
||||
@@ -665,8 +660,6 @@ pub async fn review(
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||
sess.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer()))
|
||||
.await;
|
||||
#[allow(deprecated)]
|
||||
match resolve_review_request(review_request, &turn_context.cwd) {
|
||||
Ok(resolved) => {
|
||||
@@ -784,8 +777,12 @@ pub(super) async fn submission_loop(
|
||||
dynamic_tool_response(&sess, id, response).await;
|
||||
false
|
||||
}
|
||||
Op::RefreshMcpServers { config } => {
|
||||
refresh_mcp_servers(&sess, config).await;
|
||||
Op::RefreshMcpServers => {
|
||||
refresh_mcp_servers(&sess);
|
||||
false
|
||||
}
|
||||
Op::ReloadMcpConfig { config } => {
|
||||
sess.refresh_mcp_config(config).await;
|
||||
false
|
||||
}
|
||||
Op::ReloadUserConfig => {
|
||||
|
||||
+149
-311
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::mcp::McpRuntimeProjection;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoveryCache;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS;
|
||||
@@ -7,7 +6,6 @@ use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_mcp::ElicitationReviewRequest;
|
||||
use codex_mcp::ElicitationReviewer;
|
||||
use codex_mcp::ElicitationReviewerHandle;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
|
||||
@@ -45,6 +43,21 @@ struct GuardianMcpElicitationReviewer {
|
||||
session: std::sync::Weak<Session>,
|
||||
}
|
||||
|
||||
/// Restores a claimed refresh when its task is cancelled before publication.
|
||||
struct McpRefreshInvalidationGuard<'a> {
|
||||
pending: &'a std::sync::atomic::AtomicBool,
|
||||
published: bool,
|
||||
}
|
||||
|
||||
impl Drop for McpRefreshInvalidationGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.published {
|
||||
self.pending
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct McpServerElicitationOutcome {
|
||||
pub(crate) response: Option<ElicitationResponse>,
|
||||
pub(crate) sent: bool,
|
||||
@@ -82,6 +95,13 @@ impl ElicitationReviewer for GuardianMcpElicitationReviewer {
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn runtime_mcp_config(&self, config: &Config) -> McpConfig {
|
||||
self.runtime_mcp_config_and_context(config).await.0
|
||||
}
|
||||
|
||||
pub(crate) async fn runtime_mcp_config_and_context(
|
||||
&self,
|
||||
config: &Config,
|
||||
) -> (McpConfig, McpRuntimeContext) {
|
||||
let originator = self.originator().await;
|
||||
let environments = self.services.turn_environments.snapshot().await;
|
||||
let selected_capability_roots = self
|
||||
@@ -92,7 +112,8 @@ impl Session {
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(config, &ready_selected_capability_roots)
|
||||
.await;
|
||||
self.services
|
||||
let mcp_config = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
config,
|
||||
@@ -103,7 +124,17 @@ impl Session {
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await
|
||||
.config
|
||||
.config;
|
||||
let local_stdio_fallback_cwd = environments
|
||||
.primary()
|
||||
.and_then(|environment| environment.cwd().to_abs_path().ok())
|
||||
.map(|cwd| cwd.to_path_buf())
|
||||
.unwrap_or_else(|| config.cwd.to_path_buf());
|
||||
let runtime_context = McpRuntimeContext::new(
|
||||
self.services.turn_environments.environment_manager(),
|
||||
local_stdio_fallback_cwd,
|
||||
);
|
||||
(mcp_config, runtime_context)
|
||||
}
|
||||
|
||||
pub(crate) async fn runtime_mcp_servers(
|
||||
@@ -113,91 +144,107 @@ impl Session {
|
||||
codex_mcp::configured_mcp_servers(&self.runtime_mcp_config(config).await)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP runtime comparison and publication must remain serialized"
|
||||
)]
|
||||
/// Publishes changed MCP state, waiting for any refresh already in progress.
|
||||
pub(crate) async fn refresh_mcp_if_dirty(self: &Arc<Self>) {
|
||||
let Ok(_refresh) = self.mcp_refresh_lock.acquire().await else {
|
||||
error!("MCP runtime refresh semaphore closed");
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if !self
|
||||
.mcp_refresh_pending
|
||||
.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut refresh_invalidation = McpRefreshInvalidationGuard {
|
||||
pending: &self.mcp_refresh_pending,
|
||||
published: false,
|
||||
};
|
||||
let desired = self.latest_mcp_desired_state().await;
|
||||
let selected_capability_roots = self
|
||||
.resolve_selected_capability_roots_for_step(&desired.environments)
|
||||
.await;
|
||||
let ready_selected_capability_roots =
|
||||
Self::ready_selected_capability_roots(&selected_capability_roots);
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(
|
||||
&desired.config,
|
||||
&ready_selected_capability_roots,
|
||||
)
|
||||
.await;
|
||||
let mcp_projection = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
&desired.config,
|
||||
&self.services.mcp_thread_init,
|
||||
&self.services.thread_extension_data,
|
||||
&desired.originator,
|
||||
&ready_selected_capability_roots,
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await;
|
||||
self.publish_mcp_runtime(
|
||||
&desired,
|
||||
mcp_projection,
|
||||
&ready_selected_capability_roots,
|
||||
Some(self.mcp_elicitation_reviewer()),
|
||||
)
|
||||
.await;
|
||||
refresh_invalidation.published = true;
|
||||
if !self
|
||||
.mcp_refresh_pending
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the future Apps catalog without making an exact step a refresh owner.
|
||||
pub(crate) async fn hard_refresh_latest_codex_apps_tools(
|
||||
self: &Arc<Self>,
|
||||
) -> anyhow::Result<Vec<codex_mcp::ToolInfo>> {
|
||||
self.refresh_mcp_if_dirty().await;
|
||||
let _refresh = self
|
||||
.mcp_refresh_lock
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("MCP runtime refresh semaphore closed"))?;
|
||||
self.services
|
||||
.mcp_runtime
|
||||
.latest_hard_refresh_codex_apps_tools_cache()
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn mark_mcp_runtime_dirty(&self) {
|
||||
self.mcp_refresh_pending
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "mcp.runtime.resolve_for_step", skip_all)]
|
||||
pub(crate) async fn mcp_runtime_for_step(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &TurnContext,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
selected_capability_roots: &[ResolvedSelectedCapabilityRoot],
|
||||
executor_capability_discovery: Option<&ExecutorCapabilityDiscoverySnapshot>,
|
||||
) -> Arc<McpRuntimeSnapshot> {
|
||||
) -> Arc<codex_mcp::McpBinding> {
|
||||
let ready_selected_capability_roots =
|
||||
Self::ready_selected_capability_roots(selected_capability_roots);
|
||||
let available_environment_ids =
|
||||
Self::available_selected_environment_ids(selected_capability_roots);
|
||||
let current = self.services.latest_mcp_runtime();
|
||||
if current.ready_selected_capability_roots() == ready_selected_capability_roots {
|
||||
return current;
|
||||
}
|
||||
|
||||
let _guard = self.services.mcp_projection_lock.lock().await;
|
||||
let current = self.services.latest_mcp_runtime();
|
||||
if current.ready_selected_capability_roots() == ready_selected_capability_roots {
|
||||
return current;
|
||||
}
|
||||
let mcp_projection = self
|
||||
if self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
&turn_context.config,
|
||||
&self.services.mcp_thread_init,
|
||||
&self.services.thread_extension_data,
|
||||
&turn_context.originator,
|
||||
&ready_selected_capability_roots,
|
||||
executor_capability_discovery,
|
||||
)
|
||||
.await;
|
||||
let mcp_config = &mcp_projection.config;
|
||||
let changed_environment_is_used_by_mcp = mcp_config
|
||||
.mcp_server_catalog
|
||||
.configured_servers()
|
||||
.values()
|
||||
.any(|server| {
|
||||
let was_available = current
|
||||
.ready_selected_capability_roots()
|
||||
.iter()
|
||||
.any(|root| {
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
&root.location;
|
||||
environment_id == &server.environment_id
|
||||
});
|
||||
let is_available = available_environment_ids.contains(&server.environment_id);
|
||||
server.enabled && was_available != is_available
|
||||
});
|
||||
if !changed_environment_is_used_by_mcp
|
||||
&& current
|
||||
.config()
|
||||
.mcp_server_catalog
|
||||
.has_same_servers(&mcp_config.mcp_server_catalog)
|
||||
&& current.config().connector_snapshot == mcp_config.connector_snapshot
|
||||
.mcp_runtime
|
||||
.current_ready_selected_capability_roots()
|
||||
!= ready_selected_capability_roots
|
||||
{
|
||||
// Selected roots are only an input to the MCP projection. When they change but the
|
||||
// projected servers and connectors do not, advance the input key without
|
||||
// replacing the live manager and restarting its processes.
|
||||
let runtime = Arc::new(McpRuntimeSnapshot::new(
|
||||
Arc::new(current.config().clone()),
|
||||
mcp_projection.plugins_available,
|
||||
current.manager_arc(),
|
||||
current.runtime_context().clone(),
|
||||
ready_selected_capability_roots,
|
||||
));
|
||||
self.services
|
||||
.mcp_runtime_snapshot
|
||||
.store(Some(Arc::clone(&runtime)));
|
||||
return runtime;
|
||||
self.mark_mcp_runtime_dirty();
|
||||
}
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
mcp_projection,
|
||||
environments,
|
||||
&ready_selected_capability_roots,
|
||||
Some(self.mcp_elicitation_reviewer()),
|
||||
)
|
||||
.await
|
||||
self.refresh_mcp_if_dirty().await;
|
||||
if let Some(binding) = self.services.mcp_runtime.current_binding().await {
|
||||
return binding;
|
||||
}
|
||||
let config = Arc::new(self.runtime_mcp_config(&turn_context.config).await);
|
||||
Arc::new(codex_mcp::McpBinding::empty(config))
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
@@ -299,12 +346,7 @@ impl Session {
|
||||
request_id: RequestId,
|
||||
request: ElicitationRequest,
|
||||
) -> McpServerElicitationOutcome {
|
||||
if self
|
||||
.services
|
||||
.latest_mcp_runtime()
|
||||
.manager()
|
||||
.elicitations_auto_deny()
|
||||
{
|
||||
if self.services.mcp_runtime.elicitations_auto_deny() {
|
||||
return McpServerElicitationOutcome {
|
||||
response: Some(ElicitationResponse {
|
||||
action: codex_rmcp_client::ElicitationAction::Accept,
|
||||
@@ -398,187 +440,11 @@ impl Session {
|
||||
}
|
||||
|
||||
self.services
|
||||
.latest_mcp_runtime()
|
||||
.manager_arc()
|
||||
.mcp_runtime
|
||||
.resolve_elicitation(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "mcp.runtime.refresh", skip_all)]
|
||||
async fn refresh_mcp_servers_inner(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
mcp_projection: McpRuntimeProjection,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
ready_selected_capability_roots: &[SelectedCapabilityRoot],
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) -> Arc<McpRuntimeSnapshot> {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let McpRuntimeProjection {
|
||||
config: mcp_config,
|
||||
plugins_available,
|
||||
} = mcp_projection;
|
||||
let mcp_config = Arc::new(mcp_config);
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
|
||||
let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
let environment_manager = self.services.turn_environments.environment_manager();
|
||||
// TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment cwd
|
||||
// values can be used without falling back to the legacy host cwd.
|
||||
let cwd = environments
|
||||
.primary()
|
||||
.and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok())
|
||||
.map(|cwd| cwd.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf()
|
||||
});
|
||||
let mcp_runtime_context = McpRuntimeContext::new(environment_manager, cwd);
|
||||
let mcp_startup_cancellation_token = {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
// The previous runtime owns the old token and may still be serving an in-flight step.
|
||||
// Its manager cancels that token when the last runtime handle is dropped.
|
||||
let cancellation_token = CancellationToken::new();
|
||||
*guard = cancellation_token.clone();
|
||||
cancellation_token
|
||||
};
|
||||
let current_runtime = self.services.latest_mcp_runtime();
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.services.auth_manager));
|
||||
let refreshed_manager = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
mcp_config.mcp_oauth_credentials_store_mode,
|
||||
mcp_config.auth_keyring_backend_kind,
|
||||
&turn_context.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
Some(self.get_tx_event()),
|
||||
mcp_startup_cancellation_token,
|
||||
turn_context.permission_profile(),
|
||||
mcp_runtime_context.clone(),
|
||||
mcp_config.codex_home.clone(),
|
||||
self.services.mcp_manager.codex_apps_tools_cache(),
|
||||
self.services.mcp_manager.tool_catalog_cache(),
|
||||
connector_runtime_context_key(auth.as_ref()),
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability.clone(),
|
||||
self.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth.as_ref(),
|
||||
codex_apps_auth_manager,
|
||||
elicitation_reviewer,
|
||||
Some(self.mcp_elicitation_lifecycle()),
|
||||
current_runtime.manager().elicitation_router(),
|
||||
)
|
||||
.await;
|
||||
refreshed_manager
|
||||
.set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny());
|
||||
self.services.publish_mcp_runtime(
|
||||
mcp_config,
|
||||
plugins_available,
|
||||
mcp_runtime_context,
|
||||
ready_selected_capability_roots.to_vec(),
|
||||
refreshed_manager,
|
||||
)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP runtime refresh and publication must remain serialized"
|
||||
)]
|
||||
pub(crate) async fn refresh_mcp_servers_if_requested(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) {
|
||||
let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() };
|
||||
let Some(refresh_config) = refresh_config else {
|
||||
return;
|
||||
};
|
||||
|
||||
let McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
auth_keyring_backend_kind,
|
||||
} = refresh_config;
|
||||
|
||||
let mcp_servers =
|
||||
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
|
||||
Ok(servers) => servers,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP server refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
|
||||
mcp_oauth_credentials_store_mode,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP OAuth refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let keyring_backend_kind =
|
||||
match serde_json::from_value::<AuthKeyringBackendKind>(auth_keyring_backend_kind) {
|
||||
Ok(kind) => kind,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP auth keyring backend refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut refresh_config = self.get_config().await.as_ref().clone();
|
||||
refresh_config.mcp_oauth_credentials_store_mode = store_mode;
|
||||
let secret_auth_storage_enabled = match keyring_backend_kind {
|
||||
AuthKeyringBackendKind::Direct => false,
|
||||
AuthKeyringBackendKind::Secrets => true,
|
||||
};
|
||||
if let Err(err) = refresh_config
|
||||
.features
|
||||
.set_enabled(Feature::SecretAuthStorage, secret_auth_storage_enabled)
|
||||
{
|
||||
warn!("failed to apply MCP auth keyring backend refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = self.services.mcp_projection_lock.lock().await;
|
||||
let current_runtime = self.services.latest_mcp_runtime();
|
||||
let ready_selected_capability_roots =
|
||||
current_runtime.ready_selected_capability_roots().to_vec();
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(
|
||||
&refresh_config,
|
||||
&ready_selected_capability_roots,
|
||||
)
|
||||
.await;
|
||||
let mut mcp_projection = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
&refresh_config,
|
||||
&self.services.mcp_thread_init,
|
||||
&self.services.thread_extension_data,
|
||||
&turn_context.originator,
|
||||
&ready_selected_capability_roots,
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await;
|
||||
mcp_projection.config.mcp_server_catalog = mcp_projection
|
||||
.config
|
||||
.mcp_server_catalog
|
||||
.with_materialized_servers(mcp_servers);
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
mcp_projection,
|
||||
&turn_context.environments,
|
||||
&ready_selected_capability_roots,
|
||||
elicitation_reviewer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn set_openai_form_elicitation_support(
|
||||
&self,
|
||||
supported: bool,
|
||||
@@ -592,35 +458,33 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = self.get_config().await;
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(config.mcp_servers.get())?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
)?,
|
||||
auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?,
|
||||
};
|
||||
self.services
|
||||
.supports_openai_form_elicitation
|
||||
.store(supported, std::sync::atomic::Ordering::Relaxed);
|
||||
*self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config);
|
||||
self.mark_mcp_runtime_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP runtime refresh and publication must remain serialized"
|
||||
)]
|
||||
pub(crate) async fn refresh_mcp_servers_now(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
refresh_config: &Config,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) {
|
||||
let _guard = self.services.mcp_projection_lock.lock().await;
|
||||
let current_runtime = self.services.latest_mcp_runtime();
|
||||
let ready_selected_capability_roots =
|
||||
current_runtime.ready_selected_capability_roots().to_vec();
|
||||
let Ok(_refresh) = self.mcp_refresh_lock.acquire().await else {
|
||||
error!("MCP runtime refresh semaphore closed");
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.mcp_servers = refresh_config.mcp_servers.clone();
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
}
|
||||
let ready_selected_capability_roots = self
|
||||
.services
|
||||
.mcp_runtime
|
||||
.current_ready_selected_capability_roots();
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(
|
||||
refresh_config,
|
||||
@@ -639,30 +503,17 @@ impl Session {
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await;
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
let mut desired = self.latest_mcp_desired_state().await;
|
||||
desired.config = Arc::new(refresh_config.clone());
|
||||
self.publish_mcp_runtime(
|
||||
&desired,
|
||||
mcp_projection,
|
||||
&turn_context.environments,
|
||||
&ready_selected_capability_roots,
|
||||
elicitation_reviewer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn available_selected_environment_ids(
|
||||
selected_capability_roots: &[ResolvedSelectedCapabilityRoot],
|
||||
) -> Vec<String> {
|
||||
let mut available = Vec::new();
|
||||
for root in selected_capability_roots {
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
&root.selected_root().location;
|
||||
if !available.contains(environment_id) {
|
||||
available.push(environment_id.clone());
|
||||
}
|
||||
}
|
||||
available
|
||||
}
|
||||
|
||||
pub(crate) fn ready_selected_capability_roots(
|
||||
selected_capability_roots: &[ResolvedSelectedCapabilityRoot],
|
||||
) -> Vec<SelectedCapabilityRoot> {
|
||||
@@ -672,21 +523,8 @@ impl Session {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn mcp_startup_cancellation_token(&self) -> CancellationToken {
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_mcp_startup(&self) {
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.cancel();
|
||||
pub(crate) fn cancel_mcp_startup(&self) {
|
||||
self.services.mcp_runtime.cancel_startup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +1,166 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
//! Thread MCP runtime projection and publication.
|
||||
//!
|
||||
//! This module owns the small correctness boundary between immutable session
|
||||
//! inputs and the mutable [`codex_mcp::McpRuntime`]. Background scheduling
|
||||
//! belongs elsewhere.
|
||||
|
||||
use codex_mcp::McpConfig;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use super::session::SessionConfiguration;
|
||||
use super::*;
|
||||
use crate::mcp::McpRuntimeProjection;
|
||||
use codex_mcp::ElicitationReviewerHandle;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
|
||||
/// MCP config, plugin availability, exact environment bindings, and manager for one request.
|
||||
pub struct McpRuntimeSnapshot {
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
manager: Arc<McpConnectionSet>,
|
||||
runtime_context: McpRuntimeContext,
|
||||
ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
pub(super) struct McpDesiredState {
|
||||
pub(super) config: Arc<Config>,
|
||||
pub(super) submit_id: String,
|
||||
pub(super) originator: String,
|
||||
pub(super) environments: TurnEnvironmentSnapshot,
|
||||
}
|
||||
|
||||
impl McpRuntimeSnapshot {
|
||||
pub(crate) fn new(
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
manager: Arc<McpConnectionSet>,
|
||||
runtime_context: McpRuntimeContext,
|
||||
ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
plugins_available,
|
||||
manager,
|
||||
runtime_context,
|
||||
ready_selected_capability_roots,
|
||||
impl McpDesiredState {
|
||||
pub(super) fn local_stdio_fallback_cwd(&self) -> PathBuf {
|
||||
self.environments
|
||||
.primary()
|
||||
.and_then(|environment| environment.cwd().to_abs_path().ok())
|
||||
.map(|cwd| cwd.to_path_buf())
|
||||
.unwrap_or_else(|| self.config.cwd.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(super) async fn latest_mcp_desired_state(&self) -> McpDesiredState {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
let environments = self.services.turn_environments.snapshot().await;
|
||||
let cwd = environments
|
||||
.primary()
|
||||
.and_then(|environment| environment.cwd().to_abs_path().ok())
|
||||
.unwrap_or_else(|| session_configuration.cwd().clone());
|
||||
let mut config = Self::build_per_turn_config(&session_configuration, cwd);
|
||||
config.permissions.approval_policy = session_configuration.approval_policy.clone();
|
||||
|
||||
McpDesiredState {
|
||||
config: Arc::new(config),
|
||||
submit_id: self.next_internal_sub_id(),
|
||||
originator: session_configuration.originator.clone(),
|
||||
environments,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &McpConfig {
|
||||
self.config.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn plugins_available(&self) -> bool {
|
||||
self.plugins_available
|
||||
}
|
||||
|
||||
pub fn manager(&self) -> &McpConnectionSet {
|
||||
self.manager.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn manager_arc(&self) -> Arc<McpConnectionSet> {
|
||||
Arc::clone(&self.manager)
|
||||
}
|
||||
|
||||
pub fn runtime_context(&self) -> &McpRuntimeContext {
|
||||
&self.runtime_context
|
||||
}
|
||||
|
||||
pub(crate) fn ready_selected_capability_roots(&self) -> &[SelectedCapabilityRoot] {
|
||||
&self.ready_selected_capability_roots
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_uninitialized_for_test(config: &crate::config::Config) -> Arc<Self> {
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::ResolvedMcpCatalog;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
|
||||
let mcp_config = McpConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
apps_mcp_product_sku: config.apps_mcp_product_sku.clone(),
|
||||
codex_home: config.codex_home.to_path_buf(),
|
||||
mcp_oauth_credentials_store_mode: config.mcp_oauth_credentials_store_mode,
|
||||
auth_keyring_backend_kind: config.auth_keyring_backend_kind(),
|
||||
mcp_oauth_callback_port: config.mcp_oauth_callback_port,
|
||||
mcp_oauth_callback_url: config.mcp_oauth_callback_url.clone(),
|
||||
skill_mcp_dependency_install_enabled: config
|
||||
.features
|
||||
.enabled(Feature::SkillMcpDependencyInstall),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
apps_enabled: config.features.enabled(Feature::Apps),
|
||||
prefix_mcp_tool_names: config.prefix_mcp_tool_names(),
|
||||
client_elicitation_capability: ElicitationCapability::default(),
|
||||
mcp_server_catalog: ResolvedMcpCatalog::default(),
|
||||
connector_snapshot: codex_connectors::ConnectorSnapshot::default(),
|
||||
pub(super) async fn install_initial_mcp_runtime(
|
||||
self: &Arc<Self>,
|
||||
session_configuration: &SessionConfiguration,
|
||||
mcp_projection: McpRuntimeProjection,
|
||||
resolved_environments: &TurnEnvironmentSnapshot,
|
||||
local_stdio_fallback_cwd: PathBuf,
|
||||
) -> anyhow::Result<()> {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(local_stdio_fallback_cwd)
|
||||
.unwrap_or_else(|_| session_configuration.cwd().clone());
|
||||
let mut config = Self::build_per_turn_config(session_configuration, cwd);
|
||||
config.permissions.approval_policy = session_configuration.approval_policy.clone();
|
||||
let desired = McpDesiredState {
|
||||
config: Arc::new(config),
|
||||
submit_id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
originator: session_configuration.originator.clone(),
|
||||
environments: resolved_environments.clone(),
|
||||
};
|
||||
let manager = McpConnectionSet::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
config.prefix_mcp_tool_names(),
|
||||
);
|
||||
let runtime_context = McpRuntimeContext::new(
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config.cwd.to_path_buf(),
|
||||
);
|
||||
Arc::new(Self::new(
|
||||
Arc::new(mcp_config),
|
||||
/*plugins_available*/ false,
|
||||
Arc::new(manager),
|
||||
runtime_context,
|
||||
Vec::new(),
|
||||
self.publish_mcp_runtime(
|
||||
&desired,
|
||||
mcp_projection,
|
||||
/*ready_selected_capability_roots*/ &[],
|
||||
Some(self.mcp_elicitation_reviewer()),
|
||||
)
|
||||
.instrument(info_span!(
|
||||
"session_init.mcp_manager_init",
|
||||
otel.name = "session_init.mcp_manager_init",
|
||||
))
|
||||
}
|
||||
}
|
||||
.await;
|
||||
|
||||
impl fmt::Debug for McpRuntimeSnapshot {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("McpRuntimeSnapshot")
|
||||
.finish_non_exhaustive()
|
||||
self.services.mcp_runtime.validate_required_servers().await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "mcp.runtime.refresh", skip_all)]
|
||||
pub(super) async fn publish_mcp_runtime(
|
||||
&self,
|
||||
desired: &McpDesiredState,
|
||||
mcp_projection: McpRuntimeProjection,
|
||||
ready_selected_capability_roots: &[SelectedCapabilityRoot],
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) {
|
||||
let input = self
|
||||
.build_mcp_runtime_input(
|
||||
desired,
|
||||
mcp_projection,
|
||||
ready_selected_capability_roots,
|
||||
elicitation_reviewer,
|
||||
)
|
||||
.await;
|
||||
self.services.mcp_runtime.replace(input).await;
|
||||
}
|
||||
|
||||
async fn build_mcp_runtime_input(
|
||||
&self,
|
||||
desired: &McpDesiredState,
|
||||
mcp_projection: McpRuntimeProjection,
|
||||
ready_selected_capability_roots: &[SelectedCapabilityRoot],
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) -> McpRuntimeInput {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let supports_openai_form_elicitation = self
|
||||
.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let McpRuntimeProjection {
|
||||
mut config,
|
||||
plugins_available,
|
||||
} = mcp_projection;
|
||||
config.approval_policy = desired.config.permissions.approval_policy.clone();
|
||||
config.permission_profile = desired.config.permissions.effective_permission_profile();
|
||||
config.approvals_reviewer = desired.config.approvals_reviewer;
|
||||
config.environment_cwds = desired
|
||||
.environments
|
||||
.turn_environments()
|
||||
.map(|environment| {
|
||||
(
|
||||
environment.environment_id.clone(),
|
||||
environment.cwd().clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
config
|
||||
.environment_cwds
|
||||
.entry(codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string())
|
||||
.or_insert_with(|| PathUri::from_abs_path(&desired.config.cwd));
|
||||
let mcp_config = Arc::new(config);
|
||||
let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
let local_stdio_fallback_cwd = desired.local_stdio_fallback_cwd();
|
||||
let runtime_context = McpRuntimeContext::new(
|
||||
self.services.turn_environments.environment_manager(),
|
||||
local_stdio_fallback_cwd,
|
||||
);
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.services.auth_manager));
|
||||
|
||||
McpRuntimeInput {
|
||||
config: mcp_config,
|
||||
plugins_available,
|
||||
ready_selected_capability_roots: ready_selected_capability_roots.to_vec(),
|
||||
mcp_servers,
|
||||
submit_id: desired.submit_id.clone(),
|
||||
tx_event: Some(self.get_tx_event()),
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
runtime_context,
|
||||
codex_apps_tools_cache: self.services.mcp_manager.codex_apps_tools_cache(),
|
||||
tool_catalog_cache: self.services.mcp_manager.tool_catalog_cache(),
|
||||
codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()),
|
||||
supports_openai_form_elicitation,
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle: Some(self.mcp_elicitation_lifecycle()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::SubAgentThreadStartedInput;
|
||||
use codex_analytics::TurnCodexErrorFact;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_connectors::connector_runtime_context_key;
|
||||
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
|
||||
use codex_exec_server::Environment;
|
||||
@@ -73,10 +72,10 @@ use codex_hooks::HooksConfig;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
@@ -195,7 +194,9 @@ use crate::thread_rollout_truncation::initial_history_has_prior_user_turns;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
@@ -231,7 +232,6 @@ use self::handlers::submission_loop;
|
||||
pub(crate) use self::input_queue::InputQueueActivity;
|
||||
pub(crate) use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInputQueue;
|
||||
pub use self::mcp_runtime::McpRuntimeSnapshot;
|
||||
use self::review::spawn_review_thread;
|
||||
use self::session::AppServerClientMetadata;
|
||||
use self::session::Session;
|
||||
@@ -1494,11 +1494,15 @@ impl Session {
|
||||
let updated_permission_profile = updated.permission_profile();
|
||||
let permission_profile_changed =
|
||||
previous_permission_profile != updated_permission_profile;
|
||||
let mcp_inputs_changed = state.session_configuration.mcp_inputs_differ(&updated);
|
||||
if updates.environments.is_some() {
|
||||
self.services
|
||||
.turn_environments
|
||||
.update_selections(updated.environment_selections());
|
||||
}
|
||||
if mcp_inputs_changed {
|
||||
self.mark_mcp_runtime_dirty();
|
||||
}
|
||||
state.session_configuration = updated;
|
||||
(previous_config, new_config, permission_profile_changed)
|
||||
};
|
||||
@@ -1507,7 +1511,6 @@ impl Session {
|
||||
self.refresh_managed_network_proxy_for_current_permission_profile()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1540,8 +1543,7 @@ impl Session {
|
||||
})
|
||||
.await?;
|
||||
self.services
|
||||
.latest_mcp_runtime()
|
||||
.manager()
|
||||
.mcp_runtime
|
||||
.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1608,8 +1610,17 @@ impl Session {
|
||||
.with_user_layer_from(&next_config.config_layer_stack);
|
||||
config.tool_suggest =
|
||||
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
|
||||
config.mcp_servers = next_config.mcp_servers.clone();
|
||||
config.mcp_oauth_credentials_store_mode = next_config.mcp_oauth_credentials_store_mode;
|
||||
if let Err(err) = config.features.set_enabled(
|
||||
Feature::SecretAuthStorage,
|
||||
next_config.features.enabled(Feature::SecretAuthStorage),
|
||||
) {
|
||||
warn!("failed to refresh MCP auth storage config: {err}");
|
||||
}
|
||||
let config = Arc::new(config);
|
||||
state.session_configuration.original_config_do_not_use = Arc::clone(&config);
|
||||
self.mark_mcp_runtime_dirty();
|
||||
let new_config = notify_config_contributors
|
||||
.then(|| Self::build_effective_session_config(&state.session_configuration));
|
||||
(previous_config, new_config, config)
|
||||
@@ -1636,6 +1647,54 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_mcp_config(&self, next_config: McpServerRefreshConfig) {
|
||||
let McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
auth_keyring_backend_kind,
|
||||
} = next_config;
|
||||
let mcp_servers =
|
||||
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
|
||||
Ok(servers) => servers,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP server refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
|
||||
mcp_oauth_credentials_store_mode,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP OAuth refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let keyring_backend_kind =
|
||||
match serde_json::from_value::<AuthKeyringBackendKind>(auth_keyring_backend_kind) {
|
||||
Ok(kind) => kind,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP auth keyring backend refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
if let Err(err) = config.mcp_servers.set(mcp_servers) {
|
||||
warn!("failed to apply MCP server refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
config.mcp_oauth_credentials_store_mode = store_mode;
|
||||
if let Err(err) = config.features.set_enabled(
|
||||
Feature::SecretAuthStorage,
|
||||
matches!(keyring_backend_kind, AuthKeyringBackendKind::Secrets),
|
||||
) {
|
||||
warn!("failed to refresh MCP auth storage config: {err}");
|
||||
}
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
self.mark_mcp_runtime_dirty();
|
||||
}
|
||||
|
||||
fn emit_config_changed_contributors(
|
||||
&self,
|
||||
previous_config: Option<&Config>,
|
||||
@@ -2941,20 +3000,16 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
let mcp = self
|
||||
.mcp_runtime_for_step(
|
||||
turn_context.as_ref(),
|
||||
&environments,
|
||||
&selected_capability_roots,
|
||||
executor_capability_discovery.as_deref(),
|
||||
)
|
||||
.await;
|
||||
.mcp_runtime_for_step(turn_context.as_ref(), &selected_capability_roots)
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
let (mcp_tools, tool_router) = turn::built_tools(
|
||||
self.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&environments,
|
||||
mcp.as_ref(),
|
||||
cancellation_token,
|
||||
)
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
Ok(Arc::new(StepContext {
|
||||
turn: turn_context,
|
||||
@@ -3227,22 +3282,10 @@ impl Session {
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn build_initial_context_with_world_state(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
world_state: &WorldState,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mcp = self.services.latest_mcp_runtime();
|
||||
self.build_initial_context_with_world_state_and_mcp(turn_context, world_state, &mcp)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_initial_context_with_world_state_and_mcp(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
world_state: &WorldState,
|
||||
mcp: &McpRuntimeSnapshot,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut developer_sections = Vec::<String>::with_capacity(8);
|
||||
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
|
||||
@@ -3392,9 +3435,10 @@ impl Session {
|
||||
if turn_context.config.features.enabled(Feature::TokenBudget)
|
||||
&& turn_context.model_context_window().is_some()
|
||||
{
|
||||
let mcp_result = mcp
|
||||
.manager()
|
||||
.call_tool(
|
||||
let mcp_result = self
|
||||
.services
|
||||
.mcp_runtime
|
||||
.latest_call_tool(
|
||||
"notes",
|
||||
"thread_hint",
|
||||
/*arguments*/ None,
|
||||
@@ -3549,11 +3593,7 @@ impl Session {
|
||||
};
|
||||
let (window_number, window_ids) = window;
|
||||
let context_items = self
|
||||
.build_initial_context_with_world_state_and_mcp(
|
||||
turn_context,
|
||||
world_state.as_ref(),
|
||||
step_context.mcp.as_ref(),
|
||||
)
|
||||
.build_initial_context_with_world_state(turn_context, world_state.as_ref())
|
||||
.await;
|
||||
let turn_context_item = turn_context.to_turn_context_item();
|
||||
self.replace_compacted_history(
|
||||
@@ -3606,11 +3646,7 @@ impl Session {
|
||||
// Full initial context resets the baseline; later turns persist only its changes.
|
||||
let (mut context_items, world_state_item) = if should_inject_full_context {
|
||||
let context_items = self
|
||||
.build_initial_context_with_world_state_and_mcp(
|
||||
turn_context,
|
||||
world_state.as_ref(),
|
||||
step_context.mcp.as_ref(),
|
||||
)
|
||||
.build_initial_context_with_world_state(turn_context, world_state.as_ref())
|
||||
.await;
|
||||
let snapshot = world_state.snapshot();
|
||||
self.state
|
||||
@@ -3966,7 +4002,7 @@ impl Session {
|
||||
let had_active_turn = self.active_turn.lock().await.is_some();
|
||||
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
if !had_active_turn {
|
||||
self.cancel_mcp_startup().await;
|
||||
self.cancel_mcp_startup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ pub(crate) struct Session {
|
||||
/// session.
|
||||
pub(super) features: ManagedFeatures,
|
||||
pub(super) multi_agent_version: OnceLock<MultiAgentVersion>,
|
||||
pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(super) mcp_refresh_pending: std::sync::atomic::AtomicBool,
|
||||
/// Serializes runtime refreshes without blocking calls that own a snapshot.
|
||||
pub(super) mcp_refresh_lock: Semaphore,
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) input_queue: InputQueue,
|
||||
@@ -143,6 +145,13 @@ impl SessionConfiguration {
|
||||
self.permission_profile_state.permission_profile().clone()
|
||||
}
|
||||
|
||||
pub(super) fn mcp_inputs_differ(&self, next: &Self) -> bool {
|
||||
self.environments != next.environments
|
||||
|| self.approval_policy.value() != next.approval_policy.value()
|
||||
|| self.approvals_reviewer != next.approvals_reviewer
|
||||
|| self.permission_profile() != next.permission_profile()
|
||||
}
|
||||
|
||||
fn materialized_permission_profile(&self) -> PermissionProfile {
|
||||
self.permission_profile()
|
||||
.materialize_project_roots_with_workspace_roots(&self.primary_workspace_roots())
|
||||
@@ -702,8 +711,6 @@ impl Session {
|
||||
.and_then(|environment| environment.cwd.to_abs_path().ok())
|
||||
.map(|cwd| cwd.to_path_buf())
|
||||
.unwrap_or_else(|| session_configuration.cwd().to_path_buf());
|
||||
let mcp_runtime_context =
|
||||
McpRuntimeContext::new(Arc::clone(&environment_manager), mcp_runtime_cwd);
|
||||
let auth_and_mcp_fut = async move {
|
||||
let auth = auth_manager_clone.auth().await;
|
||||
let mcp_projection = mcp_manager_for_mcp
|
||||
@@ -716,10 +723,7 @@ impl Session {
|
||||
/*executor_capability_discovery*/ None,
|
||||
)
|
||||
.await;
|
||||
let mcp_config = &mcp_projection.config;
|
||||
let mcp_servers = codex_mcp::effective_mcp_servers(mcp_config, auth.as_ref());
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(mcp_config);
|
||||
(auth, mcp_projection, mcp_servers, tool_plugin_provenance)
|
||||
(auth, mcp_projection)
|
||||
}
|
||||
.instrument(info_span!(
|
||||
"session_init.auth_mcp",
|
||||
@@ -727,11 +731,8 @@ impl Session {
|
||||
));
|
||||
|
||||
// Join all independent futures.
|
||||
let (
|
||||
thread_persistence_result,
|
||||
state_db_ctx,
|
||||
(auth, mcp_projection, mcp_servers, tool_plugin_provenance),
|
||||
) = tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut);
|
||||
let (thread_persistence_result, state_db_ctx, (auth, mcp_projection)) =
|
||||
tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut);
|
||||
|
||||
let mut live_thread_init =
|
||||
LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| {
|
||||
@@ -808,10 +809,12 @@ impl Session {
|
||||
) {
|
||||
post_session_configured_events.push(event);
|
||||
}
|
||||
let auth = auth.as_ref();
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
let account_id = auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = auth.and_then(CodexAuth::get_account_email);
|
||||
let telemetry_auth = auth.as_ref();
|
||||
let auth_mode = telemetry_auth
|
||||
.map(CodexAuth::auth_mode)
|
||||
.map(TelemetryAuthMode::from);
|
||||
let account_id = telemetry_auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = telemetry_auth.and_then(CodexAuth::get_account_email);
|
||||
let originator = session_configuration.originator.clone();
|
||||
let terminal_type = user_agent();
|
||||
let session_model = session_configuration.collaboration_mode.model().to_string();
|
||||
@@ -860,6 +863,13 @@ impl Session {
|
||||
)],
|
||||
);
|
||||
|
||||
let mcp_server_names =
|
||||
codex_mcp::effective_mcp_servers(
|
||||
&mcp_projection.config,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.into_keys()
|
||||
.collect::<Vec<_>>();
|
||||
session_telemetry.conversation_starts(
|
||||
config.model_provider.name.as_str(),
|
||||
session_configuration.collaboration_mode.reasoning_effort(),
|
||||
@@ -872,7 +882,7 @@ impl Session {
|
||||
config
|
||||
.permissions
|
||||
.legacy_sandbox_policy(session_configuration.cwd().as_path()),
|
||||
mcp_servers.keys().map(String::as_str).collect(),
|
||||
mcp_server_names.iter().map(String::as_str).collect(),
|
||||
);
|
||||
|
||||
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
|
||||
@@ -1032,13 +1042,10 @@ impl Session {
|
||||
config.analytics_enabled,
|
||||
)
|
||||
});
|
||||
let mcp_runtime = Arc::new(McpRuntime::new(Arc::new(
|
||||
McpConnectionSet::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
config.prefix_mcp_tool_names(),
|
||||
),
|
||||
)));
|
||||
// Extensions need a stable thread-owned resource client before the Session exists.
|
||||
let mcp_runtime = Arc::new(McpRuntime::empty(
|
||||
mcp_projection.config.prefix_mcp_tool_names,
|
||||
));
|
||||
let session_extension_data =
|
||||
codex_extension_api::ExtensionData::new(session_id.to_string());
|
||||
let mcp_resource_client = Arc::new(McpResourceClient::new(Arc::clone(&mcp_runtime)));
|
||||
@@ -1058,9 +1065,6 @@ impl Session {
|
||||
// Start with an empty connection set. The initialized set is
|
||||
// published after SessionConfigured so MCP events follow it.
|
||||
mcp_runtime,
|
||||
mcp_runtime_snapshot: arc_swap::ArcSwapOption::empty(),
|
||||
mcp_projection_lock: Mutex::new(()),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
@@ -1145,7 +1149,8 @@ impl Session {
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
multi_agent_version,
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(false),
|
||||
mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
input_queue: InputQueue::new(),
|
||||
@@ -1196,59 +1201,13 @@ impl Session {
|
||||
}
|
||||
turn_environments.start_connection_event_forwarding(tx_event.clone());
|
||||
|
||||
let mcp_startup_cancellation_token = {
|
||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
let cancel_token = CancellationToken::new();
|
||||
*cancel_guard = cancel_token.clone();
|
||||
cancel_token
|
||||
};
|
||||
let codex_apps_auth_manager =
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_projection.config, auth)
|
||||
.then(|| Arc::clone(&sess.services.auth_manager));
|
||||
let mcp_connection_manager = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
&session_configuration.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
Some(tx_event.clone()),
|
||||
mcp_startup_cancellation_token,
|
||||
session_configuration.permission_profile(),
|
||||
mcp_runtime_context.clone(),
|
||||
config.codex_home.to_path_buf(),
|
||||
sess.services.mcp_manager.codex_apps_tools_cache(),
|
||||
sess.services.mcp_manager.tool_catalog_cache(),
|
||||
connector_runtime_context_key(auth),
|
||||
config.prefix_mcp_tool_names(),
|
||||
mcp_projection
|
||||
.config
|
||||
.client_elicitation_capability
|
||||
.clone(),
|
||||
sess.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
codex_apps_auth_manager,
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
Some(sess.mcp_elicitation_lifecycle()),
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
sess.install_initial_mcp_runtime(
|
||||
&session_configuration,
|
||||
mcp_projection,
|
||||
&resolved_environments,
|
||||
mcp_runtime_cwd,
|
||||
)
|
||||
.instrument(info_span!(
|
||||
"session_init.mcp_manager_init",
|
||||
otel.name = "session_init.mcp_manager_init",
|
||||
))
|
||||
.await;
|
||||
sess.services
|
||||
.install_mcp_runtime(
|
||||
Arc::new(mcp_projection.config),
|
||||
mcp_projection.plugins_available,
|
||||
mcp_runtime_context,
|
||||
/*ready_selected_capability_roots*/ Vec::new(),
|
||||
mcp_connection_manager,
|
||||
)
|
||||
.await?;
|
||||
.await?;
|
||||
sess.schedule_startup_prewarm(session_configuration.base_instructions.clone())
|
||||
.await;
|
||||
let session_start_source = match &initial_history {
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agents_md::LoadedAgentsMd;
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::session::McpRuntimeSnapshot;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_mcp::McpBinding;
|
||||
use codex_mcp::ToolInfo;
|
||||
|
||||
/// Request-scoped state that may change between model sampling requests.
|
||||
@@ -17,8 +17,8 @@ pub(crate) struct StepContext {
|
||||
pub(crate) selected_capability_roots: Vec<ResolvedSelectedCapabilityRoot>,
|
||||
/// Executor-materialized capability files shared by MCP and skills in this exact step.
|
||||
pub(crate) executor_capability_discovery: Option<Arc<ExecutorCapabilityDiscoverySnapshot>>,
|
||||
/// The exact MCP config and manager used to advertise and execute tools for this step.
|
||||
pub(crate) mcp: Arc<McpRuntimeSnapshot>,
|
||||
/// The exact MCP connections, configuration, and catalog captured for this step.
|
||||
pub(crate) mcp: Arc<McpBinding>,
|
||||
/// The fixed MCP tool list used for this exact sampling request.
|
||||
pub(crate) mcp_tools: Vec<ToolInfo>,
|
||||
/// The finalized tool plan advertised and executed for this exact sampling request.
|
||||
|
||||
+157
-185
@@ -195,6 +195,13 @@ use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
pub(crate) fn mcp_config_for_test(config: &crate::config::Config) -> Arc<codex_mcp::McpConfig> {
|
||||
Arc::new(config.to_mcp_config_with_loaded_plugins(
|
||||
&codex_core_plugins::PluginLoadOutcome::default(),
|
||||
std::iter::empty(),
|
||||
))
|
||||
}
|
||||
|
||||
impl StepContext {
|
||||
pub(crate) fn for_test(turn: Arc<TurnContext>) -> Arc<Self> {
|
||||
let environments = turn.environments.clone();
|
||||
@@ -203,7 +210,9 @@ impl StepContext {
|
||||
environments,
|
||||
selected_capability_roots: Vec::new(),
|
||||
executor_capability_discovery: None,
|
||||
mcp: crate::session::McpRuntimeSnapshot::new_uninitialized_for_test(&turn.config),
|
||||
mcp: Arc::new(codex_mcp::McpBinding::empty(mcp_config_for_test(
|
||||
&turn.config,
|
||||
))),
|
||||
mcp_tools: Vec::new(),
|
||||
tool_router: Arc::new(ToolRouter::from_parts(
|
||||
ToolRegistry::empty_for_test(),
|
||||
@@ -414,8 +423,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
|
||||
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
session
|
||||
.services
|
||||
.latest_mcp_runtime()
|
||||
.manager()
|
||||
.mcp_runtime
|
||||
.set_elicitations_auto_deny(/*auto_deny*/ true);
|
||||
|
||||
let response = session
|
||||
@@ -5407,16 +5415,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
/*bundled_skills_enabled*/ true,
|
||||
));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let mcp_runtime_snapshot =
|
||||
crate::session::McpRuntimeSnapshot::new_uninitialized_for_test(config.as_ref());
|
||||
let mcp_runtime = Arc::new(codex_mcp::McpRuntime::new(
|
||||
mcp_runtime_snapshot.manager_arc(),
|
||||
));
|
||||
let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names()));
|
||||
let services = SessionServices {
|
||||
mcp_runtime,
|
||||
mcp_runtime_snapshot: arc_swap::ArcSwapOption::from(Some(mcp_runtime_snapshot)),
|
||||
mcp_projection_lock: Mutex::new(()),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
@@ -5539,7 +5540,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true),
|
||||
mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
input_queue: super::input_queue::InputQueue::new(),
|
||||
@@ -6634,7 +6636,6 @@ fn op_kind_for_input_and_context_ops() {
|
||||
async fn user_turn_updates_approvals_reviewer() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let config = session.get_config().await;
|
||||
|
||||
handlers::user_input_or_turn(
|
||||
&session,
|
||||
"sub-1".to_string(),
|
||||
@@ -6673,6 +6674,12 @@ async fn user_turn_updates_approvals_reviewer() {
|
||||
state.session_configuration.approvals_reviewer,
|
||||
codex_config::types::ApprovalsReviewer::AutoReview
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.mcp_refresh_pending
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
"server elicitation authority changes must refresh MCP state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -7569,16 +7576,9 @@ where
|
||||
/*bundled_skills_enabled*/ true,
|
||||
));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let mcp_runtime_snapshot =
|
||||
crate::session::McpRuntimeSnapshot::new_uninitialized_for_test(config.as_ref());
|
||||
let mcp_runtime = Arc::new(codex_mcp::McpRuntime::new(
|
||||
mcp_runtime_snapshot.manager_arc(),
|
||||
));
|
||||
let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names()));
|
||||
let services = SessionServices {
|
||||
mcp_runtime,
|
||||
mcp_runtime_snapshot: arc_swap::ArcSwapOption::from(Some(mcp_runtime_snapshot)),
|
||||
mcp_projection_lock: Mutex::new(()),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
@@ -7701,7 +7701,8 @@ where
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true),
|
||||
mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
input_queue: super::input_queue::InputQueue::new(),
|
||||
@@ -7740,23 +7741,15 @@ pub(crate) async fn make_session_and_context_with_rx() -> (
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() {
|
||||
async fn refresh_mcp_servers_uses_latest_state_for_existing_turns() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let old_runtime = session.services.latest_mcp_runtime();
|
||||
let step_context = session
|
||||
let old_step = session
|
||||
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
|
||||
.await
|
||||
.expect("a fresh cancellation token cannot be cancelled");
|
||||
assert!(Arc::ptr_eq(&step_context.mcp, &old_runtime));
|
||||
let old_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!old_token.is_cancelled());
|
||||
|
||||
let mcp_oauth_credentials_store_mode =
|
||||
serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode");
|
||||
let auth_keyring_backend_kind =
|
||||
serde_json::to_value(AuthKeyringBackendKind::Secrets).expect("serialize keyring backend");
|
||||
let refreshed_mcp_servers = serde_json::from_value::<HashMap<String, McpServerConfig>>(json!({
|
||||
"refreshed": {
|
||||
"url": "https://refreshed.example/mcp",
|
||||
@@ -7764,46 +7757,129 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() {
|
||||
}
|
||||
}))
|
||||
.expect("parse refreshed MCP servers");
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(&refreshed_mcp_servers)
|
||||
.expect("serialize refreshed MCP servers"),
|
||||
mcp_oauth_credentials_store_mode,
|
||||
auth_keyring_backend_kind,
|
||||
};
|
||||
{
|
||||
let mut guard = session.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
let mut state = session.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config
|
||||
.mcp_servers
|
||||
.set(refreshed_mcp_servers.clone())
|
||||
.expect("set refreshed MCP servers");
|
||||
config.mcp_oauth_credentials_store_mode =
|
||||
codex_config::types::OAuthCredentialsStoreMode::Auto;
|
||||
config
|
||||
.features
|
||||
.set_enabled(Feature::SecretAuthStorage, /*enabled*/ true)
|
||||
.expect("enable secret auth storage");
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
}
|
||||
session.mark_mcp_runtime_dirty();
|
||||
|
||||
assert!(!old_token.is_cancelled());
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
|
||||
session
|
||||
.refresh_mcp_servers_if_requested(&turn_context, /*elicitation_reviewer*/ None)
|
||||
let next_turn = session.new_default_turn().await;
|
||||
let new_step = session
|
||||
.capture_step_context(next_turn, &CancellationToken::new())
|
||||
.await
|
||||
.expect("a fresh cancellation token cannot be cancelled");
|
||||
let rematerialized_old = session
|
||||
.mcp_runtime_for_step(&turn_context, /*selected_capability_roots*/ &[])
|
||||
.await;
|
||||
|
||||
assert!(!old_token.is_cancelled());
|
||||
let configured_servers = codex_mcp::configured_mcp_servers(new_step.mcp.config());
|
||||
assert_eq!(
|
||||
configured_servers.get("refreshed"),
|
||||
refreshed_mcp_servers.get("refreshed")
|
||||
);
|
||||
assert!(
|
||||
!codex_mcp::configured_mcp_servers(old_step.mcp.config()).contains_key("refreshed"),
|
||||
"an already-bound step must keep its captured config"
|
||||
);
|
||||
assert!(
|
||||
codex_mcp::configured_mcp_servers(rematerialized_old.config()).contains_key("refreshed"),
|
||||
"an older turn should resolve the latest MCP state"
|
||||
);
|
||||
let current = session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.current_binding()
|
||||
.await
|
||||
.expect("current MCP binding");
|
||||
assert!(
|
||||
codex_mcp::configured_mcp_servers(current.config()).contains_key("refreshed"),
|
||||
"the refreshed state should remain globally current"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshed_mcp_binding_captures_current_approval_authority() {
|
||||
let (session, old_turn) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let previous_policy = old_turn.approval_policy.value();
|
||||
|
||||
session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
permission_profile: Some(PermissionProfile::Disabled),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("approval settings should update");
|
||||
session.refresh_mcp_if_dirty().await;
|
||||
|
||||
let binding = session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.current_binding()
|
||||
.await
|
||||
.expect("refreshed runtime should be available");
|
||||
let config = binding.config();
|
||||
assert_eq!(
|
||||
(
|
||||
config.approval_policy.value(),
|
||||
&config.permission_profile,
|
||||
config.approvals_reviewer,
|
||||
),
|
||||
(
|
||||
AskForApproval::Never,
|
||||
&PermissionProfile::Disabled,
|
||||
ApprovalsReviewer::AutoReview,
|
||||
)
|
||||
);
|
||||
assert_eq!(old_turn.approval_policy.value(), previous_policy);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_mcp_refresh_remains_pending() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
|
||||
{
|
||||
let _state = session.state.lock().await;
|
||||
{
|
||||
let mut refresh = Box::pin(session.refresh_mcp_if_dirty());
|
||||
let mut context = std::task::Context::from_waker(futures::task::noop_waker_ref());
|
||||
assert!(std::future::Future::poll(refresh.as_mut(), &mut context).is_pending());
|
||||
assert!(
|
||||
!session
|
||||
.mcp_refresh_pending
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
"the refresh should have claimed its pending invalidation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_none()
|
||||
.mcp_refresh_pending
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
"a cancelled refresh must leave the runtime dirty"
|
||||
);
|
||||
let new_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!new_token.is_cancelled());
|
||||
let new_runtime = session.services.latest_mcp_runtime();
|
||||
assert!(!Arc::ptr_eq(&old_runtime, &new_runtime));
|
||||
assert!(Arc::ptr_eq(&step_context.mcp, &old_runtime));
|
||||
assert_eq!(
|
||||
codex_mcp::configured_mcp_servers(new_runtime.config()),
|
||||
refreshed_mcp_servers
|
||||
|
||||
session.refresh_mcp_if_dirty().await;
|
||||
assert!(
|
||||
!session
|
||||
.mcp_refresh_pending
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
"the next refresh should publish the pending runtime"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7821,126 +7897,6 @@ impl codex_exec_server::NoiseRendezvousConnectProvider for PendingNoiseConnectPr
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deferred_environment_roots_refresh_plugin_availability() {
|
||||
struct ReadyPluginContributor;
|
||||
|
||||
impl codex_extension_api::McpServerContributor<Config> for ReadyPluginContributor {
|
||||
fn id(&self) -> &'static str {
|
||||
"ready_plugin_test"
|
||||
}
|
||||
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let available = context
|
||||
.ready_selected_capability_roots()
|
||||
.is_some_and(|roots| roots.iter().any(|root| root.id == "skill-only"));
|
||||
available
|
||||
.then(
|
||||
|| codex_extension_api::McpServerContribution::SelectedPluginPackage {
|
||||
plugin_id: "skill-only".to_string(),
|
||||
plugin_display_name: "Skill Only".to_string(),
|
||||
connector_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
let mut registry = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
registry.mcp_server_contributor(Arc::new(ReadyPluginContributor));
|
||||
let registry = Arc::new(registry.build());
|
||||
session.services.extensions = Arc::clone(®istry);
|
||||
session.services.mcp_manager = Arc::new(McpManager::new_with_extensions(
|
||||
Arc::clone(&session.services.plugins_manager),
|
||||
registry,
|
||||
crate::CodexAppsToolsCache::default(),
|
||||
));
|
||||
let session = Arc::new(session);
|
||||
session
|
||||
.refresh_mcp_servers_now(
|
||||
&turn_context,
|
||||
&turn_context.config,
|
||||
/*elicitation_reviewer*/ None,
|
||||
)
|
||||
.await;
|
||||
let old_runtime = session.services.latest_mcp_runtime();
|
||||
let old_manager = old_runtime.manager_arc();
|
||||
|
||||
let selected_root = codex_protocol::capabilities::SelectedCapabilityRoot {
|
||||
id: "skill-only".to_string(),
|
||||
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
|
||||
environment_id: "executor".to_string(),
|
||||
path: PathUri::from_host_native_path(turn_context.config.cwd.as_path())
|
||||
.expect("selected capability root URI"),
|
||||
},
|
||||
};
|
||||
let environment_manager = session.services.turn_environments.environment_manager();
|
||||
let registration = environment_manager
|
||||
.register_deferred_noise_environment(
|
||||
"executor".to_string(),
|
||||
Arc::new(PendingNoiseConnectProvider),
|
||||
)
|
||||
.expect("register deferred environment");
|
||||
let environment = environment_manager
|
||||
.get_environment("executor")
|
||||
.expect("deferred environment");
|
||||
assert!(environment.selected_capability_roots().is_empty());
|
||||
registration
|
||||
.complete(Ok(codex_exec_server::EnvironmentReadyInfo {
|
||||
selected_capability_roots: vec![selected_root.clone()],
|
||||
}))
|
||||
.expect("complete deferred environment");
|
||||
assert_eq!(
|
||||
environment.selected_capability_roots(),
|
||||
std::slice::from_ref(&selected_root)
|
||||
);
|
||||
let local_environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("ready local environment");
|
||||
let environments = TurnEnvironmentSnapshot {
|
||||
environments: vec![TurnEnvironmentState::Ready(TurnEnvironment::new(
|
||||
"executor".to_string(),
|
||||
environment,
|
||||
local_environment.cwd().clone(),
|
||||
local_environment.workspace_roots().to_vec(),
|
||||
local_environment.shell.clone(),
|
||||
))],
|
||||
};
|
||||
let resolved_roots = session
|
||||
.resolve_selected_capability_roots_for_step(&environments)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resolved_roots
|
||||
.iter()
|
||||
.map(|root| root.selected_root().clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![selected_root]
|
||||
);
|
||||
|
||||
let new_runtime = session
|
||||
.mcp_runtime_for_step(
|
||||
&turn_context,
|
||||
&environments,
|
||||
&resolved_roots,
|
||||
/*executor_capability_discovery*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!old_runtime.plugins_available());
|
||||
assert!(new_runtime.plugins_available());
|
||||
assert!(!Arc::ptr_eq(&old_runtime, &new_runtime));
|
||||
assert!(Arc::ptr_eq(&old_manager, &new_runtime.manager_arc()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tracing_test::traced_test]
|
||||
async fn conflicting_ready_environment_root_ids_keep_first_location() {
|
||||
@@ -8021,7 +7977,7 @@ async fn conflicting_ready_environment_root_ids_keep_first_location() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn built_tools_uses_the_step_mcp_runtime() -> anyhow::Result<()> {
|
||||
async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
@@ -8065,6 +8021,22 @@ async fn built_tools_uses_the_step_mcp_runtime() -> anyhow::Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let next_step = session
|
||||
.capture_step_context(Arc::clone(&step_context.turn), &CancellationToken::new())
|
||||
.await
|
||||
.expect("a fresh cancellation token cannot be cancelled");
|
||||
assert!(codex_mcp::configured_mcp_servers(next_step.mcp.config()).contains_key("newer"));
|
||||
|
||||
session.mark_mcp_runtime_dirty();
|
||||
session.refresh_mcp_if_dirty().await;
|
||||
let current = session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.current_binding()
|
||||
.await
|
||||
.expect("refreshed runtime should be available");
|
||||
assert!(codex_mcp::configured_mcp_servers(current.config()).contains_key("newer"));
|
||||
|
||||
let router = &step_context.tool_router;
|
||||
assert!(
|
||||
!router
|
||||
|
||||
@@ -39,7 +39,6 @@ use crate::responses_metadata::CodexResponsesMetadata;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_retry::ResponsesStreamRequest;
|
||||
use crate::responses_retry::handle_retryable_response_stream_error;
|
||||
use crate::session::McpRuntimeSnapshot;
|
||||
use crate::session::PreviousTurnSettings;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
@@ -602,17 +601,7 @@ async fn build_skills_and_plugins(
|
||||
// Plugin mentions need raw MCP/app inventory even when app tools
|
||||
// are normally hidden so we can describe the plugin's currently
|
||||
// usable capabilities for this turn.
|
||||
match step_context
|
||||
.mcp
|
||||
.manager_arc()
|
||||
.list_all_tools()
|
||||
.or_cancel(cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) if turn_context.apps_enabled() => return None,
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
step_context.mcp.tools().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -1281,14 +1270,9 @@ pub(crate) async fn built_tools(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
mcp: &McpRuntimeSnapshot,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> CodexResult<(Vec<ToolInfo>, Arc<ToolRouter>)> {
|
||||
let all_mcp_tools = mcp
|
||||
.manager()
|
||||
.list_all_tools()
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
) -> (Vec<ToolInfo>, Arc<ToolRouter>) {
|
||||
let all_mcp_tools = mcp.tools().to_vec();
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
@@ -1409,7 +1393,7 @@ pub(crate) async fn built_tools(
|
||||
},
|
||||
&sess.services.tool_search_handler_cache,
|
||||
));
|
||||
Ok((all_mcp_tools, tool_router))
|
||||
(all_mcp_tools, tool_router)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -603,6 +603,7 @@ impl Session {
|
||||
let mut state = self.state.lock().await;
|
||||
match state.session_configuration.clone().apply(&updates) {
|
||||
Ok(next) => {
|
||||
let mcp_inputs_changed = state.session_configuration.mcp_inputs_differ(&next);
|
||||
let previous_permission_profile =
|
||||
state.session_configuration.permission_profile();
|
||||
let next_permission_profile = next.permission_profile();
|
||||
@@ -618,6 +619,9 @@ impl Session {
|
||||
.turn_environments
|
||||
.update_selections(next.environment_selections());
|
||||
}
|
||||
if mcp_inputs_changed {
|
||||
self.mark_mcp_runtime_dirty();
|
||||
}
|
||||
state.session_configuration = next.clone();
|
||||
Ok((
|
||||
next,
|
||||
@@ -652,7 +656,6 @@ impl Session {
|
||||
self.refresh_managed_network_proxy_for_current_permission_profile()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.new_turn_from_configuration(
|
||||
sub_id,
|
||||
@@ -711,13 +714,6 @@ impl Session {
|
||||
.and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok())
|
||||
.unwrap_or_else(|| session_configuration.cwd().clone());
|
||||
let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone());
|
||||
{
|
||||
let mcp_runtime = self.services.latest_mcp_runtime();
|
||||
let mcp_connection_manager = mcp_runtime.manager();
|
||||
mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy);
|
||||
mcp_connection_manager
|
||||
.set_permission_profile(session_configuration.permission_profile());
|
||||
}
|
||||
|
||||
let model_info = self
|
||||
.services
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
@@ -14,13 +15,11 @@ use crate::environment_selection::ThreadEnvironments;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::guardian::GuardianRejectionCircuitBreaker;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::session::McpRuntimeSnapshot;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
use crate::tools::handlers::ToolSearchHandlerCache;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
@@ -30,10 +29,7 @@ use codex_extension_api::ExtensionDataInit;
|
||||
use codex_extension_api::ExtensionRegistry;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_login::AuthManager;
|
||||
use codex_mcp::McpConfig;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
@@ -41,19 +37,12 @@ use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_rollout_trace::ThreadTraceContext;
|
||||
use codex_thread_store::LiveThread;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use std::path::PathBuf;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
/// The single owner of live MCP connections for this thread.
|
||||
pub(crate) mcp_runtime: Arc<McpRuntime>,
|
||||
/// The latest atomically published MCP config and connection snapshot.
|
||||
pub(crate) mcp_runtime_snapshot: ArcSwapOption<McpRuntimeSnapshot>,
|
||||
/// Serializes environment-driven runtime rebuilds.
|
||||
pub(crate) mcp_projection_lock: Mutex<()>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
pub(crate) elicitations: ElicitationService,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
@@ -100,52 +89,3 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) tool_search_handler_cache: ToolSearchHandlerCache,
|
||||
pub(crate) turn_environments: Arc<ThreadEnvironments>,
|
||||
}
|
||||
|
||||
impl SessionServices {
|
||||
/// Publishes the initial connections before validating required servers so startup-time
|
||||
/// elicitation can resolve through the thread runtime while validation waits.
|
||||
pub(crate) async fn install_mcp_runtime(
|
||||
&self,
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
runtime_context: McpRuntimeContext,
|
||||
ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
connections: McpConnectionSet,
|
||||
) -> Result<()> {
|
||||
let runtime = self.publish_mcp_runtime(
|
||||
config,
|
||||
plugins_available,
|
||||
runtime_context,
|
||||
ready_selected_capability_roots,
|
||||
connections,
|
||||
);
|
||||
runtime.manager().validate_required_servers().await
|
||||
}
|
||||
|
||||
pub(crate) fn publish_mcp_runtime(
|
||||
&self,
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
runtime_context: McpRuntimeContext,
|
||||
ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
connections: McpConnectionSet,
|
||||
) -> Arc<McpRuntimeSnapshot> {
|
||||
let connections = self.mcp_runtime.replace(connections);
|
||||
let runtime = Arc::new(McpRuntimeSnapshot::new(
|
||||
config,
|
||||
plugins_available,
|
||||
connections,
|
||||
runtime_context,
|
||||
ready_selected_capability_roots,
|
||||
));
|
||||
self.mcp_runtime_snapshot.store(Some(Arc::clone(&runtime)));
|
||||
runtime
|
||||
}
|
||||
|
||||
pub(crate) fn latest_mcp_runtime(&self) -> Arc<McpRuntimeSnapshot> {
|
||||
let Some(runtime) = self.mcp_runtime_snapshot.load_full() else {
|
||||
unreachable!("MCP runtime must be installed before handling requests");
|
||||
};
|
||||
runtime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use rmcp::model::RequestId;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::agent::control::AgentExecutionGuard;
|
||||
use crate::mcp_tool_call::McpToolApprovalMetadata;
|
||||
use crate::session::TurnInputQueue;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tasks::AnySessionTask;
|
||||
@@ -89,6 +90,7 @@ pub(crate) struct TurnState {
|
||||
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
|
||||
mcp_tool_approval_metadata: HashMap<String, McpToolApprovalMetadata>,
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pub(crate) pending_input: TurnInputQueue,
|
||||
mailbox_delivery_phase: MailboxDeliveryPhase,
|
||||
@@ -126,6 +128,7 @@ impl TurnState {
|
||||
self.pending_request_permissions.clear();
|
||||
self.pending_user_input.clear();
|
||||
self.pending_elicitations.clear();
|
||||
self.mcp_tool_approval_metadata.clear();
|
||||
self.pending_dynamic_tools.clear();
|
||||
}
|
||||
|
||||
@@ -179,6 +182,21 @@ impl TurnState {
|
||||
.remove(&(server_name.to_string(), request_id.clone()))
|
||||
}
|
||||
|
||||
pub(crate) fn insert_mcp_tool_approval_metadata(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
metadata: McpToolApprovalMetadata,
|
||||
) {
|
||||
self.mcp_tool_approval_metadata.insert(call_id, metadata);
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_tool_approval_metadata(
|
||||
&self,
|
||||
call_id: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
self.mcp_tool_approval_metadata.get(call_id).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pending_dynamic_tool(
|
||||
&mut self,
|
||||
key: String,
|
||||
|
||||
@@ -59,7 +59,7 @@ impl ListMcpResourceTemplatesHandler {
|
||||
..
|
||||
} = invocation;
|
||||
let turn = std::sync::Arc::clone(&step_context.turn);
|
||||
let manager = step_context.mcp.manager();
|
||||
let mcp = &step_context.mcp;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
@@ -91,7 +91,7 @@ impl ListMcpResourceTemplatesHandler {
|
||||
let params = cursor
|
||||
.clone()
|
||||
.map(|value| PaginatedRequestParams::default().with_cursor(Some(value)));
|
||||
let result = manager
|
||||
let result = mcp
|
||||
.list_resource_templates(&server_name, params)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -110,7 +110,7 @@ impl ListMcpResourceTemplatesHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let templates = manager
|
||||
let templates = mcp
|
||||
.list_all_resource_templates(|server_name| {
|
||||
model_can_access_mcp_server(turn.as_ref(), server_name)
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ impl ListMcpResourcesHandler {
|
||||
..
|
||||
} = invocation;
|
||||
let turn = std::sync::Arc::clone(&step_context.turn);
|
||||
let manager = step_context.mcp.manager();
|
||||
let mcp = &step_context.mcp;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
@@ -91,7 +91,7 @@ impl ListMcpResourcesHandler {
|
||||
let params = cursor
|
||||
.clone()
|
||||
.map(|value| PaginatedRequestParams::default().with_cursor(Some(value)));
|
||||
let result = manager
|
||||
let result = mcp
|
||||
.list_resources(&server_name, params)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -108,7 +108,7 @@ impl ListMcpResourcesHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let resources = manager
|
||||
let resources = mcp
|
||||
.list_all_resources(|server_name| {
|
||||
model_can_access_mcp_server(turn.as_ref(), server_name)
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ impl ReadMcpResourceHandler {
|
||||
..
|
||||
} = invocation;
|
||||
let turn = std::sync::Arc::clone(&step_context.turn);
|
||||
let manager = step_context.mcp.manager();
|
||||
let mcp = &step_context.mcp;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
@@ -86,7 +86,7 @@ impl ReadMcpResourceHandler {
|
||||
|
||||
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
|
||||
ensure_model_can_access_mcp_server(turn.as_ref(), &server)?;
|
||||
let result = manager
|
||||
let result = mcp
|
||||
.read_resource(&server, ReadResourceRequestParams::new(uri.clone()))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
|
||||
@@ -100,7 +100,7 @@ impl RequestPluginInstallHandler {
|
||||
..
|
||||
} = invocation;
|
||||
let turn = Arc::clone(&step_context.turn);
|
||||
let manager = step_context.mcp.manager();
|
||||
let mcp = &step_context.mcp;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
@@ -230,7 +230,7 @@ impl RequestPluginInstallHandler {
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let completed = if user_confirmed {
|
||||
verify_request_plugin_install_completed(&session, &turn, manager, &tool, auth.as_ref())
|
||||
verify_request_plugin_install_completed(&session, &turn, mcp, &tool, auth.as_ref())
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
@@ -347,16 +347,17 @@ fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool
|
||||
}
|
||||
|
||||
async fn verify_request_plugin_install_completed(
|
||||
session: &crate::session::session::Session,
|
||||
session: &Arc<crate::session::session::Session>,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
manager: &codex_mcp::McpConnectionSet,
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
tool: &DiscoverableTool,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
) -> bool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
manager,
|
||||
mcp,
|
||||
auth,
|
||||
std::slice::from_ref(&connector.id),
|
||||
connector.id.as_str(),
|
||||
@@ -375,8 +376,9 @@ async fn verify_request_plugin_install_completed(
|
||||
plugin.id.as_str(),
|
||||
),
|
||||
refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
manager,
|
||||
mcp,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
plugin.id.as_str(),
|
||||
@@ -398,8 +400,9 @@ async fn verify_request_plugin_install_completed(
|
||||
session.services.plugins_manager.as_ref(),
|
||||
);
|
||||
let _ = refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
manager,
|
||||
mcp,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
plugin.id.as_str(),
|
||||
@@ -440,8 +443,9 @@ fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
|
||||
}
|
||||
|
||||
async fn refresh_missing_requested_connectors(
|
||||
session: &Arc<crate::session::session::Session>,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
manager: &codex_mcp::McpConnectionSet,
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
expected_connector_ids: &[String],
|
||||
tool_id: &str,
|
||||
@@ -450,16 +454,16 @@ async fn refresh_missing_requested_connectors(
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
let mcp_tools = mcp.tools();
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
connectors::accessible_connectors_from_mcp_tools(mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
|
||||
return Some(accessible_connectors);
|
||||
}
|
||||
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
match session.hard_refresh_latest_codex_apps_tools().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::McpRuntimeSnapshot;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -63,7 +62,7 @@ impl ToolRouter {
|
||||
pub(crate) fn from_context(
|
||||
turn_context: &TurnContext,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
mcp: &McpRuntimeSnapshot,
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
params: ToolRouterParams<'_>,
|
||||
tool_search_handler_cache: &ToolSearchHandlerCache,
|
||||
) -> Self {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::session::McpRuntimeSnapshot;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::code_mode::default_exec_yield_time_override_ms;
|
||||
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
|
||||
@@ -143,7 +142,7 @@ impl PlannedTools {
|
||||
struct CoreToolPlanContext<'a> {
|
||||
turn_context: &'a TurnContext,
|
||||
environments: &'a TurnEnvironmentSnapshot,
|
||||
mcp: &'a McpRuntimeSnapshot,
|
||||
mcp: &'a codex_mcp::McpBinding,
|
||||
tool_runtimes: &'a [PlannedRuntime],
|
||||
tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>,
|
||||
extension_tool_executors: &'a [Arc<dyn ToolExecutor<ExtensionToolCall>>],
|
||||
@@ -157,7 +156,7 @@ struct CoreToolPlanContext<'a> {
|
||||
pub(crate) fn build_tool_router(
|
||||
turn_context: &TurnContext,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
mcp: &McpRuntimeSnapshot,
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
params: ToolRouterParams<'_>,
|
||||
tool_search_handler_cache: &ToolSearchHandlerCache,
|
||||
) -> ToolRouter {
|
||||
@@ -175,7 +174,7 @@ pub(crate) fn build_tool_router(
|
||||
fn build_tool_specs_and_registry(
|
||||
turn_context: &TurnContext,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
mcp: &McpRuntimeSnapshot,
|
||||
mcp: &codex_mcp::McpBinding,
|
||||
params: ToolRouterParams<'_>,
|
||||
tool_search_handler_cache: &ToolSearchHandlerCache,
|
||||
) -> (Vec<ToolSpec>, ToolRegistry) {
|
||||
@@ -701,7 +700,7 @@ fn unified_exec_should_include_shell_parameter(
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
|
||||
if context.mcp.manager().has_servers() {
|
||||
if context.mcp.has_servers() {
|
||||
planned_tools.add(ListMcpResourcesHandler);
|
||||
planned_tools.add(ListMcpResourceTemplatesHandler);
|
||||
planned_tools.add(ReadMcpResourceHandler);
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::config::CurrentTimeReminderConfig;
|
||||
use crate::environment_selection::TurnEnvironmentState;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session::tests::mcp_config_for_test;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use crate::tools::handlers::ToolSearchHandlerCache;
|
||||
@@ -681,7 +682,9 @@ async fn environment_tools_follow_the_step_context() {
|
||||
let environments = turn.environments.clone();
|
||||
turn.environments.environments.clear();
|
||||
let turn = Arc::new(turn);
|
||||
let mcp = crate::session::McpRuntimeSnapshot::new_uninitialized_for_test(&turn.config);
|
||||
let mcp = Arc::new(codex_mcp::McpBinding::empty(mcp_config_for_test(
|
||||
&turn.config,
|
||||
)));
|
||||
|
||||
let plan = ToolPlanProbe::from_router(ToolRouter::from_context(
|
||||
turn.as_ref(),
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_models_manager::bundled_models_response;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use wiremock::Mock;
|
||||
@@ -81,10 +82,10 @@ pub enum AppsTestToolLoading {
|
||||
Searchable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
enum AppsTestToolsListBehavior {
|
||||
AlwaysAvailable,
|
||||
AvailableAfterInitialList,
|
||||
AvailableWhen(Arc<AtomicBool>),
|
||||
AlwaysUnavailable,
|
||||
}
|
||||
|
||||
@@ -197,12 +198,13 @@ impl AppsTestServer {
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn mount_with_tools_available_after_initial_list(
|
||||
pub async fn mount_with_tools_available_when(
|
||||
server: &MockServer,
|
||||
tools_available: Arc<AtomicBool>,
|
||||
) -> Result<Self> {
|
||||
Self::mount_with_tools_list_behavior(
|
||||
server,
|
||||
AppsTestToolsListBehavior::AvailableAfterInitialList,
|
||||
AppsTestToolsListBehavior::AvailableWhen(tools_available),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -435,7 +437,6 @@ async fn mount_streamable_http_json_rpc_with_startup_control(
|
||||
searchable,
|
||||
include_app_only_tool,
|
||||
tools_list_behavior,
|
||||
tools_list_calls: AtomicUsize::new(0),
|
||||
initialize_attempts,
|
||||
remaining_initialize_failures,
|
||||
})
|
||||
@@ -449,7 +450,6 @@ struct CodexAppsJsonRpcResponder {
|
||||
searchable: bool,
|
||||
include_app_only_tool: bool,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
tools_list_calls: AtomicUsize,
|
||||
initialize_attempts: Option<Arc<AtomicUsize>>,
|
||||
remaining_initialize_failures: Option<Arc<AtomicUsize>>,
|
||||
}
|
||||
@@ -515,10 +515,11 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
}
|
||||
"notifications/initialized" => ResponseTemplate::new(202),
|
||||
"tools/list" => {
|
||||
let list_index = self.tools_list_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let tools_available = match self.tools_list_behavior {
|
||||
let tools_available = match &self.tools_list_behavior {
|
||||
AppsTestToolsListBehavior::AlwaysAvailable => true,
|
||||
AppsTestToolsListBehavior::AvailableAfterInitialList => list_index > 0,
|
||||
AppsTestToolsListBehavior::AvailableWhen(tools_available) => {
|
||||
tools_available.load(Ordering::SeqCst)
|
||||
}
|
||||
AppsTestToolsListBehavior::AlwaysUnavailable => false,
|
||||
};
|
||||
let id = body.get("id").cloned().unwrap_or(Value::Null);
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::ExternalAuth;
|
||||
@@ -13,18 +12,15 @@ use codex_login::ExternalAuthRefreshContext;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::CodexAppsToolsCache;
|
||||
use codex_mcp::EffectiveMcpServer;
|
||||
use codex_mcp::ElicitationRequestRouter;
|
||||
use codex_mcp::McpConnectionSet;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_mcp::McpToolCatalogCache;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -83,34 +79,34 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
EffectiveMcpServer::configured(hosted_plugin_runtime_config),
|
||||
)]);
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
let manager = McpConnectionSet::new(
|
||||
&mcp_servers,
|
||||
OAuthCredentialsStoreMode::default(),
|
||||
AuthKeyringBackendKind::default(),
|
||||
&approval_policy,
|
||||
"test".to_string(),
|
||||
/*tx_event*/ None,
|
||||
CancellationToken::new(),
|
||||
PermissionProfile::default(),
|
||||
McpRuntimeContext::new(
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
let plugins_manager = codex_core_plugins::PluginsManager::new(home.path().to_path_buf());
|
||||
let mcp_config = Arc::new(config.to_mcp_config(&plugins_manager).await);
|
||||
let runtime = McpRuntime::new(McpRuntimeInput {
|
||||
config: mcp_config,
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
mcp_servers,
|
||||
submit_id: "test".to_string(),
|
||||
tx_event: None,
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
runtime_context: McpRuntimeContext::new(
|
||||
Arc::new(codex_exec_server::EnvironmentManager::without_environments()),
|
||||
home.path().to_path_buf(),
|
||||
),
|
||||
home.path().to_path_buf(),
|
||||
CodexAppsToolsCache::default(),
|
||||
McpToolCatalogCache::default(),
|
||||
codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
Some(&expected_auth),
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
)
|
||||
codex_apps_tools_cache: CodexAppsToolsCache::default(),
|
||||
tool_catalog_cache: McpToolCatalogCache::default(),
|
||||
codex_apps_tools_cache_key: codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
|
||||
supports_openai_form_elicitation: false,
|
||||
auth: Some(expected_auth.clone()),
|
||||
codex_apps_auth_manager: Some(Arc::clone(&auth_manager)),
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
.await;
|
||||
// The model-provider test covers AuthManager reload behavior. Keep this
|
||||
// regression focused on core MCP wiring by updating the same shared
|
||||
@@ -128,8 +124,8 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token(
|
||||
// The manager and its static fallback were created before the auth update,
|
||||
// so this tool call only sees the new token if the Codex Apps provider
|
||||
// reads the shared AuthManager at request time.
|
||||
let tool_result = manager
|
||||
.call_tool(
|
||||
let tool_result = runtime
|
||||
.latest_call_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some(json!({
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_config::Constrained;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
@@ -107,6 +108,11 @@ async fn regular_mcp_definition_cache_preserves_live_session_state() -> anyhow::
|
||||
let fixture = test_codex()
|
||||
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
|
||||
.with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::Disabled)
|
||||
.expect("test config should allow disabled permissions");
|
||||
let app_only_cwd_marker_file = config.cwd.join("cwd-app-only");
|
||||
let barrier_file = config.cwd.join("allow-initialize");
|
||||
let pid_file = config.cwd.join("mcp.pid");
|
||||
@@ -242,31 +248,30 @@ async fn regular_mcp_definition_cache_preserves_live_session_state() -> anyhow::
|
||||
.await;
|
||||
anyhow::Ok(called_process)
|
||||
});
|
||||
fixture.codex.shutdown_and_wait().await?;
|
||||
fs.write_file(&barrier_file, b"ready".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while cached_response.requests().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("cached MCP definitions should reach inference before initialization")?;
|
||||
.context("live MCP definitions should reach inference after initialization")?;
|
||||
assert_definition(
|
||||
&cached_response,
|
||||
&format!("Tools in the {NAMESPACE} namespace."),
|
||||
&format!("Echo from {first_process}."),
|
||||
&format!("Use the tools from {second_process}."),
|
||||
&format!("Echo from {second_process}."),
|
||||
);
|
||||
|
||||
fixture.codex.shutdown_and_wait().await?;
|
||||
fs.write_file(&barrier_file, b"ready".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
let expected_error = format!("MCP tool `{SERVER_NAME}/cwd` is not available to the model");
|
||||
assert_eq!(cached_turn.await??, second_process);
|
||||
let output = cached_done_response
|
||||
.single_request()
|
||||
.function_call_output_text(app_only_call_id)
|
||||
.expect("app-only tool error should be returned to the model");
|
||||
assert!(
|
||||
output.contains(&expected_error),
|
||||
"model-visible tool output should contain the live visibility error: {output}"
|
||||
output.contains("is not available to the model") || output.contains("unsupported call"),
|
||||
"app-only tools must be rejected before reaching the MCP server: {output}"
|
||||
);
|
||||
let output = cached_done_response
|
||||
.single_request()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::Constrained;
|
||||
use codex_core::config::Config;
|
||||
use codex_extension_api::ExtensionFuture;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
@@ -10,8 +8,9 @@ use codex_extension_api::ThreadStartInput;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
@@ -31,8 +30,8 @@ use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -63,17 +62,8 @@ impl ThreadLifecycleContributor<Config> for McpResourceClientCapture {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn session_resource_client_follows_published_mcp_runtime() -> Result<()> {
|
||||
async fn out_of_band_resource_read_reconciles_the_published_mcp_runtime() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let response = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let captured_client = Arc::new(Mutex::new(None));
|
||||
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||
@@ -91,50 +81,37 @@ async fn session_resource_client_follows_published_mcp_runtime() -> Result<()> {
|
||||
.expect("thread start should capture the MCP resource client");
|
||||
assert!(!resource_client.has_server("refreshed").await);
|
||||
|
||||
let refreshed_server = McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: format!("{}/mcp", server.uri()),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
auth: Default::default(),
|
||||
environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_millis(100)),
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
test.codex
|
||||
.submit(Op::RefreshMcpServers {
|
||||
config: McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(HashMap::from([(
|
||||
"refreshed".to_string(),
|
||||
refreshed_server,
|
||||
)]))?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
test.config.mcp_oauth_credentials_store_mode,
|
||||
)?,
|
||||
auth_keyring_backend_kind: serde_json::to_value(
|
||||
test.config.auth_keyring_backend_kind(),
|
||||
)?,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
test.submit_turn("observe the refreshed MCP runtime")
|
||||
.await?;
|
||||
let mut refresh_config = test.config.clone();
|
||||
let user_config_path = refresh_config.codex_home.join("config.toml");
|
||||
let user_config: toml::Value = toml::from_str(&format!(
|
||||
r#"
|
||||
[mcp_servers.refreshed]
|
||||
url = "{}/mcp"
|
||||
startup_timeout_sec = 0.1
|
||||
"#,
|
||||
server.uri()
|
||||
))?;
|
||||
let refreshed_servers = user_config
|
||||
.get("mcp_servers")
|
||||
.cloned()
|
||||
.map(HashMap::<String, codex_config::types::McpServerConfig>::deserialize)
|
||||
.transpose()?
|
||||
.expect("test config should define MCP servers");
|
||||
refresh_config
|
||||
.mcp_servers
|
||||
.set(refreshed_servers)
|
||||
.expect("test config should allow MCP servers");
|
||||
refresh_config.config_layer_stack = refresh_config
|
||||
.config_layer_stack
|
||||
.with_user_config(&user_config_path, user_config)?;
|
||||
test.codex.refresh_runtime_config(refresh_config).await;
|
||||
test.codex.submit(Op::RefreshMcpServers).await?;
|
||||
|
||||
let _ = test
|
||||
.codex
|
||||
.read_mcp_resource("refreshed", "test://resource")
|
||||
.await;
|
||||
assert!(resource_client.has_server("refreshed").await);
|
||||
response.single_request();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -206,14 +183,14 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Result<()> {
|
||||
async fn apps_guidance_appears_after_recovery_between_sampling_requests() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let (apps_server, startup_control) =
|
||||
AppsTestServer::mount_with_startup_control(&server).await?;
|
||||
// Initial context is rendered twice before the first request, so keep both reads unavailable.
|
||||
startup_control.fail_next_initialize_attempts(/*attempts*/ 2);
|
||||
// The exact step is captured once before the first request; its background retry can recover.
|
||||
startup_control.fail_next_initialize_attempts(/*attempts*/ 1);
|
||||
let call_id = "pause-for-apps";
|
||||
let response = mount_sse_sequence(
|
||||
&server,
|
||||
@@ -257,7 +234,6 @@ async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Resu
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let mcp_runtime = test.codex.current_mcp_runtime().await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
@@ -271,11 +247,33 @@ async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Resu
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::RequestUserInput(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
let request = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
let mut request = None;
|
||||
let mut apps_ready = false;
|
||||
while request.is_none() || !apps_ready {
|
||||
let event = test
|
||||
.codex
|
||||
.next_event()
|
||||
.await
|
||||
.expect("event stream should stay open");
|
||||
match event.msg {
|
||||
EventMsg::RequestUserInput(next_request) => request = Some(next_request),
|
||||
EventMsg::McpStartupUpdate(update)
|
||||
if update.server == CODEX_APPS_MCP_SERVER_NAME
|
||||
&& matches!(
|
||||
update.status,
|
||||
codex_protocol::protocol::McpStartupStatus::Ready
|
||||
) =>
|
||||
{
|
||||
apps_ready = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
request.expect("request user input event")
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.expect("Apps should recover before the second sampling request");
|
||||
|
||||
let initial_requests = response.requests();
|
||||
assert_eq!(initial_requests.len(), 1);
|
||||
@@ -289,16 +287,6 @@ async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Resu
|
||||
0
|
||||
);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if !mcp_runtime.manager().list_all_tools().await.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Apps MCP should recover while the turn is paused");
|
||||
test.codex
|
||||
.submit(Op::UserInputAnswer {
|
||||
id: request.turn_id,
|
||||
@@ -363,6 +351,11 @@ async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup
|
||||
|
||||
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
|
||||
.with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::Disabled)
|
||||
.expect("test config should allow disabled permissions");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeModeOnly)
|
||||
@@ -388,24 +381,10 @@ async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup
|
||||
|
||||
tokio::fs::remove_dir_all(test.codex_home_path().join("cache/codex_apps_tools")).await?;
|
||||
startup_control.fail_next_initialize_attempts(/*attempts*/ 1);
|
||||
let runtime_mcp_config = test.codex.runtime_mcp_config(&test.config).await;
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(codex_mcp::configured_mcp_servers(&runtime_mcp_config))?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
runtime_mcp_config.mcp_oauth_credentials_store_mode,
|
||||
)?,
|
||||
auth_keyring_backend_kind: serde_json::to_value(
|
||||
runtime_mcp_config.auth_keyring_backend_kind,
|
||||
)?,
|
||||
};
|
||||
test.codex
|
||||
.submit(Op::RefreshMcpServers {
|
||||
config: refresh_config,
|
||||
})
|
||||
.await?;
|
||||
test.codex.submit(Op::RefreshMcpServers).await?;
|
||||
test.submit_turn("use Calendar after transient Apps startup failures")
|
||||
.await?;
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while startup_control.initialize_attempts() < 3 {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use wiremock::Mock;
|
||||
@@ -137,7 +140,13 @@ async fn build_test(
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config({
|
||||
let apps_base_url = apps_server.chatgpt_base_url.clone();
|
||||
move |config| configure_apps_without_search_tool(config, apps_base_url.as_str())
|
||||
move |config| {
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::Disabled)
|
||||
.expect("test config should allow disabled permissions");
|
||||
configure_apps_without_search_tool(config, apps_base_url.as_str());
|
||||
}
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
@@ -550,9 +559,11 @@ async fn remote_plugin_install_refreshes_plugin_and_apps_tool_caches() -> Result
|
||||
|
||||
async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTools) -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let tools_available = Arc::new(AtomicBool::new(false));
|
||||
let apps_server = match refreshed_tools {
|
||||
RefreshedAppsTools::Available => {
|
||||
AppsTestServer::mount_with_tools_available_after_initial_list(&server).await?
|
||||
AppsTestServer::mount_with_tools_available_when(&server, Arc::clone(&tools_available))
|
||||
.await?
|
||||
}
|
||||
RefreshedAppsTools::Missing => AppsTestServer::mount_without_tools(&server).await?,
|
||||
};
|
||||
@@ -589,6 +600,10 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
|
||||
let elicitation = start_install_turn(&test, "use Calendar").await?;
|
||||
mount_remote_calendar_installed_plugins(&server).await;
|
||||
drop(initial_remote_installed_plugins);
|
||||
tools_available.store(
|
||||
matches!(refreshed_tools, RefreshedAppsTools::Available),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Accept).await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
|
||||
@@ -24,6 +24,7 @@ use codex_extension_api::TurnInputContext;
|
||||
use codex_extension_api::TurnInputContributor;
|
||||
use codex_extension_api::WorldStateContributionInput;
|
||||
use codex_extension_api::WorldStateSectionContribution;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_otel::MetricsClient;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::Event;
|
||||
@@ -306,7 +307,7 @@ where
|
||||
include_host_skills: !host_catalog_in_world_state,
|
||||
include_bundled_skills: config.bundled_skills_enabled,
|
||||
include_orchestrator_skills: thread_state.orchestrator_skills_enabled(),
|
||||
mcp_resources,
|
||||
mcp_resources: mcp_resources.clone(),
|
||||
executor_capability_discovery: None,
|
||||
};
|
||||
let host_query = query.clone();
|
||||
@@ -366,7 +367,12 @@ where
|
||||
let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default();
|
||||
for entry in &selected_entries {
|
||||
match self
|
||||
.read_main_prompt(entry, host_snapshot.clone(), session_store, &thread_state)
|
||||
.read_main_prompt(
|
||||
entry,
|
||||
host_snapshot.clone(),
|
||||
mcp_resources.clone(),
|
||||
&thread_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(read_result) => {
|
||||
@@ -466,7 +472,7 @@ impl<C> SkillsExtension<C> {
|
||||
&self,
|
||||
entry: &SkillCatalogEntry,
|
||||
host_snapshot: Option<Arc<HostSkillsSnapshot>>,
|
||||
session_store: &ExtensionData,
|
||||
mcp_resources: Option<Arc<McpResourceClient>>,
|
||||
thread_state: &SkillsThreadState,
|
||||
) -> Result<SkillReadResult, String> {
|
||||
thread_state
|
||||
@@ -477,9 +483,7 @@ impl<C> SkillsExtension<C> {
|
||||
package: entry.id.clone(),
|
||||
resource: entry.main_prompt.clone(),
|
||||
host_snapshot,
|
||||
mcp_resources: session_store
|
||||
.get::<SkillsSessionState>()
|
||||
.and_then(|state| state.mcp_resources.clone()),
|
||||
mcp_resources,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -192,12 +192,10 @@ pub struct W3cTraceContext {
|
||||
pub tracestate: Option<String>,
|
||||
}
|
||||
|
||||
/// Config payload for refreshing MCP servers.
|
||||
/// Resolved MCP inputs to apply through a thread's submission queue.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct McpServerRefreshConfig {
|
||||
/// Complete runtime server map after source and thread-scoped resolution.
|
||||
pub mcp_servers: Value,
|
||||
/// OAuth credential store mode to use with this server snapshot.
|
||||
pub mcp_oauth_credentials_store_mode: Value,
|
||||
pub auth_keyring_backend_kind: Value,
|
||||
}
|
||||
@@ -641,7 +639,10 @@ pub enum Op {
|
||||
},
|
||||
|
||||
/// Request MCP servers to reinitialize and refresh cached tool lists.
|
||||
RefreshMcpServers { config: McpServerRefreshConfig },
|
||||
RefreshMcpServers,
|
||||
|
||||
/// Replace the thread's resolved MCP configuration before its next turn.
|
||||
ReloadMcpConfig { config: McpServerRefreshConfig },
|
||||
|
||||
/// Reload user config layer overrides for the active session.
|
||||
///
|
||||
@@ -882,7 +883,8 @@ impl Op {
|
||||
Self::UserInputAnswer { .. } => "user_input_answer",
|
||||
Self::RequestPermissionsResponse { .. } => "request_permissions_response",
|
||||
Self::DynamicToolResponse { .. } => "dynamic_tool_response",
|
||||
Self::RefreshMcpServers { .. } => "refresh_mcp_servers",
|
||||
Self::RefreshMcpServers => "refresh_mcp_servers",
|
||||
Self::ReloadMcpConfig { .. } => "reload_mcp_config",
|
||||
Self::ReloadUserConfig => "reload_user_config",
|
||||
Self::Compact => "compact",
|
||||
Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode",
|
||||
|
||||
Reference in New Issue
Block a user