Preserve session MCP config on refresh (#21055)

# Overview
MCP refreshes were rebuilding active threads from fresh disk-backed
config only, which dropped thread-start session overlays such as
app-injected MCP servers. This keeps refreshes current with disk config
while preserving the thread-local config that only the active thread
knows about.

# Changes
- Rebuild refreshed config per active thread using that thread's current
`cwd`, rather than fanning out one app-server config to every thread.
- Preserve each thread's `SessionFlags` layer while replacing reloadable
config layers with freshly loaded config, then derive the MCP refresh
payload from the rebuilt result.
- Move MCP refresh orchestration into app-server so manual refreshes
fail loudly while background refreshes remain best-effort, and route
plugin-triggered refreshes through the same per-thread reload path.
- Add regression coverage for session overlays, fresh project config,
plugin-derived MCP config, current requirements, and strict vs
best-effort refresh behavior.

# Verification
- Passed focused Rust coverage for the thread-config rebuild behavior
and deferred MCP refresh flow, plus `cargo test -p codex-app-server
--lib`.
- Verified end to end in the Codex dev app against the locally built
CLI: registered an MCP via thread config, verified that it could be used
successfully before refresh, manually triggered MCP refresh, and
verified that it continued to be available afterward.
This commit is contained in:
aaronl-openai
2026-05-05 21:09:28 -07:00
committed by GitHub
parent 8ef31894dc
commit 9f06d171e2
11 changed files with 652 additions and 93 deletions

View File

@@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::RequirementSource;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
@@ -2845,6 +2846,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
);
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let project_dot_codex =
AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path());
let mcp_requirements = BTreeMap::from([
(
"session_overrides_user".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "session-command".to_string(),
},
},
),
(
"managed_overrides_session".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "managed-command".to_string(),
},
},
),
(
"fresh_global".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "fresh-global-command".to_string(),
},
},
),
(
"fresh_project".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "fresh-project-command".to_string(),
},
},
),
]);
let requirements_toml = codex_config::ConfigRequirementsToml {
mcp_servers: Some(mcp_requirements.clone()),
..Default::default()
};
let requirements = codex_config::ConfigRequirements {
mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)),
..Default::default()
};
let refreshed_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[mcp_servers.session_overrides_user]
command = "new-user-command"
[mcp_servers.managed_overrides_session]
command = "new-user-command"
[mcp_servers.fresh_global]
command = "fresh-global-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex.clone(),
},
toml::toml! {
[mcp_servers.fresh_project]
command = "fresh-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
toml::toml! {
[mcp_servers.managed_overrides_session]
command = "managed-command"
}
.into(),
),
],
requirements,
requirements_toml,
)
.map_err(std::io::Error::other)?;
let refreshed_toml = refreshed_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let refreshed_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
refreshed_toml,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
refreshed_layer_stack,
)
.await?;
let thread_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[mcp_servers.session_overrides_user]
command = "old-user-command"
[mcp_servers.managed_overrides_session]
command = "old-user-command"
[mcp_servers.fresh_global]
command = "old-global-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex,
},
toml::toml! {
[mcp_servers.fresh_project]
command = "old-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::SessionFlags,
toml::toml! {
[mcp_servers.session_overrides_user]
command = "session-command"
[mcp_servers.managed_overrides_session]
command = "session-command"
[mcp_servers.blocked_session]
command = "blocked-session-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
toml::toml! {
[mcp_servers.managed_overrides_session]
command = "old-managed-command"
}
.into(),
),
],
Default::default(),
Default::default(),
)
.map_err(std::io::Error::other)?;
let thread_toml = thread_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_toml,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
assert_eq!(
config.mcp_servers.get(),
&HashMap::from([
(
"session_overrides_user".to_string(),
stdio_mcp("session-command"),
),
(
"managed_overrides_session".to_string(),
stdio_mcp("managed-command"),
),
(
"fresh_global".to_string(),
stdio_mcp("fresh-global-command"),
),
(
"fresh_project".to_string(),
stdio_mcp("fresh-project-command"),
),
(
"blocked_session".to_string(),
McpServerConfig {
enabled: false,
disabled_reason: Some(McpServerDisabledReason::Requirements {
source: RequirementSource::Unknown,
}),
..stdio_mcp("blocked-session-command")
},
),
])
);
Ok(())
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let refreshed_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[features]
plugins = true
[plugins."sample@test"]
enabled = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let refreshed_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
refreshed_layer_stack.effective_config().try_into()?,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
refreshed_layer_stack,
)
.await?;
let thread_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User { file: user_file },
toml::toml! {
[features]
plugins = false
[plugins."sample@test"]
enabled = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_layer_stack.effective_config().try_into()?,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://sample.example/mcp"))
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -1130,6 +1130,62 @@ impl Config {
}
}
pub async fn rebuild_preserving_session_layers(
&self,
refreshed_config: &Config,
) -> std::io::Result<Self> {
let mut layers = refreshed_config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| !is_session_layer(&layer.name))
.cloned()
.collect::<Vec<_>>();
layers.extend(
self.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| is_session_layer(&layer.name))
.cloned(),
);
layers.sort_by_key(|layer| layer.name.precedence());
let config_layer_stack = ConfigLayerStack::new(
layers,
refreshed_config.config_layer_stack.requirements().clone(),
refreshed_config
.config_layer_stack
.requirements_toml()
.clone(),
)?
.with_user_and_project_exec_policy_rules_ignored(
refreshed_config
.config_layer_stack
.ignore_user_and_project_exec_policy_rules(),
);
let cfg: ConfigToml = config_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
Self::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(self.cwd.to_path_buf()),
..Default::default()
},
refreshed_config.codex_home.clone(),
config_layer_stack,
)
.await
}
/// This is the preferred way to create an instance of [Config].
pub async fn load_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
@@ -1680,6 +1736,10 @@ fn thread_store_config(
}
}
fn is_session_layer(source: &ConfigLayerSource) -> bool {
matches!(source, ConfigLayerSource::SessionFlags)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,

View File

@@ -42,7 +42,6 @@ use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
@@ -526,27 +525,6 @@ impl ThreadManager {
self.state.list_thread_ids().await
}
pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) {
let threads = self
.state
.threads
.read()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for thread in threads {
if let Err(err) = thread
.submit(Op::RefreshMcpServers {
config: refresh_config.clone(),
})
.await
{
warn!("failed to request MCP server refresh: {err}");
}
}
}
pub fn subscribe_thread_created(&self) -> broadcast::Receiver<ThreadId> {
self.state.thread_created_tx.subscribe()
}