Add opt-in protocol discovery for local stdio MCP servers (#57210)

## Why

Stdio servers without `CODEX_MCP_PROTOCOL_VERSION` use the legacy handshake, even when they support MCP `2026-07-28`.

## What changed

- Add the disabled-by-default `mcp_connection_sharing` feature to enable discovery for eligible local stdio servers.
- Recover from failed or late discovery with a fresh handshake when needed, waiting for the previous helper to release resources before restarting.
- Retain a working legacy connection when an upgrade attempt fails for the same account, except on authentication errors.
- Keep tool catalogs separate by protocol and invalidate legacy catalogs when discovery negotiates the modern protocol.

## Testing

Add regression coverage for failed and late discovery, legacy fallback, protocol-specific catalog reuse, message limits, and Windows descendant cleanup.

GitOrigin-RevId: 36c675ef3b81d32fccf06ccbddda2312cfe602c9
This commit is contained in:
Charlie Marsh
2026-10-26 19:37:06 +00:00
committed by copyberry
parent 33b16a9306
commit 0bc73bbeb1
30 changed files with 1645 additions and 316 deletions
+28 -6
View File
@@ -184,6 +184,7 @@ impl McpToolCatalogCacheContext {
cached_deadline.deadline
}
#[cfg(test)]
pub(crate) fn current_tools(&self) -> Option<Vec<ToolInfo>> {
self.current_tools_or(/*fallback*/ None)
}
@@ -235,25 +236,28 @@ impl McpToolCatalogCacheContext {
state.snapshot = None;
}
/// An observed protocol change invalidates a legacy catalog, including any older fetch in flight.
pub(crate) fn invalidate_if_newest(&self, ticket: &McpToolCatalogFetchTicket) {
if let Some(mut state) = self.accept_generation(ticket.generation) {
state.snapshot = None;
}
}
pub(crate) fn publish_if_newest(
&self,
ticket: McpToolCatalogFetchTicket,
tools: &[ToolInfo],
meta: Option<Arc<serde_json::Value>>,
) {
let mut state = lock_unpoisoned(&self.entry.state);
if state.disabled_by_server || ticket.generation <= state.last_accepted_generation {
let Some(mut state) = self.accept_generation(ticket.generation) else {
return;
}
};
let mut tools = tools.to_vec();
for tool in &mut tools {
// Tool annotations affect approval and parallelism decisions, so only the live
// connection may supply them.
tool.tool.annotations = None;
}
state.last_accepted_generation = ticket.generation;
state.optional_startup_deadline = None;
state.snapshot = Some(ToolCatalogSnapshot {
tools,
meta,
@@ -261,6 +265,16 @@ impl McpToolCatalogCacheContext {
tool_list_revision: ticket.tool_list_revision,
});
}
fn accept_generation(&self, generation: u64) -> Option<MutexGuard<'_, ToolCatalogCacheState>> {
let mut state = lock_unpoisoned(&self.entry.state);
if state.disabled_by_server || generation <= state.last_accepted_generation {
return None;
}
state.last_accepted_generation = generation;
state.optional_startup_deadline = None;
Some(state)
}
}
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
@@ -417,6 +431,13 @@ impl ToolCatalogTransportIdentity {
else {
return None;
};
// An unmarked helper can expose different tools after a legacy fallback.
if matches!(
connection_identity,
Some((_, McpProtocolMode::DiscoverLocalStdio, _))
) {
return None;
}
if env_vars
.iter()
.any(codex_config::McpServerEnvVar::is_remote_source)
@@ -438,6 +459,7 @@ impl ToolCatalogTransportIdentity {
env_vars,
cwd,
&config.environment_id,
connection_identity.map(|(_, mode, _)| mode.preferred_protocol_version()),
client_elicitation_capability,
client_mcp_extensions.iter().collect::<BTreeMap<_, _>>(),
))