Compare commits

...

8 Commits

Author SHA1 Message Date
Eric Traut
cdbd0629ea Simplify client compatibility state 2026-05-04 16:14:30 -07:00
Eric Traut
41f4b83259 Apply client compatibility to threadless MCP ops 2026-05-04 15:52:10 -07:00
Eric Traut
6a37b1d53e Apply client compatibility to review start 2026-05-04 15:44:06 -07:00
Eric Traut
725bfb2514 Refresh MCP compatibility on client changes 2026-05-04 15:37:28 -07:00
Eric Traut
1113126c80 Simplify client compatibility storage 2026-05-04 15:28:13 -07:00
Eric Traut
efc64b0349 Simplify Xcode MCP compatibility plumbing 2026-05-04 15:08:32 -07:00
Eric Traut
58fdaec898 Handle lowercase Xcode client name 2026-05-04 13:49:25 -07:00
Eric Traut
5db02d3bb5 Handle Xcode 26.4 MCP elicitation compatibility 2026-05-04 13:43:40 -07:00
21 changed files with 327 additions and 109 deletions

View File

@@ -59,6 +59,8 @@ use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings;
use codex_core::ClientCompatibilityFlags;
use codex_core::McpElicitationCompatibility;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::thread_store_from_config;
@@ -187,6 +189,26 @@ pub(crate) struct InitializedConnectionSessionState {
pub(crate) opted_out_notification_methods: HashSet<String>,
pub(crate) app_server_client_name: String,
pub(crate) client_version: String,
pub(crate) client_compatibility_flags: ClientCompatibilityFlags,
}
fn client_compatibility_flags_for_app_server_client(
client_name: &str,
client_version: &str,
) -> ClientCompatibilityFlags {
// Compatibility hack for Xcode 26.4: that client shipped against the
// pre-PR #17043 MCP surface, where only `codex_apps` advertised elicitation
// support. Advertising custom-server elicitations creates app-server
// events/messages that Xcode 26.4 cannot handle, so keep that client on the
// old capability surface.
// TODO: Remove once Xcode 26.4 ages out of support.
let mcp_elicitation =
if client_name.eq_ignore_ascii_case("xcode") && client_version.starts_with("26.4") {
McpElicitationCompatibility::CodexAppsOnly
} else {
McpElicitationCompatibility::Default
};
ClientCompatibilityFlags { mcp_elicitation }
}
impl Default for ConnectionSessionState {
@@ -195,6 +217,27 @@ impl Default for ConnectionSessionState {
}
}
impl InitializedConnectionSessionState {
pub(crate) fn new(
experimental_api_enabled: bool,
opted_out_notification_methods: HashSet<String>,
app_server_client_name: String,
client_version: String,
) -> Self {
let client_compatibility_flags = client_compatibility_flags_for_app_server_client(
&app_server_client_name,
&client_version,
);
Self {
experimental_api_enabled,
opted_out_notification_methods,
app_server_client_name,
client_version,
client_compatibility_flags,
}
}
}
impl ConnectionSessionState {
pub(crate) fn new(origin: ConnectionOrigin) -> Self {
Self {
@@ -237,6 +280,13 @@ impl ConnectionSessionState {
.map(|session| session.client_version.as_str())
}
pub(crate) fn client_compatibility_flags(&self) -> ClientCompatibilityFlags {
self.initialized
.get()
.map(|session| session.client_compatibility_flags)
.unwrap_or_default()
}
pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> {
self.initialized.set(session).map_err(|_| ())
}
@@ -756,11 +806,10 @@ impl MessageProcessor {
);
let serialization_scope = codex_request.serialization_scope();
let app_server_client_name = session.app_server_client_name().map(str::to_string);
let client_version = session.client_version().map(str::to_string);
let device_key_requests_allowed = session.allows_device_key_requests();
let error_request_id = connection_request_id.clone();
let rpc_gate = Arc::clone(&session.rpc_gate);
let client_session = Arc::clone(&session);
let processor = Arc::clone(self);
let span = request_context.span();
let request = QueuedInitializedRequest::new(
@@ -772,8 +821,7 @@ impl MessageProcessor {
connection_request_id,
codex_request,
request_context,
app_server_client_name,
client_version,
client_session,
device_key_requests_allowed,
)
.await;
@@ -802,8 +850,7 @@ impl MessageProcessor {
connection_request_id: ConnectionRequestId,
codex_request: ClientRequest,
request_context: RequestContext,
app_server_client_name: Option<String>,
client_version: Option<String>,
client_session: Arc<ConnectionSessionState>,
device_key_requests_allowed: bool,
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
@@ -811,7 +858,6 @@ impl MessageProcessor {
connection_id,
request_id: codex_request.id().clone(),
};
let result: Result<Option<ClientResponsePayload>, JSONRPCErrorError> = match codex_request {
ClientRequest::Initialize { .. } => {
panic!("Initialize should be handled before initialized request dispatch");
@@ -926,8 +972,7 @@ impl MessageProcessor {
.thread_start(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
client_session.as_ref(),
request_context,
)
.await
@@ -939,12 +984,12 @@ impl MessageProcessor {
}
ClientRequest::ThreadResume { params, .. } => {
self.thread_processor
.thread_resume(request_id.clone(), params)
.thread_resume(request_id.clone(), params, client_session.as_ref())
.await
}
ClientRequest::ThreadFork { params, .. } => {
self.thread_processor
.thread_fork(request_id.clone(), params)
.thread_fork(request_id.clone(), params, client_session.as_ref())
.await
}
ClientRequest::ThreadArchive { params, .. } => {
@@ -1095,12 +1140,7 @@ impl MessageProcessor {
}
ClientRequest::TurnStart { params, .. } => {
self.turn_processor
.turn_start(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
)
.turn_start(request_id.clone(), params, client_session.as_ref())
.await
}
ClientRequest::ThreadInjectItems { params, .. } => {
@@ -1138,7 +1178,9 @@ impl MessageProcessor {
self.turn_processor.thread_realtime_list_voices().await
}
ClientRequest::ReviewStart { params, .. } => {
self.turn_processor.review_start(&request_id, params).await
self.turn_processor
.review_start(&request_id, params, client_session.as_ref())
.await
}
ClientRequest::McpServerOauthLogin { params, .. } => {
self.mcp_processor.mcp_server_oauth_login(params).await
@@ -1148,12 +1190,12 @@ impl MessageProcessor {
}
ClientRequest::McpServerStatusList { params, .. } => {
self.mcp_processor
.mcp_server_status_list(&request_id, params)
.mcp_server_status_list(&request_id, params, client_session.as_ref())
.await
}
ClientRequest::McpResourceRead { params, .. } => {
self.mcp_processor
.mcp_resource_read(&request_id, params)
.mcp_resource_read(&request_id, params, client_session.as_ref())
.await
}
ClientRequest::McpServerToolCall { params, .. } => {
@@ -1259,3 +1301,29 @@ impl MessageProcessor {
#[cfg(test)]
#[path = "message_processor_tracing_tests.rs"]
mod message_processor_tracing_tests;
#[cfg(test)]
mod client_compatibility_tests {
use super::*;
#[test]
fn xcode_26_4_uses_legacy_mcp_elicitation_compatibility() {
use McpElicitationCompatibility::CodexAppsOnly;
use McpElicitationCompatibility::Default as DefaultCompatibility;
let cases = [
("Xcode", "26.4", CodexAppsOnly),
("xcode", "26.4.1", CodexAppsOnly),
("Xcode", "26.4-beta", CodexAppsOnly),
("Xcode", "26.3", DefaultCompatibility),
("Xcode", "26.5", DefaultCompatibility),
("codex-vscode", "26.4", DefaultCompatibility),
];
for (name, version, expected) in cases {
assert_eq!(
client_compatibility_flags_for_app_server_client(name, version).mcp_elicitation,
expected
);
}
}
}

View File

@@ -244,6 +244,7 @@ use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::ClientCompatibilityFlags;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
use codex_core::ForkSnapshot;
@@ -460,6 +461,7 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
use crate::message_processor::ConnectionSessionState;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::ThreadStateManager;
@@ -482,6 +484,10 @@ pub(crate) use self::thread_summary::read_rollout_items_from_rollout;
pub(crate) use self::thread_summary::read_summary_from_rollout;
pub(crate) use self::thread_summary::summary_to_thread;
fn apply_client_compatibility(config: &mut Config, session: &ConnectionSessionState) {
config.client_compatibility_flags = session.client_compatibility_flags();
}
pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
let mut builder = ThreadHistoryBuilder::new();
for item in items {

View File

@@ -90,12 +90,12 @@ impl InitializeRequestProcessor {
let user_agent_suffix = format!("{name}; {version}");
let codex_home = self.config.codex_home.clone();
if session
.initialize(InitializedConnectionSessionState {
.initialize(InitializedConnectionSessionState::new(
experimental_api_enabled,
opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(),
app_server_client_name: name.clone(),
client_version: version,
})
opt_out_notification_methods.into_iter().collect(),
name.clone(),
version,
))
.is_err()
{
return Err(invalid_request("Already initialized"));

View File

@@ -47,8 +47,9 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.list_mcp_server_status(request_id, params)
self.list_mcp_server_status(request_id, params, client_session)
.await
.map(|()| None)
}
@@ -57,8 +58,9 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.read_mcp_resource(request_id, params)
self.read_mcp_resource(request_id, params, client_session)
.await
.map(|()| None)
}
@@ -248,8 +250,10 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
client_session: &ConnectionSessionState,
) -> Result<(), JSONRPCErrorError> {
let request = request_id.clone();
let client_compatibility_flags = client_session.client_compatibility_flags();
let outgoing = Arc::clone(&self.outgoing);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
@@ -271,41 +275,21 @@ impl McpRequestProcessor {
};
tokio::spawn(async move {
Self::list_mcp_server_status_task(
outgoing,
request,
let result = Self::list_mcp_server_status_response(
request.request_id.to_string(),
params,
config,
mcp_config,
auth,
runtime_environment,
client_compatibility_flags,
)
.await;
outgoing.send_result(request, result).await;
});
Ok(())
}
async fn list_mcp_server_status_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
) {
let result = Self::list_mcp_server_status_response(
request_id.request_id.to_string(),
params,
config,
mcp_config,
auth,
runtime_environment,
)
.await;
outgoing.send_result(request_id, result).await;
}
async fn list_mcp_server_status_response(
request_id: String,
params: ListMcpServerStatusParams,
@@ -313,6 +297,7 @@ impl McpRequestProcessor {
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
client_compatibility_flags: ClientCompatibilityFlags,
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
@@ -325,6 +310,7 @@ impl McpRequestProcessor {
request_id,
runtime_environment,
detail,
client_compatibility_flags.mcp_elicitation,
)
.await;
@@ -398,8 +384,10 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
client_session: &ConnectionSessionState,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let client_compatibility_flags = client_session.client_compatibility_flags();
let McpResourceReadParams {
thread_id,
server,
@@ -440,6 +428,7 @@ impl McpRequestProcessor {
runtime_environment,
&server,
&uri,
client_compatibility_flags.mcp_elicitation,
)
.await
.and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from));

View File

@@ -301,19 +301,12 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
client_session: &ConnectionSessionState,
request_context: RequestContext,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_start_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
request_context,
)
.await
.map(|()| None)
self.thread_start_inner(request_id, params, client_session, request_context)
.await
.map(|()| None)
}
pub(crate) async fn thread_unsubscribe(
@@ -330,8 +323,9 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(request_id, params)
self.thread_resume_inner(request_id, params, client_session)
.await
.map(|()| None)
}
@@ -340,8 +334,9 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(request_id, params)
self.thread_fork_inner(request_id, params, client_session)
.await
.map(|()| None)
}
@@ -582,16 +577,6 @@ impl ThreadRequestProcessor {
Ok((thread_id, thread))
}
async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
.acquire()
.await
.map_err(|err| {
internal_error(format!("failed to acquire thread list state permit: {err}"))
})
}
async fn set_app_server_client_info(
thread: &CodexThread,
@@ -608,6 +593,17 @@ impl ThreadRequestProcessor {
})
}
async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
.acquire()
.await
.map_err(|err| {
internal_error(format!("failed to acquire thread list state permit: {err}"))
})
}
async fn finalize_thread_teardown(&self, thread_id: ThreadId) {
self.pending_thread_unloads.lock().await.remove(&thread_id);
self.outgoing
@@ -716,8 +712,7 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
client_session: &ConnectionSessionState,
request_context: RequestContext,
) -> Result<(), JSONRPCErrorError> {
let ThreadStartParams {
@@ -777,6 +772,9 @@ impl ThreadRequestProcessor {
let config_manager = self.config_manager.clone();
let outgoing = Arc::clone(&listener_task_context.outgoing);
let error_request_id = request_id.clone();
let app_server_client_name = client_session.app_server_client_name().map(str::to_string);
let app_server_client_version = client_session.client_version().map(str::to_string);
let client_compatibility_flags = client_session.client_compatibility_flags();
let thread_start_task = async move {
if let Err(error) = Self::thread_start_task(
listener_task_context,
@@ -784,6 +782,7 @@ impl ThreadRequestProcessor {
request_id,
app_server_client_name,
app_server_client_version,
client_compatibility_flags,
config,
typesafe_overrides,
dynamic_tools,
@@ -856,6 +855,7 @@ impl ThreadRequestProcessor {
request_id: ConnectionRequestId,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
client_compatibility_flags: ClientCompatibilityFlags,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
@@ -960,6 +960,7 @@ impl ThreadRequestProcessor {
.collect()
};
let core_dynamic_tool_count = core_dynamic_tools.len();
config.client_compatibility_flags = client_compatibility_flags;
let NewThread {
thread_id,
@@ -2296,6 +2297,7 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
client_session: &ConnectionSessionState,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2400,7 +2402,7 @@ impl ThreadRequestProcessor {
.await;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match self
let mut config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
@@ -2412,6 +2414,7 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
apply_client_compatibility(&mut config, client_session);
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
@@ -2931,6 +2934,7 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
client_session: &ConnectionSessionState,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -3009,11 +3013,12 @@ impl ThreadRequestProcessor {
);
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
let mut config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
apply_client_compatibility(&mut config, client_session);
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;

View File

@@ -49,17 +49,11 @@ impl TurnRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: TurnStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.turn_start_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
)
.await
.map(|response| Some(response.into()))
self.turn_start_inner(request_id, params, client_session)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_inject_items(
@@ -146,8 +140,9 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
client_session: &ConnectionSessionState,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.review_start_inner(request_id, params)
self.review_start_inner(request_id, params, client_session)
.await
.map(|()| None)
}
@@ -330,8 +325,7 @@ impl TurnRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: TurnStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
client_session: &ConnectionSessionState,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
self.track_error_response(
@@ -349,8 +343,8 @@ impl TurnRequestProcessor {
})?;
Self::set_app_server_client_info(
thread.as_ref(),
app_server_client_name,
app_server_client_version,
client_session.app_server_client_name().map(str::to_string),
client_session.client_version().map(str::to_string),
)
.await
.inspect_err(|error| {
@@ -885,6 +879,7 @@ impl TurnRequestProcessor {
request_id: &ConnectionRequestId,
parent_thread_id: ThreadId,
parent_thread: Arc<CodexThread>,
client_session: &ConnectionSessionState,
review_request: ReviewRequest,
display_text: &str,
) -> std::result::Result<(), JSONRPCErrorError> {
@@ -904,6 +899,7 @@ impl TurnRequestProcessor {
};
let mut config = self.config.as_ref().clone();
apply_client_compatibility(&mut config, client_session);
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
}
@@ -996,6 +992,7 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
client_session: &ConnectionSessionState,
) -> Result<(), JSONRPCErrorError> {
let ReviewStartParams {
thread_id,
@@ -1021,6 +1018,7 @@ impl TurnRequestProcessor {
request_id,
parent_thread_id,
parent_thread,
client_session,
review_request,
display_text.as_str(),
)

View File

@@ -142,13 +142,17 @@ impl McpConnectionManager {
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_compatibility: crate::elicitation::McpElicitationCompatibility,
) -> (Self, CancellationToken) {
let cancel_token = CancellationToken::new();
let mut clients = HashMap::new();
let mut server_origins = HashMap::new();
let mut join_set = JoinSet::new();
let elicitation_requests =
ElicitationRequestManager::new(approval_policy.value(), initial_permission_profile);
let elicitation_requests = ElicitationRequestManager::new_with_compatibility(
approval_policy.value(),
initial_permission_profile,
elicitation_compatibility,
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id.clone();
let codex_apps_auth_provider = auth

View File

@@ -6,6 +6,7 @@ use crate::codex_apps::read_cached_codex_apps_tools;
use crate::codex_apps::write_cached_codex_apps_tools;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::McpElicitationCompatibility;
use crate::elicitation::elicitation_is_rejected_by_policy;
use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::ManagedClient;
@@ -25,7 +26,6 @@ use futures::FutureExt;
use pretty_assertions::assert_eq;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationCapability;
use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::model::NumberOrString;
@@ -266,6 +266,34 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
);
}
#[tokio::test]
async fn codex_apps_only_custom_server_elicitation_is_declined_without_event() {
let manager = ElicitationRequestManager::new_with_compatibility(
AskForApproval::OnRequest,
PermissionProfile::default(),
McpElicitationCompatibility::CodexAppsOnly,
);
let (tx_event, rx_event) = async_channel::bounded(1);
let sender = manager.make_sender("custom_mcp".to_string(), tx_event);
let response = sender(
NumberOrString::Number(1),
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
)
.await
.expect("elicitation should auto decline");
assert_eq!(response.action, ElicitationAction::Decline);
assert_eq!(response.content, None);
assert!(rx_event.try_recv().is_err());
}
#[test]
fn test_qualify_tools_short_non_duplicated_names() {
let tools = vec![
@@ -802,8 +830,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
#[test]
fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() {
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
let capability = elicitation_capability_for_server(server_name);
assert_eq!(capability, Some(ElicitationCapability::default()));
let capability =
elicitation_capability_for_server(server_name, McpElicitationCompatibility::Default);
assert_eq!(
serde_json::to_value(capability).expect("serialize elicitation capability"),
serde_json::json!({})
@@ -811,6 +839,18 @@ fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() {
}
}
#[test]
fn codex_apps_only_elicitation_capability_suppresses_custom_servers() {
let compatibility = McpElicitationCompatibility::CodexAppsOnly;
assert_eq!(
(
elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME, compatibility).is_some(),
elicitation_capability_for_server("custom_mcp", compatibility).is_some(),
),
(true, false)
);
}
#[test]
fn mcp_init_error_display_prompts_for_github_pat() {
let server_name = "github";

View File

@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::McpPermissionPromptAutoApproveContext;
use crate::mcp::mcp_permission_prompt_is_auto_approved;
use anyhow::Context;
@@ -36,17 +37,31 @@ pub(crate) struct ElicitationRequestManager {
requests: Arc<Mutex<ResponderMap>>,
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
pub(crate) compatibility: McpElicitationCompatibility,
}
impl ElicitationRequestManager {
pub(crate) fn new(
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
) -> Self {
Self::new_with_compatibility(
approval_policy,
permission_profile,
McpElicitationCompatibility::Default,
)
}
pub(crate) fn new_with_compatibility(
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
compatibility: McpElicitationCompatibility,
) -> Self {
Self {
requests: Arc::new(Mutex::new(HashMap::new())),
approval_policy: Arc::new(StdMutex::new(approval_policy)),
permission_profile: Arc::new(StdMutex::new(permission_profile)),
compatibility,
}
}
@@ -73,6 +88,7 @@ impl ElicitationRequestManager {
let elicitation_requests = self.requests.clone();
let approval_policy = self.approval_policy.clone();
let permission_profile = self.permission_profile.clone();
let compatibility = self.compatibility;
Box::new(move |id, elicitation| {
let elicitation_requests = elicitation_requests.clone();
let tx_event = tx_event.clone();
@@ -80,6 +96,14 @@ impl ElicitationRequestManager {
let approval_policy = approval_policy.clone();
let permission_profile = permission_profile.clone();
async move {
if !compatibility.allows_server_elicitation(&server_name) {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
let approval_policy = approval_policy
.lock()
.map(|policy| *policy)
@@ -169,6 +193,22 @@ impl ElicitationRequestManager {
}
}
/// Compatibility policy for the MCP elicitation capability surface.
/// `CodexAppsOnly` preserves the pre-PR #17043 behavior for old clients by
/// keeping support for `codex_apps` while suppressing custom MCP servers.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum McpElicitationCompatibility {
#[default]
Default,
CodexAppsOnly,
}
impl McpElicitationCompatibility {
pub fn allows_server_elicitation(self, server_name: &str) -> bool {
self == Self::Default || server_name == CODEX_APPS_MCP_SERVER_NAME
}
}
pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
match approval_policy {
AskForApproval::Never => true,

View File

@@ -34,6 +34,7 @@ pub use mcp::resolve_oauth_scopes;
pub use mcp::should_retry_without_scopes;
pub use codex_apps::filter_non_codex_apps_mcp_tools_only;
pub use elicitation::McpElicitationCompatibility;
pub use mcp::McpPermissionPromptAutoApproveContext;
pub use mcp::mcp_permission_prompt_is_auto_approved;
pub use mcp::qualified_mcp_tool_name_prefix;

View File

@@ -231,6 +231,7 @@ pub async fn read_mcp_resource(
runtime_environment: McpRuntimeEnvironment,
server: &str,
uri: &str,
elicitation_compatibility: crate::McpElicitationCompatibility,
) -> anyhow::Result<ReadResourceResult> {
let mut mcp_servers = effective_mcp_servers(config, auth);
mcp_servers.retain(|name, _| name == server);
@@ -255,6 +256,7 @@ pub async fn read_mcp_resource(
codex_apps_tools_cache_key(auth),
tool_plugin_provenance(config),
auth,
elicitation_compatibility,
)
.await;
@@ -285,6 +287,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
detail: McpSnapshotDetail,
elicitation_compatibility: crate::McpElicitationCompatibility,
) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth);
let tool_plugin_provenance = tool_plugin_provenance(config);
@@ -320,6 +323,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
auth,
elicitation_compatibility,
)
.await;

View File

@@ -26,6 +26,7 @@ use crate::codex_apps::normalize_codex_apps_callable_namespace;
use crate::codex_apps::normalize_codex_apps_tool_title;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::McpElicitationCompatibility;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::runtime::McpRuntimeEnvironment;
@@ -318,8 +319,16 @@ impl From<anyhow::Error> for StartupOutcomeError {
}
pub(crate) fn elicitation_capability_for_server(
_server_name: &str,
server_name: &str,
compatibility: McpElicitationCompatibility,
) -> Option<ElicitationCapability> {
// Keep capability advertising aligned with the client compatibility flags.
// The sender has a matching defensive decline path for servers that send
// elicitations despite this advertised capability.
if !compatibility.allows_server_elicitation(server_name) {
return None;
}
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability::default())
@@ -457,7 +466,8 @@ async fn start_server_task(
elicitation_requests,
codex_apps_tools_cache_context,
} = params;
let elicitation = elicitation_capability_for_server(&server_name);
let elicitation =
elicitation_capability_for_server(&server_name, elicitation_requests.compatibility);
let params = InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {

View File

@@ -0,0 +1,14 @@
//! Internal compatibility flags selected by app-server clients.
//!
//! These flags keep app-server client identity at the app-server boundary. Core
//! should receive the behavior switches it needs, not the raw client name and
//! version that led to those switches.
pub use codex_mcp::McpElicitationCompatibility;
/// Behavior switches selected by the app-server layer for known client quirks.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ClientCompatibilityFlags {
/// MCP elicitation capability policy to apply when starting or refreshing MCP servers.
pub mcp_elicitation: McpElicitationCompatibility,
}

View File

@@ -6447,6 +6447,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
client_compatibility_flags: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
@@ -6649,6 +6650,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
client_compatibility_flags: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
@@ -6805,6 +6807,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
client_compatibility_flags: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
@@ -6946,6 +6949,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
client_compatibility_flags: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,

View File

@@ -1,3 +1,4 @@
use crate::ClientCompatibilityFlags;
use crate::agents_md::AgentsMdManager;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
@@ -505,6 +506,10 @@ pub struct Config {
/// TUI notification settings, including enabled events, delivery method, and focus condition.
pub tui_notifications: TuiNotificationSettings,
/// Runtime-only client compatibility flags selected by the app server
/// before creating a session. These are not loaded from config.toml.
pub client_compatibility_flags: ClientCompatibilityFlags,
/// Enable ASCII animations and shimmer effects in the TUI.
pub animations: bool,
@@ -3135,6 +3140,7 @@ impl Config {
.as_ref()
.map(|t| t.notification_settings.clone())
.unwrap_or_default(),
client_compatibility_flags: ClientCompatibilityFlags::default(),
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true),
model_availability_nux: cfg

View File

@@ -280,6 +280,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
codex_apps_tools_cache_key(auth.as_ref()),
ToolPluginProvenance::default(),
auth.as_ref(),
codex_mcp::McpElicitationCompatibility::default(),
)
.await;

View File

@@ -10,6 +10,7 @@ mod apps;
mod arc_monitor;
mod client;
mod client_common;
mod client_compatibility;
mod realtime_context;
mod realtime_conversation;
mod realtime_prompt;
@@ -110,6 +111,8 @@ pub mod test_support;
mod unified_exec;
pub mod windows_sandbox;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use client_compatibility::ClientCompatibilityFlags;
pub use client_compatibility::McpElicitationCompatibility;
pub use codex_protocol::config_types::ModelProviderAuthInfo;
mod event_mapping;
pub mod review_format;

View File

@@ -1099,7 +1099,12 @@ async fn maybe_request_mcp_tool_approval(
let tool_call_mcp_elicitation_enabled = turn_context
.config
.features
.enabled(Feature::ToolCallMcpElicitation);
.enabled(Feature::ToolCallMcpElicitation)
&& turn_context
.config
.client_compatibility_flags
.mcp_elicitation
.allows_server_elicitation(&invocation.server);
if routes_approval_to_guardian(turn_context) {
let review_id = new_guardian_review_id();

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::McpElicitationCompatibility;
use crate::config::ConfigBuilder;
use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
@@ -18,6 +19,7 @@ use codex_hooks::HooksConfig;
use codex_model_provider::create_model_provider;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GranularApprovalConfig;
use core_test_support::PathExt;
use core_test_support::responses::ev_assistant_message;
@@ -2276,7 +2278,18 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
#[tokio::test]
async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval() {
let (session, turn_context, _rx_event) = make_session_and_context_with_rx().await;
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
{
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
let mut config = (*turn_context.config).clone();
config
.features
.enable(Feature::ToolCallMcpElicitation)
.expect("test setup should allow enabling tool call MCP elicitation");
config.client_compatibility_flags.mcp_elicitation =
McpElicitationCompatibility::CodexAppsOnly;
turn_context.config = Arc::new(config);
}
{
let mut active_turn = session.active_turn.lock().await;
*active_turn = Some(ActiveTurn::default());
@@ -2302,7 +2315,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
openai_file_input_params: None,
};
let mut approval_task = {
let approval_task = {
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
tokio::spawn(async move {
@@ -2319,12 +2332,11 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
})
};
assert!(
tokio::time::timeout(std::time::Duration::from_millis(200), &mut approval_task)
.await
.is_err(),
"prompt mode should wait for approval instead of auto-allowing"
);
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
.await
.expect("approval request should emit an event")
.expect("event channel should stay open");
assert!(matches!(event.msg, EventMsg::RequestUserInput(_)));
approval_task.abort();
}

View File

@@ -252,6 +252,10 @@ impl Session {
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,
auth.as_ref(),
turn_context
.config
.client_compatibility_flags
.mcp_elicitation,
)
.await;
{

View File

@@ -996,6 +996,10 @@ impl Session {
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
auth,
session_configuration
.original_config_do_not_use
.client_compatibility_flags
.mcp_elicitation,
)
.instrument(info_span!(
"session_init.mcp_manager_init",