Start dormant MCP servers for hook calls (#55263)

## Why

MCP tool hooks need to invoke model-hidden tools even when a subagent's cached server has not started. Their timeout must also cover server startup.

## What changed

- Let cached servers with allowed model-hidden tools remain dormant until called.
- Wait for the selected server when executing an MCP hook, leaving unrelated cached servers dormant, and apply the hook timeout to the entire operation.
- Capture tool namespace attribution after startup and retain it only while catalog revisions remain unchanged.
- Validate the server, environment, and tool filter before starting a server or capturing attribution.

## Testing

Extend integration coverage for cached subagent hooks under both startup readiness modes, startup timeouts without context injection, and namespace attribution for hook and token-budget context while keeping tools hidden from the model. Verify that a direct call starts a cached server whose tools are hidden from model exposure.

GitOrigin-RevId: 7a54b69aa2515722aeb925e03e1667bf63aa67ca
This commit is contained in:
Charlie Marsh
2026-10-19 11:40:15 +00:00
committed by copyberry
parent 71ab2ea77b
commit cc44ffb3e2
6 changed files with 413 additions and 52 deletions
+34 -10
View File
@@ -657,9 +657,9 @@ impl McpRuntime {
.await
}
/// Calls a tool for the host, using a cached namespace only if its catalogs stay unchanged.
/// Missing or changed catalogs leave the namespace unknown. Attribution never starts
/// servers or refreshes their catalogs.
/// Calls a tool for the host, retaining attribution while its catalogs stay unchanged.
/// Waiting calls capture a binding after the selected server starts; non-waiting calls
/// use the cached binding. Missing or changed catalogs leave the namespace unknown.
#[allow(clippy::too_many_arguments)]
pub async fn latest_call_tool(
&self,
@@ -672,12 +672,36 @@ impl McpRuntime {
wait_for_server: bool,
) -> anyhow::Result<McpToolCallResult> {
let current = self.current.load_full();
let cached = current
.cached_binding
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|cached| Some((cached.catalog_revisions.clone(), cached.binding.upgrade()?)));
current
.connections
.validate_tool_call(server, tool, environment_id)?;
let attribution = if wait_for_server
&& current.connections.wait_for_server_startup(server).await
&& let Some(revisions) = current
.connections
.stable_catalog_revisions(
/*required_servers*/ &[],
/*required_plugins*/ &HashSet::new(),
)
.await
{
Self::binding_from_published_runtime(
Arc::clone(&current),
/*required_servers*/ &[],
/*required_plugins*/ &HashSet::new(),
)
.await
.map(|binding| (revisions, binding))
} else {
current
.cached_binding
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|cached| {
Some((cached.catalog_revisions.clone(), cached.binding.upgrade()?))
})
};
let result = current
.connections
.call_tool(
@@ -694,7 +718,7 @@ impl McpRuntime {
// Check after dispatch so a refresh before or during the call invalidates attribution.
// Revisions include client identity and increase on catalog changes. Reading them
// does not start servers or refresh catalogs; all catalogs matter for name collisions.
let namespace = if let Some((revisions, binding)) = cached
let namespace = if let Some((revisions, binding)) = attribution
&& current
.connections
.stable_catalog_revisions(