Pin MCP runtimes to model steps (#30101)

## Why

An MCP refresh can replace the session's current manager while a model
step is still running. The step must execute calls through the same
manager whose tools it advertised.

## Boundary

```text
current session MCP runtime
          |
          | capture once for this model step
          v
StepContext.mcp
  - exact MCP config
  - exact connection manager
  - exact runtime environment context
```

```rust
pub struct McpRuntimeSnapshot {
    config: Arc<McpConfig>,
    manager: Arc<McpConnectionManager>,
    runtime_context: McpRuntimeContext,
}
```

## Example

```text
step A captures runtime A and advertises A's tools
refresh publishes runtime B
step A tool call -> runtime A
next step        -> runtime B
```

Capturing the snapshot is only an `Arc` clone. It does not restart MCPs
or make an RPC.

## What changes

- Captures one MCP runtime in `StepContext`.
- Uses it for tool planning, tool calls, resources, approvals, connector
attribution, and elicitation.
- Publishes replacement runtimes atomically.
- Lets an old runtime live only while an in-flight step or request still
holds its `Arc`.

Most of this diff is mechanical routing from the session-global manager
to `step_context.mcp`; it does not introduce selected-plugin discovery
yet.

## What does not change

- No plugin or extension migration.
- No new MCP cache policy.
- No environment file watching.
- No client sharing between separate managers.

## Stack

1. Extension-owned World State sections.
2. Project executor skills through World State.
3. **This PR:** pin one MCP runtime to each model step.
4. Project selected MCP/app/connector metadata by environment
availability.
5. One end-to-end integration scenario.
This commit is contained in:
jif
2026-06-26 00:53:07 +01:00
committed by GitHub
parent 8ce931ab76
commit ee9e0f6387
27 changed files with 596 additions and 325 deletions
@@ -16,7 +16,7 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_mcp_server;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()> {
async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -84,7 +84,7 @@ async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()>
"sync",
Some(serde_json::json!({
"barrier": barrier,
"sleep_after_ms": 30_000
"sleep_after_ms": 300_000
})),
/*meta*/ None,
)
@@ -119,9 +119,16 @@ async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()>
let replacement_pid = wait_for_pid_file(&pid_file).await?;
assert_ne!(replacement_pid, superseded_pid);
assert!(process_is_alive(&superseded_pid)?);
long_call.abort();
assert!(
long_call
.await
.expect_err("call should be aborted")
.is_cancelled()
);
wait_for_process_exit(&superseded_pid).await?;
assert!(process_is_alive(&replacement_pid)?);
assert!(long_call.await?.is_err());
fixture.codex.shutdown_and_wait().await?;
wait_for_process_exit(&replacement_pid).await