Skip to content

Commit 496cb80

Browse files
authored
[MCP] Add the ability to explicitly specify a credentials store (#4857)
This lets users/companies explicitly choose whether to force/disallow the keyring/fallback file storage for mcp credentials. People who develop with Codex will want to use this until we sign binaries or else each ad-hoc debug builds will require keychain access on every build. I don't love this and am open to other ideas for how to handle that. ```toml mcp_oauth_credentials_store = "auto" mcp_oauth_credentials_store = "file" mcp_oauth_credentials_store = "keyrung" ``` Defaults to `auto`
1 parent abd5170 commit 496cb80

File tree

8 files changed

+312
-55
lines changed

8 files changed

+312
-55
lines changed

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
236236
_ => bail!("OAuth login is only supported for streamable HTTP servers."),
237237
};
238238

239-
perform_oauth_login(&name, &url).await?;
239+
perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?;
240240
println!("Successfully logged in to MCP server '{name}'.");
241241
Ok(())
242242
}
@@ -259,7 +259,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
259259
_ => bail!("OAuth logout is only supported for streamable_http transports."),
260260
};
261261

262-
match delete_oauth_tokens(&name, &url) {
262+
match delete_oauth_tokens(&name, &url, config.mcp_oauth_credentials_store_mode) {
263263
Ok(true) => println!("Removed OAuth credentials for '{name}'."),
264264
Ok(false) => println!("No OAuth credentials stored for '{name}'."),
265265
Err(err) => return Err(anyhow!("failed to delete OAuth credentials: {err}")),

codex-rs/core/src/codex.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ impl Session {
364364
let mcp_fut = McpConnectionManager::new(
365365
config.mcp_servers.clone(),
366366
config.use_experimental_use_rmcp_client,
367+
config.mcp_oauth_credentials_store_mode,
367368
);
368369
let default_shell_fut = shell::default_user_shell();
369370
let history_meta_fut = crate::message_history::history_metadata(&config);

codex-rs/core/src/config.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use codex_protocol::config_types::ReasoningEffort;
3333
use codex_protocol::config_types::ReasoningSummary;
3434
use codex_protocol::config_types::SandboxMode;
3535
use codex_protocol::config_types::Verbosity;
36+
use codex_rmcp_client::OAuthCredentialsStoreMode;
3637
use dirs::home_dir;
3738
use serde::Deserialize;
3839
use std::collections::BTreeMap;
@@ -142,6 +143,15 @@ pub struct Config {
142143
/// Definition for MCP servers that Codex can reach out to for tool calls.
143144
pub mcp_servers: HashMap<String, McpServerConfig>,
144145

146+
/// Preferred store for MCP OAuth credentials.
147+
/// keyring: Use an OS-specific keyring service.
148+
/// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.
149+
/// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2
150+
/// file: CODEX_HOME/.credentials.json
151+
/// This file will be readable to Codex and other applications running as the same user.
152+
/// auto (default): keyring if available, otherwise file.
153+
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
154+
145155
/// Combined provider map (defaults merged with user-defined overrides).
146156
pub model_providers: HashMap<String, ModelProviderInfo>,
147157

@@ -694,6 +704,14 @@ pub struct ConfigToml {
694704
#[serde(default)]
695705
pub mcp_servers: HashMap<String, McpServerConfig>,
696706

707+
/// Preferred backend for storing MCP OAuth credentials.
708+
/// keyring: Use an OS-specific keyring service.
709+
/// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2
710+
/// file: Use a file in the Codex home directory.
711+
/// auto (default): Use the OS-specific keyring service if available, otherwise use a file.
712+
#[serde(default)]
713+
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
714+
697715
/// User-defined provider entries that extend/override the built-in list.
698716
#[serde(default)]
699717
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -1074,6 +1092,9 @@ impl Config {
10741092
user_instructions,
10751093
base_instructions,
10761094
mcp_servers: cfg.mcp_servers,
1095+
// The config.toml omits "_mode" because it's a config file. However, "_mode"
1096+
// is important in code to differentiate the mode from the store implementation.
1097+
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
10771098
model_providers,
10781099
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
10791100
project_doc_fallback_filenames: cfg
@@ -1364,6 +1385,85 @@ exclude_slash_tmp = true
13641385
);
13651386
}
13661387

1388+
#[test]
1389+
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
1390+
let codex_home = TempDir::new()?;
1391+
let cfg = ConfigToml::default();
1392+
1393+
let config = Config::load_from_base_config_with_overrides(
1394+
cfg,
1395+
ConfigOverrides::default(),
1396+
codex_home.path().to_path_buf(),
1397+
)?;
1398+
1399+
assert_eq!(
1400+
config.mcp_oauth_credentials_store_mode,
1401+
OAuthCredentialsStoreMode::Auto,
1402+
);
1403+
1404+
Ok(())
1405+
}
1406+
1407+
#[test]
1408+
fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> {
1409+
let codex_home = TempDir::new()?;
1410+
let cfg = ConfigToml {
1411+
mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::File),
1412+
..Default::default()
1413+
};
1414+
1415+
let config = Config::load_from_base_config_with_overrides(
1416+
cfg,
1417+
ConfigOverrides::default(),
1418+
codex_home.path().to_path_buf(),
1419+
)?;
1420+
1421+
assert_eq!(
1422+
config.mcp_oauth_credentials_store_mode,
1423+
OAuthCredentialsStoreMode::File,
1424+
);
1425+
1426+
Ok(())
1427+
}
1428+
1429+
#[tokio::test]
1430+
async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
1431+
let codex_home = TempDir::new()?;
1432+
let managed_path = codex_home.path().join("managed_config.toml");
1433+
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
1434+
1435+
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
1436+
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
1437+
1438+
let overrides = crate::config_loader::LoaderOverrides {
1439+
managed_config_path: Some(managed_path.clone()),
1440+
#[cfg(target_os = "macos")]
1441+
managed_preferences_base64: None,
1442+
};
1443+
1444+
let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?;
1445+
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
1446+
tracing::error!("Failed to deserialize overridden config: {e}");
1447+
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
1448+
})?;
1449+
assert_eq!(
1450+
cfg.mcp_oauth_credentials_store,
1451+
Some(OAuthCredentialsStoreMode::Keyring),
1452+
);
1453+
1454+
let final_config = Config::load_from_base_config_with_overrides(
1455+
cfg,
1456+
ConfigOverrides::default(),
1457+
codex_home.path().to_path_buf(),
1458+
)?;
1459+
assert_eq!(
1460+
final_config.mcp_oauth_credentials_store_mode,
1461+
OAuthCredentialsStoreMode::Keyring,
1462+
);
1463+
1464+
Ok(())
1465+
}
1466+
13671467
#[tokio::test]
13681468
async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> {
13691469
let codex_home = TempDir::new()?;
@@ -1896,6 +1996,7 @@ model_verbosity = "high"
18961996
notify: None,
18971997
cwd: fixture.cwd(),
18981998
mcp_servers: HashMap::new(),
1999+
mcp_oauth_credentials_store_mode: Default::default(),
18992000
model_providers: fixture.model_provider_map.clone(),
19002001
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
19012002
project_doc_fallback_filenames: Vec::new(),
@@ -1958,6 +2059,7 @@ model_verbosity = "high"
19582059
notify: None,
19592060
cwd: fixture.cwd(),
19602061
mcp_servers: HashMap::new(),
2062+
mcp_oauth_credentials_store_mode: Default::default(),
19612063
model_providers: fixture.model_provider_map.clone(),
19622064
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
19632065
project_doc_fallback_filenames: Vec::new(),
@@ -2035,6 +2137,7 @@ model_verbosity = "high"
20352137
notify: None,
20362138
cwd: fixture.cwd(),
20372139
mcp_servers: HashMap::new(),
2140+
mcp_oauth_credentials_store_mode: Default::default(),
20382141
model_providers: fixture.model_provider_map.clone(),
20392142
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
20402143
project_doc_fallback_filenames: Vec::new(),
@@ -2098,6 +2201,7 @@ model_verbosity = "high"
20982201
notify: None,
20992202
cwd: fixture.cwd(),
21002203
mcp_servers: HashMap::new(),
2204+
mcp_oauth_credentials_store_mode: Default::default(),
21012205
model_providers: fixture.model_provider_map.clone(),
21022206
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
21032207
project_doc_fallback_filenames: Vec::new(),

codex-rs/core/src/mcp_connection_manager.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use anyhow::Context;
1616
use anyhow::Result;
1717
use anyhow::anyhow;
1818
use codex_mcp_client::McpClient;
19+
use codex_rmcp_client::OAuthCredentialsStoreMode;
1920
use codex_rmcp_client::RmcpClient;
2021
use mcp_types::ClientCapabilities;
2122
use mcp_types::Implementation;
@@ -125,9 +126,11 @@ impl McpClientAdapter {
125126
bearer_token: Option<String>,
126127
params: mcp_types::InitializeRequestParams,
127128
startup_timeout: Duration,
129+
store_mode: OAuthCredentialsStoreMode,
128130
) -> Result<Self> {
129131
let client = Arc::new(
130-
RmcpClient::new_streamable_http_client(&server_name, &url, bearer_token).await?,
132+
RmcpClient::new_streamable_http_client(&server_name, &url, bearer_token, store_mode)
133+
.await?,
131134
);
132135
client.initialize(params, Some(startup_timeout)).await?;
133136
Ok(McpClientAdapter::Rmcp(client))
@@ -182,6 +185,7 @@ impl McpConnectionManager {
182185
pub async fn new(
183186
mcp_servers: HashMap<String, McpServerConfig>,
184187
use_rmcp_client: bool,
188+
store_mode: OAuthCredentialsStoreMode,
185189
) -> Result<(Self, ClientStartErrors)> {
186190
// Early exit if no servers are configured.
187191
if mcp_servers.is_empty() {
@@ -249,6 +253,7 @@ impl McpConnectionManager {
249253
bearer_token,
250254
params,
251255
startup_timeout,
256+
store_mode,
252257
)
253258
.await
254259
}

codex-rs/rmcp-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod perform_oauth_login;
55
mod rmcp_client;
66
mod utils;
77

8+
pub use oauth::OAuthCredentialsStoreMode;
89
pub use oauth::StoredOAuthTokens;
910
pub use oauth::WrappedOAuthTokenResponse;
1011
pub use oauth::delete_oauth_tokens;

0 commit comments

Comments
 (0)