Skip to content

Commit 3c5e12e

Browse files
authored
[MCP] Add auth status to MCP servers (#4918)
This adds a queryable auth status for MCP servers which is useful: 1. To determine whether a streamable HTTP server supports auth or not based on whether or not it supports RFC 8414-3.2 2. Allow us to build a better user experience on top of MCP status
1 parent c89229d commit 3c5e12e

File tree

14 files changed

+307
-28
lines changed

14 files changed

+307
-28
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use codex_core::config::load_global_mcp_servers;
1313
use codex_core::config::write_global_mcp_servers;
1414
use codex_core::config_types::McpServerConfig;
1515
use codex_core::config_types::McpServerTransportConfig;
16+
use codex_core::mcp::auth::compute_auth_statuses;
17+
use codex_core::protocol::McpAuthStatus;
1618
use codex_rmcp_client::delete_oauth_tokens;
1719
use codex_rmcp_client::perform_oauth_login;
1820

@@ -340,11 +342,20 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
340342

341343
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
342344
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
345+
let auth_statuses = compute_auth_statuses(
346+
config.mcp_servers.iter(),
347+
config.mcp_oauth_credentials_store_mode,
348+
)
349+
.await;
343350

344351
if list_args.json {
345352
let json_entries: Vec<_> = entries
346353
.into_iter()
347354
.map(|(name, cfg)| {
355+
let auth_status = auth_statuses
356+
.get(name.as_str())
357+
.copied()
358+
.unwrap_or(McpAuthStatus::Unsupported);
348359
let transport = match &cfg.transport {
349360
McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({
350361
"type": "stdio",
@@ -374,6 +385,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
374385
"tool_timeout_sec": cfg
375386
.tool_timeout_sec
376387
.map(|timeout| timeout.as_secs_f64()),
388+
"auth_status": auth_status,
377389
})
378390
})
379391
.collect();
@@ -387,8 +399,8 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
387399
return Ok(());
388400
}
389401

390-
let mut stdio_rows: Vec<[String; 5]> = Vec::new();
391-
let mut http_rows: Vec<[String; 4]> = Vec::new();
402+
let mut stdio_rows: Vec<[String; 6]> = Vec::new();
403+
let mut http_rows: Vec<[String; 5]> = Vec::new();
392404

393405
for (name, cfg) in entries {
394406
match &cfg.transport {
@@ -416,12 +428,18 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
416428
} else {
417429
"disabled".to_string()
418430
};
431+
let auth_status = auth_statuses
432+
.get(name.as_str())
433+
.copied()
434+
.unwrap_or(McpAuthStatus::Unsupported)
435+
.to_string();
419436
stdio_rows.push([
420437
name.clone(),
421438
command.clone(),
422439
args_display,
423440
env_display,
424441
status,
442+
auth_status,
425443
]);
426444
}
427445
McpServerTransportConfig::StreamableHttp {
@@ -433,11 +451,17 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
433451
} else {
434452
"disabled".to_string()
435453
};
454+
let auth_status = auth_statuses
455+
.get(name.as_str())
456+
.copied()
457+
.unwrap_or(McpAuthStatus::Unsupported)
458+
.to_string();
436459
http_rows.push([
437460
name.clone(),
438461
url.clone(),
439462
bearer_token_env_var.clone().unwrap_or("-".to_string()),
440463
status,
464+
auth_status,
441465
]);
442466
}
443467
}
@@ -450,6 +474,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
450474
"Args".len(),
451475
"Env".len(),
452476
"Status".len(),
477+
"Auth".len(),
453478
];
454479
for row in &stdio_rows {
455480
for (i, cell) in row.iter().enumerate() {
@@ -458,32 +483,36 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
458483
}
459484

460485
println!(
461-
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$} {:<status_w$}",
462-
"Name",
463-
"Command",
464-
"Args",
465-
"Env",
466-
"Status",
486+
"{name:<name_w$} {command:<cmd_w$} {args:<args_w$} {env:<env_w$} {status:<status_w$} {auth:<auth_w$}",
487+
name = "Name",
488+
command = "Command",
489+
args = "Args",
490+
env = "Env",
491+
status = "Status",
492+
auth = "Auth",
467493
name_w = widths[0],
468494
cmd_w = widths[1],
469495
args_w = widths[2],
470496
env_w = widths[3],
471497
status_w = widths[4],
498+
auth_w = widths[5],
472499
);
473500

474501
for row in &stdio_rows {
475502
println!(
476-
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$} {:<status_w$}",
477-
row[0],
478-
row[1],
479-
row[2],
480-
row[3],
481-
row[4],
503+
"{name:<name_w$} {command:<cmd_w$} {args:<args_w$} {env:<env_w$} {status:<status_w$} {auth:<auth_w$}",
504+
name = row[0].as_str(),
505+
command = row[1].as_str(),
506+
args = row[2].as_str(),
507+
env = row[3].as_str(),
508+
status = row[4].as_str(),
509+
auth = row[5].as_str(),
482510
name_w = widths[0],
483511
cmd_w = widths[1],
484512
args_w = widths[2],
485513
env_w = widths[3],
486514
status_w = widths[4],
515+
auth_w = widths[5],
487516
);
488517
}
489518
}
@@ -498,6 +527,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
498527
"Url".len(),
499528
"Bearer Token Env Var".len(),
500529
"Status".len(),
530+
"Auth".len(),
501531
];
502532
for row in &http_rows {
503533
for (i, cell) in row.iter().enumerate() {
@@ -506,28 +536,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
506536
}
507537

508538
println!(
509-
"{:<name_w$} {:<url_w$} {:<token_w$} {:<status_w$}",
510-
"Name",
511-
"Url",
512-
"Bearer Token Env Var",
513-
"Status",
539+
"{name:<name_w$} {url:<url_w$} {token:<token_w$} {status:<status_w$} {auth:<auth_w$}",
540+
name = "Name",
541+
url = "Url",
542+
token = "Bearer Token Env Var",
543+
status = "Status",
544+
auth = "Auth",
514545
name_w = widths[0],
515546
url_w = widths[1],
516547
token_w = widths[2],
517548
status_w = widths[3],
549+
auth_w = widths[4],
518550
);
519551

520552
for row in &http_rows {
521553
println!(
522-
"{:<name_w$} {:<url_w$} {:<token_w$} {:<status_w$}",
523-
row[0],
524-
row[1],
525-
row[2],
526-
row[3],
554+
"{name:<name_w$} {url:<url_w$} {token:<token_w$} {status:<status_w$} {auth:<auth_w$}",
555+
name = row[0].as_str(),
556+
url = row[1].as_str(),
557+
token = row[2].as_str(),
558+
status = row[3].as_str(),
559+
auth = row[4].as_str(),
527560
name_w = widths[0],
528561
url_w = widths[1],
529562
token_w = widths[2],
530563
status_w = widths[3],
564+
auth_w = widths[4],
531565
);
532566
}
533567
}

codex-rs/cli/tests/mcp_list.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ fn list_and_get_render_expected_output() -> Result<()> {
5555
assert!(stdout.contains("docs-server"));
5656
assert!(stdout.contains("TOKEN=secret"));
5757
assert!(stdout.contains("Status"));
58+
assert!(stdout.contains("Auth"));
5859
assert!(stdout.contains("enabled"));
60+
assert!(stdout.contains("Unsupported"));
5961

6062
let mut list_json_cmd = codex_command(codex_home.path())?;
6163
let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?;
@@ -80,7 +82,8 @@ fn list_and_get_render_expected_output() -> Result<()> {
8082
}
8183
},
8284
"startup_timeout_sec": null,
83-
"tool_timeout_sec": null
85+
"tool_timeout_sec": null,
86+
"auth_status": "unsupported"
8487
}
8588
]
8689
)

codex-rs/core/src/codex.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ use crate::exec_command::WriteStdinParams;
5757
use crate::executor::Executor;
5858
use crate::executor::ExecutorConfig;
5959
use crate::executor::normalize_exec_result;
60+
use crate::mcp::auth::compute_auth_statuses;
6061
use crate::mcp_connection_manager::McpConnectionManager;
6162
use crate::model_family::find_family_for_model;
6263
use crate::openai_model_info::get_model_info;
@@ -1403,10 +1404,18 @@ async fn submission_loop(
14031404

14041405
// This is a cheap lookup from the connection manager's cache.
14051406
let tools = sess.services.mcp_connection_manager.list_all_tools();
1407+
let auth_statuses = compute_auth_statuses(
1408+
config.mcp_servers.iter(),
1409+
config.mcp_oauth_credentials_store_mode,
1410+
)
1411+
.await;
14061412
let event = Event {
14071413
id: sub_id,
14081414
msg: EventMsg::McpListToolsResponse(
1409-
crate::protocol::McpListToolsResponseEvent { tools },
1415+
crate::protocol::McpListToolsResponseEvent {
1416+
tools,
1417+
auth_statuses,
1418+
},
14101419
),
14111420
};
14121421
sess.send_event(event).await;

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod executor;
3232
mod flags;
3333
pub mod git_info;
3434
pub mod landlock;
35+
pub mod mcp;
3536
mod mcp_connection_manager;
3637
mod mcp_tool_call;
3738
mod message_history;

codex-rs/core/src/mcp/auth.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use std::collections::HashMap;
2+
3+
use anyhow::Result;
4+
use codex_protocol::protocol::McpAuthStatus;
5+
use codex_rmcp_client::OAuthCredentialsStoreMode;
6+
use codex_rmcp_client::determine_streamable_http_auth_status;
7+
use futures::future::join_all;
8+
use tracing::warn;
9+
10+
use crate::config_types::McpServerConfig;
11+
use crate::config_types::McpServerTransportConfig;
12+
13+
pub async fn compute_auth_statuses<'a, I>(
14+
servers: I,
15+
store_mode: OAuthCredentialsStoreMode,
16+
) -> HashMap<String, McpAuthStatus>
17+
where
18+
I: IntoIterator<Item = (&'a String, &'a McpServerConfig)>,
19+
{
20+
let futures = servers.into_iter().map(|(name, config)| {
21+
let name = name.clone();
22+
let config = config.clone();
23+
async move {
24+
let status = match compute_auth_status(&name, &config, store_mode).await {
25+
Ok(status) => status,
26+
Err(error) => {
27+
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
28+
McpAuthStatus::Unsupported
29+
}
30+
};
31+
(name, status)
32+
}
33+
});
34+
35+
join_all(futures).await.into_iter().collect()
36+
}
37+
38+
async fn compute_auth_status(
39+
server_name: &str,
40+
config: &McpServerConfig,
41+
store_mode: OAuthCredentialsStoreMode,
42+
) -> Result<McpAuthStatus> {
43+
match &config.transport {
44+
McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported),
45+
McpServerTransportConfig::StreamableHttp {
46+
url,
47+
bearer_token_env_var,
48+
} => {
49+
determine_streamable_http_auth_status(
50+
server_name,
51+
url,
52+
bearer_token_env_var.as_deref(),
53+
store_mode,
54+
)
55+
.await
56+
}
57+
}
58+
}

codex-rs/core/src/mcp/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod auth;

codex-rs/protocol/src/protocol.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,30 @@ pub struct GetHistoryEntryResponseEvent {
12431243
pub struct McpListToolsResponseEvent {
12441244
/// Fully qualified tool name -> tool definition.
12451245
pub tools: std::collections::HashMap<String, McpTool>,
1246+
/// Authentication status for each configured MCP server.
1247+
pub auth_statuses: std::collections::HashMap<String, McpAuthStatus>,
1248+
}
1249+
1250+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1251+
#[serde(rename_all = "snake_case")]
1252+
#[ts(rename_all = "snake_case")]
1253+
pub enum McpAuthStatus {
1254+
Unsupported,
1255+
NotLoggedIn,
1256+
BearerToken,
1257+
OAuth,
1258+
}
1259+
1260+
impl fmt::Display for McpAuthStatus {
1261+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1262+
let text = match self {
1263+
McpAuthStatus::Unsupported => "Unsupported",
1264+
McpAuthStatus::NotLoggedIn => "Not logged in",
1265+
McpAuthStatus::BearerToken => "Bearer token",
1266+
McpAuthStatus::OAuth => "OAuth",
1267+
};
1268+
f.write_str(text)
1269+
}
12461270
}
12471271

12481272
/// Response payload for `Op::ListCustomPrompts`.

codex-rs/rmcp-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ axum = { workspace = true, default-features = false, features = [
1212
"http1",
1313
"tokio",
1414
] }
15+
codex-protocol = { workspace = true }
1516
keyring = { workspace = true, features = [
1617
"apple-native",
1718
"crypto-rust",

0 commit comments

Comments
 (0)