Files
codex/codex-rs/core/src/session/mcp.rs
T
Charlie Marsh 41b4fabbb4 Use latest-wins MCP manager replacement (#27259)
## Summary

We originally addressed startup prewarming holding the read side of
`RwLock<McpConnectionManager>` by snapshotting tool-list state. Review
feedback identified the broader ownership problem: the outer
synchronization should only publish or retrieve the current manager,
while MCP operations rely on the manager's internal synchronization. A
follow-up preserved operation retirement with a separate gate, but
further review questioned whether that synchronization was actually
required and whether we could support latest-wins replacement instead.

This PR now stores the current MCP manager in `ArcSwap`. Each operation
uses `load_full()` to obtain an owned `Arc<McpConnectionManager>`, then
performs MCP I/O without retaining the publication mechanism. Refresh
cancels obsolete startup work, constructs a replacement, and atomically
publishes it. New operations see the latest manager, while operations
that already loaded the previous manager retain a valid handle. Refresh
happens at a turn boundary, so there should be no active user tool calls
to drain.

Git history supports dropping the outer `RwLock`. It was introduced in
`03ffe4d595` on November 17, 2025 for non-blocking MCP startup: the
session published an empty manager, startup initialized that same object
while holding the write lock, and readers waited for initialization.
`7cd2e84026` on February 19, 2026 removed that two-phase initialization
in favor of constructing a fresh manager and swapping it in, explicitly
noting that `Option` or `OnceCell` could replace the placeholder design.
Hot reload later reused the existing lock to publish a replacement, but
I found no indication that the lock was introduced to guarantee
in-flight tool calls finish before refresh or shutdown.

Terminal shutdown remains separate from refresh: it aborts startup
prewarming and active tasks before shutting down the current manager, so
tool calls may be interrupted and no model WebSocket work continues
after shutdown. Focused regression coverage exercises pending tool-list
cancellation, deferred refresh, and startup-prewarm shutdown.
2026-06-10 08:33:21 -07:00

680 lines
24 KiB
Rust

use super::*;
use codex_mcp::ElicitationReviewRequest;
use codex_mcp::ElicitationReviewer;
use codex_mcp::ElicitationReviewerHandle;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION;
use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_ELICITATION_CONNECTOR_NAME_KEY;
use codex_protocol::mcp_approval_meta::REQUEST_TYPE_APPROVAL_REQUEST as MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST;
use codex_protocol::mcp_approval_meta::REQUEST_TYPE_KEY as MCP_ELICITATION_REQUEST_TYPE_KEY;
use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_TOOL_DESCRIPTION_KEY;
use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY;
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY;
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use serde_json::Map;
const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message";
const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install";
const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type";
const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id";
const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type";
#[derive(Debug, PartialEq)]
enum GuardianElicitationReview {
NotRequested,
Decline(&'static str),
ApprovalRequest(Box<crate::guardian::GuardianApprovalRequest>),
}
struct GuardianMcpElicitationReviewer {
session: std::sync::Weak<Session>,
}
pub(crate) struct McpServerElicitationOutcome {
pub(crate) response: Option<ElicitationResponse>,
pub(crate) sent: bool,
}
#[derive(Debug, PartialEq, Eq)]
struct PluginInstallElicitationTelemetryMetadata {
tool_type: String,
tool_id: String,
tool_name: String,
}
impl GuardianMcpElicitationReviewer {
fn new(session: &Arc<Session>) -> Self {
Self {
session: Arc::downgrade(session),
}
}
}
impl ElicitationReviewer for GuardianMcpElicitationReviewer {
fn review(
&self,
request: ElicitationReviewRequest,
) -> BoxFuture<'static, anyhow::Result<Option<ElicitationResponse>>> {
let session = self.session.clone();
Box::pin(async move {
let Some(session) = session.upgrade() else {
return Ok(None);
};
review_guardian_mcp_elicitation(session, request).await
})
}
}
impl Session {
pub(crate) fn mcp_elicitation_reviewer(self: &Arc<Self>) -> ElicitationReviewerHandle {
Arc::new(GuardianMcpElicitationReviewer::new(self))
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_mcp_server_elicitation(
&self,
turn_context: &TurnContext,
request_id: RequestId,
params: McpServerElicitationRequestParams,
) -> McpServerElicitationOutcome {
if self
.services
.mcp_connection_manager
.load_full()
.elicitations_auto_deny()
{
return McpServerElicitationOutcome {
response: Some(ElicitationResponse {
action: codex_rmcp_client::ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
}),
sent: false,
};
}
let server_name = params.server_name.clone();
let request = match params.request {
McpServerElicitationRequest::Form {
meta,
message,
requested_schema,
} => {
let requested_schema = match serde_json::to_value(requested_schema) {
Ok(requested_schema) => requested_schema,
Err(err) => {
warn!(
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
);
return McpServerElicitationOutcome {
response: None,
sent: false,
};
}
};
codex_protocol::approvals::ElicitationRequest::Form {
meta,
message,
requested_schema,
}
}
McpServerElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} => codex_protocol::approvals::ElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
},
};
let (tx_response, rx_response) = oneshot::channel();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_elicitation(
server_name.clone(),
request_id.clone(),
tx_response,
)
}
None => None,
}
};
if prev_entry.is_some() {
warn!(
"Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}"
);
}
let id = match request_id {
rmcp::model::NumberOrString::String(value) => {
codex_protocol::mcp::RequestId::String(value.to_string())
}
rmcp::model::NumberOrString::Number(value) => {
codex_protocol::mcp::RequestId::Integer(value)
}
};
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: params.turn_id,
server_name,
id,
request,
});
let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event);
turn_context
.turn_metadata_state
.mark_user_input_requested_during_turn();
self.send_event(turn_context, event).await;
if let Some(plugin_install_telemetry) = plugin_install_telemetry {
turn_context
.session_telemetry
.record_plugin_install_elicitation_sent(
plugin_install_telemetry.tool_type.as_str(),
plugin_install_telemetry.tool_id.as_str(),
plugin_install_telemetry.tool_name.as_str(),
);
}
McpServerElicitationOutcome {
response: rx_response.await.ok(),
sent: true,
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and manager fallback must stay serialized"
)]
pub async fn resolve_elicitation(
&self,
server_name: String,
id: RequestId,
response: ElicitationResponse,
) -> anyhow::Result<()> {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_elicitation(&server_name, &id)
}
None => None,
}
};
if let Some(tx_response) = entry {
tx_response
.send(response)
.map_err(|e| anyhow::anyhow!("failed to send elicitation response: {e:?}"))?;
return Ok(());
}
self.services
.mcp_connection_manager
.load_full()
.resolve_elicitation(server_name, id, response)
.await
}
pub async fn list_resources(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> anyhow::Result<ListResourcesResult> {
self.services
.mcp_connection_manager
.load_full()
.list_resources(server, params)
.await
}
pub async fn list_resource_templates(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> anyhow::Result<ListResourceTemplatesResult> {
self.services
.mcp_connection_manager
.load_full()
.list_resource_templates(server, params)
.await
}
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
) -> anyhow::Result<ReadResourceResult> {
self.services
.mcp_connection_manager
.load_full()
.read_resource(server, params)
.await
}
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> anyhow::Result<CallToolResult> {
self.services
.mcp_connection_manager
.load_full()
.call_tool(server, tool, arguments, meta)
.await
}
async fn refresh_mcp_servers_inner(
&self,
turn_context: &TurnContext,
mcp_servers: HashMap<String, McpServerConfig>,
store_mode: OAuthCredentialsStoreMode,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) {
let auth = self.services.auth_manager.auth().await;
let config = self.get_config().await;
let mcp_config = self
.services
.mcp_manager
.runtime_config(config.as_ref())
.await;
let tool_plugin_provenance = self
.services
.mcp_manager
.tool_plugin_provenance(config.as_ref())
.await;
let mcp_servers =
effective_mcp_servers_from_configured(mcp_servers, &mcp_config, auth.as_ref());
let host_owned_codex_apps_enabled =
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_context = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
#[allow(deprecated)]
turn_context.cwd.to_path_buf(),
),
};
let mcp_startup_cancellation_token = {
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
guard.cancel();
let cancellation_token = CancellationToken::new();
*guard = cancellation_token.clone();
cancellation_token
};
let refreshed_manager = McpConnectionManager::new(
&mcp_servers,
store_mode,
auth_statuses,
&turn_context.approval_policy,
turn_context.sub_id.clone(),
self.get_tx_event(),
mcp_startup_cancellation_token,
turn_context.permission_profile(),
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
tool_plugin_provenance,
auth.as_ref(),
elicitation_reviewer,
)
.await;
{
let current_manager = self.services.mcp_connection_manager.load_full();
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
}
self.services
.mcp_connection_manager
.store(Arc::new(refreshed_manager));
}
pub(crate) async fn refresh_mcp_servers_if_requested(
&self,
turn_context: &TurnContext,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) {
let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() };
let Some(refresh_config) = refresh_config else {
return;
};
let McpServerRefreshConfig {
mcp_servers,
mcp_oauth_credentials_store_mode,
} = refresh_config;
let mcp_servers =
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
Ok(servers) => servers,
Err(err) => {
warn!("failed to parse MCP server refresh config: {err}");
return;
}
};
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
mcp_oauth_credentials_store_mode,
) {
Ok(mode) => mode,
Err(err) => {
warn!("failed to parse MCP OAuth refresh config: {err}");
return;
}
};
self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer)
.await;
}
pub(crate) async fn refresh_mcp_servers_now(
&self,
turn_context: &TurnContext,
mcp_servers: HashMap<String, McpServerConfig>,
store_mode: OAuthCredentialsStoreMode,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) {
self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer)
.await;
}
#[cfg(test)]
pub(crate) async fn mcp_startup_cancellation_token(&self) -> CancellationToken {
self.services
.mcp_startup_cancellation_token
.lock()
.await
.clone()
}
pub(crate) async fn cancel_mcp_startup(&self) {
self.services
.mcp_startup_cancellation_token
.lock()
.await
.cancel();
}
}
async fn review_guardian_mcp_elicitation(
session: Arc<Session>,
request: ElicitationReviewRequest,
) -> anyhow::Result<Option<ElicitationResponse>> {
let Some((turn_context, _cancellation_token)) =
session.active_turn_context_and_cancellation_token().await
else {
return Ok(None);
};
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer(
turn_context.config.as_ref(),
request.server_name.as_str(),
elicitation_connector_id(&request.elicitation),
);
if !crate::guardian::routes_approval_to_guardian_with_reviewer(
turn_context.as_ref(),
approvals_reviewer,
) {
return Ok(None);
}
let guardian_request = match guardian_elicitation_review_request(&request) {
GuardianElicitationReview::NotRequested => return Ok(None),
GuardianElicitationReview::Decline(reason) => {
warn!(
server_name = %request.server_name,
request_id = %mcp_elicitation_request_id(&request.request_id),
reason,
"declining Guardian MCP elicitation before review"
);
return Ok(Some(mcp_elicitation_decline_without_message()));
}
GuardianElicitationReview::ApprovalRequest(guardian_request) => *guardian_request,
};
let review_id = crate::guardian::new_guardian_review_id();
let decision = crate::guardian::review_approval_request(
&session,
&turn_context,
review_id.clone(),
guardian_request,
/*retry_reason*/ None,
)
.await;
Ok(Some(
mcp_elicitation_response_from_guardian_decision(session.as_ref(), &review_id, decision)
.await,
))
}
fn guardian_elicitation_review_request(
request: &ElicitationReviewRequest,
) -> GuardianElicitationReview {
let (meta, requested_schema) = match &request.elicitation {
CreateElicitationRequestParams::FormElicitationParams {
meta,
requested_schema,
..
} => (meta, Some(requested_schema)),
CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => {
return if meta_requests_approval_request(meta) {
GuardianElicitationReview::Decline(
"guardian MCP elicitation review only supports form elicitations",
)
} else {
GuardianElicitationReview::NotRequested
};
}
};
let Some(meta) = meta.as_ref().map(|meta| &meta.0) else {
return GuardianElicitationReview::NotRequested;
};
if metadata_str(meta, MCP_ELICITATION_REQUEST_TYPE_KEY)
!= Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST)
{
return GuardianElicitationReview::NotRequested;
}
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
!= Some(MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL)
{
return GuardianElicitationReview::Decline(
"guardian MCP elicitation metadata must declare mcp_tool_call approval kind",
);
}
if requested_schema.is_some_and(|schema| !schema.properties.is_empty()) {
return GuardianElicitationReview::Decline(
"guardian MCP elicitation review only supports empty form schemas",
);
}
let Some(tool_name) = metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY) else {
return GuardianElicitationReview::Decline(
"guardian MCP elicitation metadata must include a non-empty tool_name",
);
};
let arguments = match meta.get(MCP_ELICITATION_TOOL_PARAMS_KEY) {
Some(value @ Value::Object(_)) => Some(value.clone()),
Some(_) => {
return GuardianElicitationReview::Decline(
"guardian MCP elicitation tool_params must be an object",
);
}
None => Some(Value::Object(Map::new())),
};
GuardianElicitationReview::ApprovalRequest(Box::new(
crate::guardian::GuardianApprovalRequest::McpToolCall {
id: format!(
"mcp_elicitation:{}:{}",
request.server_name,
mcp_elicitation_request_id(&request.request_id)
),
server: request.server_name.clone(),
tool_name,
arguments,
connector_id: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_ID_KEY),
connector_name: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_NAME_KEY),
connector_description: metadata_owned_string(
meta,
MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY,
),
tool_title: metadata_owned_string(meta, MCP_ELICITATION_TOOL_TITLE_KEY),
tool_description: metadata_owned_string(meta, MCP_ELICITATION_TOOL_DESCRIPTION_KEY),
annotations: None,
},
))
}
fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> {
match elicitation {
CreateElicitationRequestParams::FormElicitationParams { meta, .. }
| CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta
.as_ref()
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)),
}
}
fn meta_requests_approval_request(meta: &Option<Meta>) -> bool {
meta.as_ref()
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY))
== Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST)
}
fn metadata_str<'a>(meta: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
meta.get(key).and_then(Value::as_str)
}
fn metadata_owned_string(meta: &Map<String, Value>, key: &str) -> Option<String> {
metadata_str(meta, key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn plugin_install_elicitation_telemetry_metadata(
event: &EventMsg,
) -> Option<PluginInstallElicitationTelemetryMetadata> {
let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else {
return None;
};
let codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(Value::Object(meta)),
..
} = request
else {
return None;
};
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
!= Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION)
|| metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL)
{
return None;
}
Some(PluginInstallElicitationTelemetryMetadata {
tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?,
tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?,
tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?,
})
}
fn mcp_elicitation_request_id(id: &RequestId) -> String {
match id {
rmcp::model::NumberOrString::String(value) => value.to_string(),
rmcp::model::NumberOrString::Number(value) => value.to_string(),
}
}
async fn mcp_elicitation_response_from_guardian_decision(
session: &Session,
review_id: &str,
decision: ReviewDecision,
) -> ElicitationResponse {
let denial_message = match decision {
ReviewDecision::Denied => {
Some(crate::guardian::guardian_rejection_message(session, review_id).await)
}
_ => None,
};
mcp_elicitation_response_from_guardian_decision_parts(decision, denial_message)
}
fn mcp_elicitation_response_from_guardian_decision_parts(
decision: ReviewDecision,
denial_message: Option<String>,
) -> ElicitationResponse {
match decision {
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: Some(mcp_elicitation_auto_meta()),
},
ReviewDecision::Denied => mcp_elicitation_decline_with_message(
denial_message.unwrap_or_else(|| "Guardian denied this request.".to_string()),
),
ReviewDecision::TimedOut => {
mcp_elicitation_decline_with_message(crate::guardian::guardian_timeout_message())
}
ReviewDecision::Abort => ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
meta: Some(mcp_elicitation_auto_meta()),
},
}
}
fn mcp_elicitation_decline_with_message(message: String) -> ElicitationResponse {
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(serde_json::json!({
MCP_ELICITATION_DECLINE_MESSAGE_KEY: message,
MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview,
})),
}
}
fn mcp_elicitation_decline_without_message() -> ElicitationResponse {
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(mcp_elicitation_auto_meta()),
}
}
fn mcp_elicitation_auto_meta() -> serde_json::Value {
serde_json::json!({
MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview,
})
}
#[cfg(test)]
#[path = "mcp_tests.rs"]
mod tests;