Skip to content

Commit be2e4af

Browse files
tsarlandie-oaicopyberry
authored andcommitted
Add MCP 2026-07-28 discovery support (#35724)
## What changed - Add an opt-in `mcp_2026_07_28` protocol mode while preserving the legacy lifecycle by default. - Negotiate the new protocol over streamable HTTP with `server/discover`, including bounded responses, redirect protection, and fallback only when a response establishes that the endpoint is legacy-only. - Require stdio servers to opt in with `CODEX_MCP_PROTOCOL_VERSION=2026-07-28`, and add a bounded local stdio transport for the modern lifecycle. - Consume paginated tool, resource, and resource-template catalogs in modern mode, reject repeated cursors, and retain discovered server identity. - Reconnect reusable MCP clients when their selected protocol mode changes. ## Testing - Cover HTTP JSON and SSE discovery, legacy fallback and rejection cases, redirects, retries, response limits, and pagination. - Cover local and executor stdio discovery, protocol markers, message limits, and legacy compatibility. GitOrigin-RevId: f6a78816e127d2a482292d63b91c8384f1595903
1 parent 85c6da1 commit be2e4af

28 files changed

Lines changed: 2891 additions & 105 deletions

codex-rs/codex-mcp/src/binding_clients.rs

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use rmcp::model::ResourceTemplate;
1414
use tokio::task::JoinSet;
1515
use tracing::warn;
1616

17+
use crate::pagination::collect_paginated;
1718
use crate::rmcp_client::ManagedClient;
1819

1920
/// The ready clients captured for one model step.
@@ -90,28 +91,16 @@ impl McpBindingClients {
9091
let client = Arc::clone(&managed.client);
9192
let timeout = managed.tool_timeout;
9293
join_set.spawn(async move {
93-
let mut collected = Vec::new();
94-
let mut cursor: Option<String> = None;
95-
loop {
96-
let params = cursor.as_ref().map(|next| {
97-
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
98-
});
99-
let response = match client.list_resources(params, timeout).await {
100-
Ok(result) => result,
101-
Err(error) => return (server_name, Err(error)),
102-
};
103-
collected.extend(response.resources);
104-
match response.next_cursor {
105-
Some(next) if cursor.as_ref() == Some(&next) => {
106-
return (
107-
server_name,
108-
Err(anyhow!("resources/list returned duplicate cursor")),
109-
);
94+
let resources =
95+
collect_paginated("resources/list", /*overall_timeout*/ None, |params| {
96+
let client = Arc::clone(&client);
97+
async move {
98+
let response = client.list_resources(params, timeout).await?;
99+
Ok((response.resources, response.next_cursor))
110100
}
111-
Some(next) => cursor = Some(next),
112-
None => return (server_name, Ok(collected)),
113-
}
114-
}
101+
})
102+
.await;
103+
(server_name, resources)
115104
});
116105
}
117106
collect_resource_results(&mut join_set, "resources").await
@@ -131,30 +120,19 @@ impl McpBindingClients {
131120
let client = Arc::clone(&managed.client);
132121
let timeout = managed.tool_timeout;
133122
join_set.spawn(async move {
134-
let mut collected = Vec::new();
135-
let mut cursor: Option<String> = None;
136-
loop {
137-
let params = cursor.as_ref().map(|next| {
138-
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
139-
});
140-
let response = match client.list_resource_templates(params, timeout).await {
141-
Ok(result) => result,
142-
Err(error) => return (server_name, Err(error)),
143-
};
144-
collected.extend(response.resource_templates);
145-
match response.next_cursor {
146-
Some(next) if cursor.as_ref() == Some(&next) => {
147-
return (
148-
server_name,
149-
Err(anyhow!(
150-
"resources/templates/list returned duplicate cursor"
151-
)),
152-
);
123+
let templates = collect_paginated(
124+
"resources/templates/list",
125+
/*overall_timeout*/ None,
126+
|params| {
127+
let client = Arc::clone(&client);
128+
async move {
129+
let response = client.list_resource_templates(params, timeout).await?;
130+
Ok((response.resource_templates, response.next_cursor))
153131
}
154-
Some(next) => cursor = Some(next),
155-
None => return (server_name, Ok(collected)),
156-
}
157-
}
132+
},
133+
)
134+
.await;
135+
(server_name, templates)
158136
});
159137
}
160138
collect_resource_results(&mut join_set, "resource templates").await

codex-rs/codex-mcp/src/connection_manager.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ impl McpConnectionSet {
187187
let codex_home = config.codex_home.clone();
188188
let prefix_mcp_tool_names = config.prefix_mcp_tool_names;
189189
let non_prefixed_mcp_tool_servers = config.non_prefixed_mcp_tool_servers.clone();
190+
let protocol_mode = config.protocol_mode;
190191
let client_elicitation_capability = config.client_elicitation_capability.clone();
191192
let tool_plugin_provenance = crate::mcp::tool_plugin_provenance(&config);
192193
let auth = auth.as_ref();
@@ -292,14 +293,37 @@ impl McpConnectionSet {
292293
client_elicitation_capability.clone(),
293294
supports_openai_form_elicitation,
294295
);
296+
let expected_protocol_mode = match &configured_config.transport {
297+
McpServerTransportConfig::StreamableHttp { .. } => Some(protocol_mode),
298+
McpServerTransportConfig::Stdio { .. }
299+
if protocol_mode == crate::McpProtocolMode::Legacy =>
300+
{
301+
Some(crate::McpProtocolMode::Legacy)
302+
}
303+
McpServerTransportConfig::Stdio { env, .. } => match env
304+
.as_ref()
305+
.and_then(|variables| variables.get("CODEX_MCP_PROTOCOL_VERSION"))
306+
{
307+
None => Some(crate::McpProtocolMode::Legacy),
308+
Some(version)
309+
if version == rmcp::model::ProtocolVersion::V_2026_07_28.as_str() =>
310+
{
311+
Some(protocol_mode)
312+
}
313+
Some(_) => None,
314+
},
315+
};
295316
if let Some(previous_view) =
296317
reusable_previous.and_then(|previous| previous.servers.get(&server_name))
297318
{
298319
let connection = Arc::clone(&previous_view.connection);
299320
if connection
300321
.reusable_client(&connection_identity)
301322
.await
302-
.is_some()
323+
.is_some_and(|client| {
324+
expected_protocol_mode
325+
.is_some_and(|expected| client.client.protocol_mode() == expected)
326+
})
303327
{
304328
servers.insert(
305329
server_name.clone(),
@@ -346,6 +370,7 @@ impl McpConnectionSet {
346370
runtime_auth_provider,
347371
client_elicitation_capability.clone(),
348372
supports_openai_form_elicitation,
373+
protocol_mode,
349374
);
350375
servers.insert(
351376
server_name.clone(),

codex-rs/codex-mcp/src/connection_manager_tests.rs

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ struct RefreshTestTransportFactory {
226226
tool: Tool,
227227
list_started: Option<Arc<Notify>>,
228228
release_list: Option<Arc<Notify>>,
229+
next_cursor: Option<String>,
230+
list_requests: Arc<AtomicUsize>,
229231
}
230232

231233
impl ServerHandler for RefreshTestTransportFactory {
@@ -238,13 +240,17 @@ impl ServerHandler for RefreshTestTransportFactory {
238240
_request: Option<PaginatedRequestParams>,
239241
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
240242
) -> Result<ListToolsResult, McpError> {
243+
self.list_requests
244+
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
241245
if let Some(list_started) = &self.list_started {
242246
list_started.notify_one();
243247
}
244248
if let Some(release_list) = &self.release_list {
245249
release_list.notified().await;
246250
}
247-
Ok(ListToolsResult::with_all_items(vec![self.tool.clone()]))
251+
let mut result = ListToolsResult::with_all_items(vec![self.tool.clone()]);
252+
result.next_cursor = self.next_cursor.clone();
253+
Ok(result)
248254
}
249255
}
250256

@@ -346,6 +352,48 @@ impl InProcessTransportFactory for DisconnectingToolsTransportFactory {
346352
}
347353
}
348354

355+
#[tokio::test]
356+
async fn legacy_tool_catalog_does_not_follow_pagination_cursor() -> anyhow::Result<()> {
357+
let requests = Arc::new(AtomicUsize::new(0));
358+
let client = Arc::new(
359+
RmcpClient::new_in_process_client(Arc::new(RefreshTestTransportFactory {
360+
tool: create_test_tool("legacy", "first-page").tool,
361+
list_started: None,
362+
release_list: None,
363+
next_cursor: Some("next-page".to_string()),
364+
list_requests: Arc::clone(&requests),
365+
}))
366+
.await?,
367+
);
368+
client
369+
.initialize(
370+
InitializeRequestParams::new(
371+
ClientCapabilities::default(),
372+
Implementation::new("codex-test", "0.0.0-test"),
373+
)
374+
.with_protocol_version(ProtocolVersion::V_2025_06_18),
375+
Some(Duration::from_secs(5)),
376+
Box::new(|_, _| async { Err(anyhow!("unexpected elicitation")) }.boxed()),
377+
)
378+
.await?;
379+
380+
let tools = list_tools_for_client_uncached(
381+
"legacy",
382+
/*is_codex_apps_mcp_server*/ false,
383+
"test",
384+
&client,
385+
Some(Duration::from_secs(5)),
386+
/*server_instructions*/ None,
387+
)
388+
.await?;
389+
390+
assert_eq!(tools.len(), 1);
391+
assert_eq!(tools[0].tool.name.as_ref(), "first-page");
392+
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
393+
client.shutdown().await;
394+
Ok(())
395+
}
396+
349397
async fn create_test_managed_client(tools: Vec<ToolInfo>) -> ManagedClient {
350398
ManagedClient {
351399
client: Arc::new(
@@ -391,6 +439,8 @@ async fn create_test_manager_with_ready_apps_client(
391439
tool: tool.tool.clone(),
392440
list_started,
393441
release_list,
442+
next_cursor: None,
443+
list_requests: Arc::new(AtomicUsize::new(0)),
394444
}))
395445
.await?,
396446
);
@@ -2568,12 +2618,13 @@ fn reusable_server_identity(
25682618
runtime_context: &McpRuntimeContext,
25692619
) -> McpServerConnectionIdentity {
25702620
let server = EffectiveMcpServer::configured(config.clone());
2621+
let resolved_environment = runtime_context.resolve_server_environment("docs", config);
25712622
McpServerConnectionIdentity::new(
25722623
"docs",
25732624
&server,
25742625
OAuthCredentialsStoreMode::default(),
25752626
AuthKeyringBackendKind::default(),
2576-
&Ok(None),
2627+
&resolved_environment,
25772628
runtime_context,
25782629
/*runtime_auth_provider*/ None,
25792630
/*auth*/ None,
@@ -2796,6 +2847,139 @@ async fn reconciliation_reuses_an_unchanged_ready_server() {
27962847
);
27972848
}
27982849

2850+
#[tokio::test]
2851+
async fn reconciliation_reuses_legacy_stdio_server_with_existing_protocol_marker() {
2852+
let runtime_context = McpRuntimeContext::new(
2853+
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
2854+
PathBuf::from("/tmp"),
2855+
);
2856+
let mut config = reusable_server_config("http://127.0.0.1:1");
2857+
config.transport = McpServerTransportConfig::Stdio {
2858+
command: "legacy-server".to_string(),
2859+
args: Vec::new(),
2860+
env: Some(HashMap::from([(
2861+
"CODEX_MCP_PROTOCOL_VERSION".to_string(),
2862+
"1999-01-01".to_string(),
2863+
)])),
2864+
env_vars: Vec::new(),
2865+
cwd: None,
2866+
};
2867+
let previous = manager_with_reusable_ready_server(
2868+
&config,
2869+
&runtime_context,
2870+
vec![create_test_tool("docs", "search")],
2871+
)
2872+
.await;
2873+
2874+
let reconciled = reconcile_reusable_server(&previous, config, runtime_context).await;
2875+
2876+
assert!(previous.shares_test_connection_with(&reconciled, "docs"));
2877+
}
2878+
2879+
#[tokio::test]
2880+
async fn reconciliation_replaces_connection_when_protocol_mode_changes() {
2881+
let runtime_context = reusable_server_runtime_context();
2882+
let config = reusable_server_config("http://127.0.0.1:1");
2883+
let previous = manager_with_reusable_ready_server(
2884+
&config,
2885+
&runtime_context,
2886+
vec![create_test_tool("docs", "search")],
2887+
)
2888+
.await;
2889+
let codex_home = tempdir().expect("tempdir");
2890+
let mut mcp_config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf());
2891+
mcp_config.protocol_mode = codex_rmcp_client::McpProtocolMode::V20260728;
2892+
2893+
let reconciled = McpConnectionSet::new(
2894+
Some(&previous),
2895+
McpPublicationGate::already_published(),
2896+
McpRuntimeInput {
2897+
config: Arc::new(mcp_config),
2898+
plugins_available: false,
2899+
ready_selected_capability_roots: Vec::new(),
2900+
mcp_servers: HashMap::from([(
2901+
"docs".to_string(),
2902+
EffectiveMcpServer::configured(config),
2903+
)]),
2904+
submit_id: "refresh".to_string(),
2905+
tx_event: None,
2906+
startup_cancellation_token: CancellationToken::new(),
2907+
runtime_context,
2908+
codex_apps_tools_cache: ConnectorRuntimeManager::default(),
2909+
tool_catalog_cache: McpToolCatalogCache::default(),
2910+
codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal(
2911+
/*account_id*/ None, /*chatgpt_user_id*/ None,
2912+
),
2913+
supports_openai_form_elicitation: false,
2914+
auth: None,
2915+
codex_apps_auth_manager: None,
2916+
elicitation_reviewer: None,
2917+
elicitation_lifecycle: None,
2918+
},
2919+
ElicitationRequestRouter::default(),
2920+
)
2921+
.await;
2922+
2923+
assert!(!previous.shares_test_connection_with(&reconciled, "docs"));
2924+
}
2925+
2926+
#[tokio::test]
2927+
async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabled() {
2928+
let runtime_context = McpRuntimeContext::new(
2929+
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
2930+
PathBuf::from("/tmp"),
2931+
);
2932+
let mut config = reusable_server_config("http://127.0.0.1:1");
2933+
config.transport = McpServerTransportConfig::Stdio {
2934+
command: "legacy-server".to_string(),
2935+
args: Vec::new(),
2936+
env: None,
2937+
env_vars: Vec::new(),
2938+
cwd: None,
2939+
};
2940+
let previous = manager_with_reusable_ready_server(
2941+
&config,
2942+
&runtime_context,
2943+
vec![create_test_tool("docs", "search")],
2944+
)
2945+
.await;
2946+
let codex_home = tempdir().expect("tempdir");
2947+
let mut mcp_config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf());
2948+
mcp_config.protocol_mode = codex_rmcp_client::McpProtocolMode::V20260728;
2949+
2950+
let reconciled = McpConnectionSet::new(
2951+
Some(&previous),
2952+
McpPublicationGate::already_published(),
2953+
McpRuntimeInput {
2954+
config: Arc::new(mcp_config),
2955+
plugins_available: false,
2956+
ready_selected_capability_roots: Vec::new(),
2957+
mcp_servers: HashMap::from([(
2958+
"docs".to_string(),
2959+
EffectiveMcpServer::configured(config),
2960+
)]),
2961+
submit_id: "refresh".to_string(),
2962+
tx_event: None,
2963+
startup_cancellation_token: CancellationToken::new(),
2964+
runtime_context,
2965+
codex_apps_tools_cache: ConnectorRuntimeManager::default(),
2966+
tool_catalog_cache: McpToolCatalogCache::default(),
2967+
codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal(
2968+
/*account_id*/ None, /*chatgpt_user_id*/ None,
2969+
),
2970+
supports_openai_form_elicitation: false,
2971+
auth: None,
2972+
codex_apps_auth_manager: None,
2973+
elicitation_reviewer: None,
2974+
elicitation_lifecycle: None,
2975+
},
2976+
ElicitationRequestRouter::default(),
2977+
)
2978+
.await;
2979+
2980+
assert!(previous.shares_test_connection_with(&reconciled, "docs"));
2981+
}
2982+
27992983
#[tokio::test]
28002984
async fn reconciliation_updates_elicitation_policy_without_restarting_ready_server() {
28012985
let runtime_context = reusable_server_runtime_context();

codex-rs/codex-mcp/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub use binding::McpBinding;
22
pub use binding::PreparedMcpCall;
3+
pub use codex_rmcp_client::McpProtocolMode;
34
pub use connection_manager::tool_is_model_visible;
45
pub use elicitation::ElicitationLifecycle;
56
pub use elicitation::ElicitationReviewRequest;
@@ -91,6 +92,7 @@ pub(crate) mod connection_manager;
9192
pub(crate) mod elicitation;
9293
pub(crate) mod mcp;
9394
mod openai_docs_source_attribution;
95+
mod pagination;
9496
mod plugin_config;
9597
mod resource_client;
9698
pub(crate) mod rmcp_client;

0 commit comments

Comments
 (0)