Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions codex-rs/codex-mcp/src/connection_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use codex_config::McpServerAuth;
use codex_config::McpServerTransportConfig;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
Expand Down Expand Up @@ -141,6 +142,7 @@ impl McpConnectionManager {
supports_openai_form_elicitation: bool,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
codex_apps_auth_manager: Option<Arc<AuthManager>>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
elicitation_router: ElicitationRequestRouter,
Expand All @@ -163,9 +165,14 @@ impl McpConnectionManager {
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id.clone();
let chatgpt_auth_provider = auth
let static_chatgpt_auth_provider = auth
.filter(|auth| auth.uses_codex_backend())
.map(codex_model_provider::auth_provider_from_auth);
let codex_apps_auth_provider = codex_apps_auth_manager.and_then(|auth_manager| {
auth.filter(|auth| auth.uses_codex_backend()).map(|auth| {
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
})
});
let mcp_servers = mcp_servers.clone();
for (server_name, server) in mcp_servers
.into_iter()
Expand Down Expand Up @@ -200,13 +207,24 @@ impl McpConnectionManager {
codex_apps_tools_cache
.context(codex_home.clone(), codex_apps_tools_cache_key.clone())
});
// The reserved Codex Apps registration follows the shared
// AuthManager across refreshes. In the hosted-plugin path, this
// is the ChatGPT /ps/mcp connection. User-configured MCP
// registrations keep their existing configured auth path.
let chatgpt_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME {
codex_apps_auth_provider
.clone()
.or_else(|| static_chatgpt_auth_provider.clone())
} else {
static_chatgpt_auth_provider.clone()
};
// If Codex Apps has an env bearer token, that is its auth path. Do
// not also attach the ambient CodexAuth provider.
let runtime_auth_provider =
if server_name == CODEX_APPS_MCP_SERVER_NAME && uses_env_bearer_token {
None
} else {
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider.clone())
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider)
};
let async_managed_client = AsyncManagedClient::new(
server_name.clone(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/codex-mcp/src/connection_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
/*auth*/ None,
/*codex_apps_auth_manager*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
ElicitationRequestRouter::default(),
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/codex-mcp/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ pub async fn read_mcp_resource(
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance(config),
auth,
/*codex_apps_auth_manager*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
Expand Down Expand Up @@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance,
auth,
/*codex_apps_auth_manager*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/core/src/connectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
drop(rx_event);

let cancel_token = CancellationToken::new();
let codex_apps_auth_manager =
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
.then(|| Arc::clone(&auth_manager));
let mcp_connection_manager = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
Expand All @@ -269,6 +272,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
auth.as_ref(),
codex_apps_auth_manager,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/mcp_tool_call_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,7 @@ async fn host_owned_codex_apps_manager(
/*supports_openai_form_elicitation*/ false,
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
/*codex_apps_auth_manager*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/core/src/session/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,9 @@ impl Session {
cancellation_token
};
let current_runtime = self.services.latest_mcp_runtime();
let codex_apps_auth_manager =
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
.then(|| Arc::clone(&self.services.auth_manager));
let refreshed_manager = McpConnectionManager::new(
&mcp_servers,
mcp_config.mcp_oauth_credentials_store_mode,
Expand All @@ -382,6 +385,7 @@ impl Session {
.load(std::sync::atomic::Ordering::Relaxed),
tool_plugin_provenance,
auth.as_ref(),
codex_apps_auth_manager,
elicitation_reviewer,
Some(self.mcp_elicitation_lifecycle()),
current_runtime.manager().elicitation_router(),
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/core/src/session/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,9 @@ impl Session {
*cancel_guard = cancel_token.clone();
cancel_token
};
let codex_apps_auth_manager =
codex_mcp::host_owned_codex_apps_enabled(&mcp_projection.config, auth)
.then(|| Arc::clone(&sess.services.auth_manager));
let mcp_connection_manager = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
Expand All @@ -1218,6 +1221,7 @@ impl Session {
.load(std::sync::atomic::Ordering::Relaxed),
tool_plugin_provenance,
auth,
codex_apps_auth_manager,
Some(sess.mcp_elicitation_reviewer()),
Some(sess.mcp_elicitation_lifecycle()),
codex_mcp::ElicitationRequestRouter::default(),
Expand Down
46 changes: 45 additions & 1 deletion codex-rs/core/tests/common/apps_test_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
const CODEX_APPS_META_KEY: &str = "_codex_apps";
const CODEX_APPS_MCP_PATH_REGEX: &str = "^/api/codex/apps/?$";
const HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX: &str = "^/api/codex/ps/mcp/?$";
const PROTOCOL_VERSION: &str = "2025-11-25";
const SERVER_NAME: &str = "codex-apps-test";
const SERVER_VERSION: &str = "1.0.0";
Expand Down Expand Up @@ -108,6 +110,24 @@ impl AppsTestServer {
})
}

pub async fn mount_hosted_plugin_runtime_searchable(server: &MockServer) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_connectors_directory(server).await;
mount_streamable_http_json_rpc_at_path(
server,
HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX,
CONNECTOR_NAME.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
/*searchable*/ true,
/*include_app_only_tool*/ false,
AppsTestToolsListBehavior::AlwaysAvailable,
)
.await;
Ok(Self {
chatgpt_base_url: server.uri(),
})
}

pub async fn mount_with_connector_name(
server: &MockServer,
connector_name: &str,
Expand Down Expand Up @@ -159,6 +179,7 @@ impl AppsTestServer {
};
mount_streamable_http_json_rpc_with_startup_control(
server,
CODEX_APPS_MCP_PATH_REGEX,
CONNECTOR_NAME.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
/*searchable*/ true,
Expand Down Expand Up @@ -358,9 +379,31 @@ async fn mount_streamable_http_json_rpc(
searchable: bool,
include_app_only_tool: bool,
tools_list_behavior: AppsTestToolsListBehavior,
) {
mount_streamable_http_json_rpc_at_path(
server,
CODEX_APPS_MCP_PATH_REGEX,
connector_name,
connector_description,
searchable,
include_app_only_tool,
tools_list_behavior,
)
.await;
}

async fn mount_streamable_http_json_rpc_at_path(
server: &MockServer,
mcp_path_regex: &str,
connector_name: String,
connector_description: String,
searchable: bool,
include_app_only_tool: bool,
tools_list_behavior: AppsTestToolsListBehavior,
) {
mount_streamable_http_json_rpc_with_startup_control(
server,
mcp_path_regex,
connector_name,
connector_description,
searchable,
Expand All @@ -375,6 +418,7 @@ async fn mount_streamable_http_json_rpc(
#[allow(clippy::too_many_arguments)]
async fn mount_streamable_http_json_rpc_with_startup_control(
server: &MockServer,
mcp_path_regex: &str,
connector_name: String,
connector_description: String,
searchable: bool,
Expand All @@ -384,7 +428,7 @@ async fn mount_streamable_http_json_rpc_with_startup_control(
remaining_initialize_failures: Option<Arc<AtomicUsize>>,
) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
.and(path_regex(mcp_path_regex))
.respond_with(CodexAppsJsonRpcResponder {
connector_name,
connector_description,
Expand Down
160 changes: 160 additions & 0 deletions codex-rs/core/tests/suite/mcp_auth_refresh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#![allow(clippy::unwrap_used)]

use anyhow::Result;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_core::config::Constrained;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::CodexAppsToolsCache;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::ElicitationRequestRouter;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeContext;
use codex_mcp::ToolPluginProvenance;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use rmcp::model::ElicitationCapability;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_reloaded_auth_manager_token() -> Result<()> {
skip_if_no_network!(Ok(()));

let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_hosted_plugin_runtime_searchable(&server).await?;
let home = Arc::new(TempDir::new()?);
login_with_chatgpt_auth_tokens(
home.path(),
"header.e30.first",
"test-account",
/*chatgpt_plan_type*/ None,
)?;
let auth_manager = Arc::new(
AuthManager::new(
home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::Ephemeral,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
let expected_auth = auth_manager
.auth_cached()
.expect("initial auth should be cached");
// Build the hosted-plugin config directly so the local test origin can
// exercise the connection-manager auth path. Effective server resolution
// correctly strips ChatGPT auth from untrusted localhost origins.
let mut hosted_plugin_runtime_config = codex_mcp::hosted_plugin_runtime_mcp_server_config(
&apps_server.chatgpt_base_url,
/*apps_mcp_product_sku*/ None,
);
let McpServerTransportConfig::StreamableHttp {
bearer_token_env_var,
..
} = &mut hosted_plugin_runtime_config.transport
else {
panic!("hosted plugin runtime should use streamable HTTP");
};
// Keep the test on the AuthManager path even if the developer has the
// debug bearer override in their environment.
*bearer_token_env_var = None;
let mcp_servers = HashMap::from([(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
EffectiveMcpServer::configured(hosted_plugin_runtime_config),
)]);
let (tx_event, rx_event) = async_channel::unbounded();
drop(rx_event);
let approval_policy = Constrained::allow_any(AskForApproval::Never);
let manager = McpConnectionManager::new(
&mcp_servers,
OAuthCredentialsStoreMode::default(),
AuthKeyringBackendKind::default(),
HashMap::new(),
&approval_policy,
"test".to_string(),
tx_event,
CancellationToken::new(),
PermissionProfile::default(),
McpRuntimeContext::new(
Arc::new(codex_exec_server::EnvironmentManager::without_environments()),
home.path().to_path_buf(),
),
home.path().to_path_buf(),
CodexAppsToolsCache::default(),
codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
/*prefix_mcp_tool_names*/ true,
ElicitationCapability::default(),
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
Some(&expected_auth),
Some(Arc::clone(&auth_manager)),
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
ElicitationRequestRouter::default(),
)
.await;
login_with_chatgpt_auth_tokens(
home.path(),
"header.e30.reloaded",
"test-account",
/*chatgpt_plan_type*/ None,
)?;
auth_manager.reload().await;

// The manager and its static fallback were created before reload, so this
// tool call only sees the new token if the Codex Apps provider reads the
// shared AuthManager at request time.
let tool_result = manager
.call_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some(json!({
"title": "Lunch",
"starts_at": "2026-06-18T12:00:00Z",
})),
/*meta*/ None,
)
.await?;
assert_eq!(tool_result.is_error, Some(false));

let requests = server
.received_requests()
.await
.expect("mock server should capture tool-call requests");
let tool_call_request = requests
.iter()
.find(|request| {
request.url.path() == "/api/codex/ps/mcp"
&& serde_json::from_slice::<Value>(&request.body)
.ok()
.is_some_and(|body| {
body.get("method").and_then(Value::as_str) == Some("tools/call")
})
})
.expect("Codex Apps should receive a tool call");
assert_eq!(
tool_call_request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer header.e30.reloaded")
);

Ok(())
}
Loading
Loading