Expose cached MCP tools before server startup (#35590)

## Why

Cached MCP definitions can be supplied to inference without waiting for the
server to finish initializing.

## What changed

- Publish cached tools while startup is still in progress, clearing their
  potentially stale read-only hint.
- Wait for the selected server to start before executing a tool call, then
  prepare the call against the refreshed live binding.
- Keep cached tools visible in a binding even when no live client is available,
  while rejecting attempts to prepare those calls.

## Testing

- Cover cached-tool visibility before startup and replacement with live tool
  metadata afterward.
- Verify cached definitions reach inference before MCP initialization and that
  calls unavailable in the live catalog return the expected model-visible
  error.

GitOrigin-RevId: 3aae8f474c344ccdc5e08fe321bbad21d85bffd1
This commit is contained in:
jif authored and copyberry committed 2026-07-27 10:25:32 +00:00
1 parent 95637f7056
commit 3bbf1fe757
6 files changed
+89 -20

No files matched your search

@@ -539,6 +539,13 @@ impl McpConnectionSet {
self.servers.contains_key(server_name)
}
pub(crate) async fn wait_for_server_startup(&self, server_name: &str) -> bool {
let Some(view) = self.servers.get(server_name) else {
return false;
};
view.connection.client().await.is_ok()
}
/// Stop all MCP clients owned by this manager and terminate stdio server processes.
pub async fn shutdown(&self) {
let connections = self
@@ -143,6 +143,19 @@ impl McpConnectionSet {
.startup_complete
.load(Ordering::Acquire)
{
if view.connection.client.has_cached_tools() {
if let Some(server_tools) =
view.listed_tools(&self.tool_plugin_provenance).await
{
listed_tools.extend(server_tools.into_iter().map(|mut tool| {
if let Some(annotations) = tool.tool.annotations.as_mut() {
annotations.read_only_hint = None;
}
Self::with_server_metadata(tool, &view.metadata)
}));
}
continue;
}
let _ = view.connection.client.client().await;
}
view.connection.client.reconnect_failed_startup().await;
@@ -186,6 +199,7 @@ impl McpConnectionSet {
continue;
}
let Some(client) = clients.client(&tool_info.server_name) else {
tools.push(tool_info);
continue;
};
let Some(call) = self.prepare_call(&tool_info, client, Arc::clone(&config), *revision)
@@ -1513,20 +1513,21 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() {
}
#[tokio::test]
async fn capture_binding_waits_for_fresh_startup_even_with_cached_tools() {
async fn capture_binding_exposes_cached_tools_before_startup() {
let codex_home = tempdir().expect("tempdir");
let cache_context = create_codex_apps_tools_cache_context(
codex_home.path().to_path_buf(),
Some("account-one"),
Some("user-one"),
);
store_current_tools(
&cache_context,
vec![create_test_tool(
CODEX_APPS_MCP_SERVER_NAME,
"shared_cached_tool",
)],
let mut cached_tool = create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "shared_cached_tool");
cached_tool.tool.annotations = Some(
rmcp::model::ToolAnnotations::new()
.read_only(true)
.destructive(false)
.open_world(false),
);
store_current_tools(&cache_context, vec![cached_tool]);
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(false));
let startup_complete_for_client = Arc::clone(&startup_complete);
let (startup_started, wait_for_startup) = tokio::sync::oneshot::channel();
@@ -1575,14 +1576,41 @@ async fn capture_binding_waits_for_fresh_startup_even_with_cached_tools() {
},
);
let manager = Arc::new(manager);
let manager_for_capture = Arc::clone(&manager);
let capture = tokio::spawn(async move { capture_binding(&manager_for_capture).await });
let cached_binding = capture_binding(&manager).await;
assert_eq!(
cached_binding
.tools()
.iter()
.map(|tool| tool.callable_name.as_str())
.collect::<Vec<_>>(),
vec!["shared_cached_tool"]
);
assert_eq!(
cached_binding.tools()[0].tool.annotations,
Some(
rmcp::model::ToolAnnotations::new()
.destructive(false)
.open_world(false)
)
);
assert!(
cached_binding
.prepare_call(CODEX_APPS_MCP_SERVER_NAME, "shared_cached_tool")
.is_none()
);
let manager_for_startup = Arc::clone(&manager);
let startup = tokio::spawn(async move {
manager_for_startup
.wait_for_server_startup(CODEX_APPS_MCP_SERVER_NAME)
.await
});
wait_for_startup.await.expect("client startup should begin");
assert!(!capture.is_finished());
release_startup.send(()).expect("release client startup");
assert!(startup.await.expect("startup task"));
let step = capture.await.expect("capture task");
let step = capture_binding(&manager).await;
assert_eq!(
step.tools()
.iter()
+15
View File
@@ -240,6 +240,21 @@ impl McpRuntime {
}
}
/// Captures the current runtime after its selected server has finished startup.
pub async fn current_binding_for_call(&self, server: &str) -> Option<Arc<McpBinding>> {
let current = self.current.load_full();
let config = Arc::clone(current.config.as_ref()?);
if !current.connections.wait_for_server_startup(server).await {
return None;
}
Some(Arc::new(
current
.connections
.capture_binding_with_metadata(config, current.plugins_available)
.await,
))
}
/// Returns the latest published configuration without waiting for clients.
pub fn current_config(&self) -> Option<Arc<McpConfig>> {
self.current.load().config.clone()