Preserve refreshed Apps tools across MCP runtime updates (#35028)

## Why

After a remote plugin install refreshes the Apps tool catalog, a later MCP
runtime publication must not restore the catalog from the previous connection.

## What changed

- Rebuild the MCP runtime with fresh connections when hard-refreshing Apps
  tools, using the latest desired runtime configuration.
- Refresh the Apps catalog on the newly published connection so subsequent
  runtime updates retain it.

## Testing

Extend the remote plugin install test to verify that both newly available and
missing Apps tools remain consistent after an unrelated runtime configuration
refresh.

GitOrigin-RevId: 5b675d53d56379ab67fab09512fc2ad0ffcb8535
This commit is contained in:
jif
2026-07-23 23:04:17 +00:00
committed by copyberry
parent 1ee8f49175
commit 091e4a5d7c
4 changed files with 70 additions and 7 deletions

View File

@@ -141,13 +141,24 @@ impl McpRuntime {
/// Reconciles configured servers and publishes their immutable runtime snapshot.
pub async fn replace(&self, input: McpRuntimeInput) {
let current = self.current.load_full();
self.publish(input, Some(current.connections.as_ref()))
.await;
}
/// Starts fresh connections and returns their complete, refreshed Apps catalog.
pub async fn replace_fresh(&self, input: McpRuntimeInput) -> anyhow::Result<Vec<ToolInfo>> {
self.publish(input, /*previous*/ None).await;
self.latest_hard_refresh_codex_apps_tools_cache().await
}
async fn publish(&self, input: McpRuntimeInput, previous: Option<&McpConnectionSet>) {
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(
McpConnectionSet::new(
Some(current.connections.as_ref()),
previous,
publication_gate,
input,
self.elicitation_router.clone(),

View File

@@ -3,6 +3,7 @@ use codex_exec_server::ExecutorCapabilityDiscoveryCache;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ElicitationReviewRequest;
use codex_mcp::ElicitationReviewer;
use codex_mcp::ElicitationReviewerHandle;
@@ -202,7 +203,7 @@ impl Session {
}
}
/// Refreshes the future Apps catalog without making an exact step a refresh owner.
/// Reconnects the runtime so refreshed Apps tools belong to their new exact client.
pub(crate) async fn hard_refresh_latest_codex_apps_tools(
self: &Arc<Self>,
) -> anyhow::Result<Vec<codex_mcp::ToolInfo>> {
@@ -212,10 +213,43 @@ impl Session {
.acquire()
.await
.map_err(|_| anyhow::anyhow!("MCP runtime refresh semaphore closed"))?;
self.services
.mcp_runtime
.latest_hard_refresh_codex_apps_tools_cache()
.await
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;
let input = self
.build_mcp_runtime_input(
&desired,
mcp_projection,
&ready_selected_capability_roots,
Some(self.mcp_elicitation_reviewer()),
)
.await;
anyhow::ensure!(
input.mcp_servers.contains_key(CODEX_APPS_MCP_SERVER_NAME),
"unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"
);
self.services.mcp_runtime.replace_fresh(input).await
}
pub(super) fn mark_mcp_runtime_dirty(&self) {

View File

@@ -100,7 +100,7 @@ impl Session {
self.services.mcp_runtime.replace(input).await;
}
async fn build_mcp_runtime_input(
pub(super) async fn build_mcp_runtime_input(
&self,
desired: &McpDesiredState,
mcp_projection: McpRuntimeProjection,

View File

@@ -592,6 +592,11 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-2", "catalog still current"),
ev_completed("resp-3"),
]),
],
)
.await;
@@ -644,6 +649,19 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"the refreshed installed-plugin cache should filter the cached recommendation"
);
drop(requests);
test.codex.refresh_runtime_config(test.config.clone()).await;
test.submit_turn("check whether Calendar is still installed")
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 3);
assert_eq!(
requests[2]
.tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL)
.is_some(),
completed,
"an unrelated runtime publication must retain the refreshed Apps catalog"
);
Ok(())
}