[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
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1352,6 +1352,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"codex-protocol",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures",
|
"futures",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use codex_core::config::load_global_mcp_servers;
|
|||||||
use codex_core::config::write_global_mcp_servers;
|
use codex_core::config::write_global_mcp_servers;
|
||||||
use codex_core::config_types::McpServerConfig;
|
use codex_core::config_types::McpServerConfig;
|
||||||
use codex_core::config_types::McpServerTransportConfig;
|
use codex_core::config_types::McpServerTransportConfig;
|
||||||
|
use codex_core::mcp::auth::compute_auth_statuses;
|
||||||
|
use codex_core::protocol::McpAuthStatus;
|
||||||
use codex_rmcp_client::delete_oauth_tokens;
|
use codex_rmcp_client::delete_oauth_tokens;
|
||||||
use codex_rmcp_client::perform_oauth_login;
|
use codex_rmcp_client::perform_oauth_login;
|
||||||
|
|
||||||
@@ -340,11 +342,20 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
|
|
||||||
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
|
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
|
||||||
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||||
|
let auth_statuses = compute_auth_statuses(
|
||||||
|
config.mcp_servers.iter(),
|
||||||
|
config.mcp_oauth_credentials_store_mode,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if list_args.json {
|
if list_args.json {
|
||||||
let json_entries: Vec<_> = entries
|
let json_entries: Vec<_> = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, cfg)| {
|
.map(|(name, cfg)| {
|
||||||
|
let auth_status = auth_statuses
|
||||||
|
.get(name.as_str())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(McpAuthStatus::Unsupported);
|
||||||
let transport = match &cfg.transport {
|
let transport = match &cfg.transport {
|
||||||
McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({
|
McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
@@ -374,6 +385,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
"tool_timeout_sec": cfg
|
"tool_timeout_sec": cfg
|
||||||
.tool_timeout_sec
|
.tool_timeout_sec
|
||||||
.map(|timeout| timeout.as_secs_f64()),
|
.map(|timeout| timeout.as_secs_f64()),
|
||||||
|
"auth_status": auth_status,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -387,8 +399,8 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stdio_rows: Vec<[String; 5]> = Vec::new();
|
let mut stdio_rows: Vec<[String; 6]> = Vec::new();
|
||||||
let mut http_rows: Vec<[String; 4]> = Vec::new();
|
let mut http_rows: Vec<[String; 5]> = Vec::new();
|
||||||
|
|
||||||
for (name, cfg) in entries {
|
for (name, cfg) in entries {
|
||||||
match &cfg.transport {
|
match &cfg.transport {
|
||||||
@@ -416,12 +428,18 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
} else {
|
} else {
|
||||||
"disabled".to_string()
|
"disabled".to_string()
|
||||||
};
|
};
|
||||||
|
let auth_status = auth_statuses
|
||||||
|
.get(name.as_str())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(McpAuthStatus::Unsupported)
|
||||||
|
.to_string();
|
||||||
stdio_rows.push([
|
stdio_rows.push([
|
||||||
name.clone(),
|
name.clone(),
|
||||||
command.clone(),
|
command.clone(),
|
||||||
args_display,
|
args_display,
|
||||||
env_display,
|
env_display,
|
||||||
status,
|
status,
|
||||||
|
auth_status,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
McpServerTransportConfig::StreamableHttp {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
@@ -433,11 +451,17 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
} else {
|
} else {
|
||||||
"disabled".to_string()
|
"disabled".to_string()
|
||||||
};
|
};
|
||||||
|
let auth_status = auth_statuses
|
||||||
|
.get(name.as_str())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(McpAuthStatus::Unsupported)
|
||||||
|
.to_string();
|
||||||
http_rows.push([
|
http_rows.push([
|
||||||
name.clone(),
|
name.clone(),
|
||||||
url.clone(),
|
url.clone(),
|
||||||
bearer_token_env_var.clone().unwrap_or("-".to_string()),
|
bearer_token_env_var.clone().unwrap_or("-".to_string()),
|
||||||
status,
|
status,
|
||||||
|
auth_status,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,6 +474,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
"Args".len(),
|
"Args".len(),
|
||||||
"Env".len(),
|
"Env".len(),
|
||||||
"Status".len(),
|
"Status".len(),
|
||||||
|
"Auth".len(),
|
||||||
];
|
];
|
||||||
for row in &stdio_rows {
|
for row in &stdio_rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
@@ -458,32 +483,36 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$} {:<status_w$}",
|
"{name:<name_w$} {command:<cmd_w$} {args:<args_w$} {env:<env_w$} {status:<status_w$} {auth:<auth_w$}",
|
||||||
"Name",
|
name = "Name",
|
||||||
"Command",
|
command = "Command",
|
||||||
"Args",
|
args = "Args",
|
||||||
"Env",
|
env = "Env",
|
||||||
"Status",
|
status = "Status",
|
||||||
|
auth = "Auth",
|
||||||
name_w = widths[0],
|
name_w = widths[0],
|
||||||
cmd_w = widths[1],
|
cmd_w = widths[1],
|
||||||
args_w = widths[2],
|
args_w = widths[2],
|
||||||
env_w = widths[3],
|
env_w = widths[3],
|
||||||
status_w = widths[4],
|
status_w = widths[4],
|
||||||
|
auth_w = widths[5],
|
||||||
);
|
);
|
||||||
|
|
||||||
for row in &stdio_rows {
|
for row in &stdio_rows {
|
||||||
println!(
|
println!(
|
||||||
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$} {:<status_w$}",
|
"{name:<name_w$} {command:<cmd_w$} {args:<args_w$} {env:<env_w$} {status:<status_w$} {auth:<auth_w$}",
|
||||||
row[0],
|
name = row[0].as_str(),
|
||||||
row[1],
|
command = row[1].as_str(),
|
||||||
row[2],
|
args = row[2].as_str(),
|
||||||
row[3],
|
env = row[3].as_str(),
|
||||||
row[4],
|
status = row[4].as_str(),
|
||||||
|
auth = row[5].as_str(),
|
||||||
name_w = widths[0],
|
name_w = widths[0],
|
||||||
cmd_w = widths[1],
|
cmd_w = widths[1],
|
||||||
args_w = widths[2],
|
args_w = widths[2],
|
||||||
env_w = widths[3],
|
env_w = widths[3],
|
||||||
status_w = widths[4],
|
status_w = widths[4],
|
||||||
|
auth_w = widths[5],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +527,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
"Url".len(),
|
"Url".len(),
|
||||||
"Bearer Token Env Var".len(),
|
"Bearer Token Env Var".len(),
|
||||||
"Status".len(),
|
"Status".len(),
|
||||||
|
"Auth".len(),
|
||||||
];
|
];
|
||||||
for row in &http_rows {
|
for row in &http_rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
@@ -506,28 +536,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{:<name_w$} {:<url_w$} {:<token_w$} {:<status_w$}",
|
"{name:<name_w$} {url:<url_w$} {token:<token_w$} {status:<status_w$} {auth:<auth_w$}",
|
||||||
"Name",
|
name = "Name",
|
||||||
"Url",
|
url = "Url",
|
||||||
"Bearer Token Env Var",
|
token = "Bearer Token Env Var",
|
||||||
"Status",
|
status = "Status",
|
||||||
|
auth = "Auth",
|
||||||
name_w = widths[0],
|
name_w = widths[0],
|
||||||
url_w = widths[1],
|
url_w = widths[1],
|
||||||
token_w = widths[2],
|
token_w = widths[2],
|
||||||
status_w = widths[3],
|
status_w = widths[3],
|
||||||
|
auth_w = widths[4],
|
||||||
);
|
);
|
||||||
|
|
||||||
for row in &http_rows {
|
for row in &http_rows {
|
||||||
println!(
|
println!(
|
||||||
"{:<name_w$} {:<url_w$} {:<token_w$} {:<status_w$}",
|
"{name:<name_w$} {url:<url_w$} {token:<token_w$} {status:<status_w$} {auth:<auth_w$}",
|
||||||
row[0],
|
name = row[0].as_str(),
|
||||||
row[1],
|
url = row[1].as_str(),
|
||||||
row[2],
|
token = row[2].as_str(),
|
||||||
row[3],
|
status = row[3].as_str(),
|
||||||
|
auth = row[4].as_str(),
|
||||||
name_w = widths[0],
|
name_w = widths[0],
|
||||||
url_w = widths[1],
|
url_w = widths[1],
|
||||||
token_w = widths[2],
|
token_w = widths[2],
|
||||||
status_w = widths[3],
|
status_w = widths[3],
|
||||||
|
auth_w = widths[4],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ fn list_and_get_render_expected_output() -> Result<()> {
|
|||||||
assert!(stdout.contains("docs-server"));
|
assert!(stdout.contains("docs-server"));
|
||||||
assert!(stdout.contains("TOKEN=secret"));
|
assert!(stdout.contains("TOKEN=secret"));
|
||||||
assert!(stdout.contains("Status"));
|
assert!(stdout.contains("Status"));
|
||||||
|
assert!(stdout.contains("Auth"));
|
||||||
assert!(stdout.contains("enabled"));
|
assert!(stdout.contains("enabled"));
|
||||||
|
assert!(stdout.contains("Unsupported"));
|
||||||
|
|
||||||
let mut list_json_cmd = codex_command(codex_home.path())?;
|
let mut list_json_cmd = codex_command(codex_home.path())?;
|
||||||
let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?;
|
let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?;
|
||||||
@@ -80,7 +82,8 @@ fn list_and_get_render_expected_output() -> Result<()> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"startup_timeout_sec": null,
|
"startup_timeout_sec": null,
|
||||||
"tool_timeout_sec": null
|
"tool_timeout_sec": null,
|
||||||
|
"auth_status": "unsupported"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ use crate::exec_command::WriteStdinParams;
|
|||||||
use crate::executor::Executor;
|
use crate::executor::Executor;
|
||||||
use crate::executor::ExecutorConfig;
|
use crate::executor::ExecutorConfig;
|
||||||
use crate::executor::normalize_exec_result;
|
use crate::executor::normalize_exec_result;
|
||||||
|
use crate::mcp::auth::compute_auth_statuses;
|
||||||
use crate::mcp_connection_manager::McpConnectionManager;
|
use crate::mcp_connection_manager::McpConnectionManager;
|
||||||
use crate::model_family::find_family_for_model;
|
use crate::model_family::find_family_for_model;
|
||||||
use crate::openai_model_info::get_model_info;
|
use crate::openai_model_info::get_model_info;
|
||||||
@@ -1403,10 +1404,18 @@ async fn submission_loop(
|
|||||||
|
|
||||||
// This is a cheap lookup from the connection manager's cache.
|
// This is a cheap lookup from the connection manager's cache.
|
||||||
let tools = sess.services.mcp_connection_manager.list_all_tools();
|
let tools = sess.services.mcp_connection_manager.list_all_tools();
|
||||||
|
let auth_statuses = compute_auth_statuses(
|
||||||
|
config.mcp_servers.iter(),
|
||||||
|
config.mcp_oauth_credentials_store_mode,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let event = Event {
|
let event = Event {
|
||||||
id: sub_id,
|
id: sub_id,
|
||||||
msg: EventMsg::McpListToolsResponse(
|
msg: EventMsg::McpListToolsResponse(
|
||||||
crate::protocol::McpListToolsResponseEvent { tools },
|
crate::protocol::McpListToolsResponseEvent {
|
||||||
|
tools,
|
||||||
|
auth_statuses,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
sess.send_event(event).await;
|
sess.send_event(event).await;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub mod executor;
|
|||||||
mod flags;
|
mod flags;
|
||||||
pub mod git_info;
|
pub mod git_info;
|
||||||
pub mod landlock;
|
pub mod landlock;
|
||||||
|
pub mod mcp;
|
||||||
mod mcp_connection_manager;
|
mod mcp_connection_manager;
|
||||||
mod mcp_tool_call;
|
mod mcp_tool_call;
|
||||||
mod message_history;
|
mod message_history;
|
||||||
|
|||||||
58
codex-rs/core/src/mcp/auth.rs
Normal file
58
codex-rs/core/src/mcp/auth.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use codex_protocol::protocol::McpAuthStatus;
|
||||||
|
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||||
|
use codex_rmcp_client::determine_streamable_http_auth_status;
|
||||||
|
use futures::future::join_all;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::config_types::McpServerConfig;
|
||||||
|
use crate::config_types::McpServerTransportConfig;
|
||||||
|
|
||||||
|
pub async fn compute_auth_statuses<'a, I>(
|
||||||
|
servers: I,
|
||||||
|
store_mode: OAuthCredentialsStoreMode,
|
||||||
|
) -> HashMap<String, McpAuthStatus>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (&'a String, &'a McpServerConfig)>,
|
||||||
|
{
|
||||||
|
let futures = servers.into_iter().map(|(name, config)| {
|
||||||
|
let name = name.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
async move {
|
||||||
|
let status = match compute_auth_status(&name, &config, store_mode).await {
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
|
||||||
|
McpAuthStatus::Unsupported
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(name, status)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
join_all(futures).await.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_auth_status(
|
||||||
|
server_name: &str,
|
||||||
|
config: &McpServerConfig,
|
||||||
|
store_mode: OAuthCredentialsStoreMode,
|
||||||
|
) -> Result<McpAuthStatus> {
|
||||||
|
match &config.transport {
|
||||||
|
McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported),
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
|
determine_streamable_http_auth_status(
|
||||||
|
server_name,
|
||||||
|
url,
|
||||||
|
bearer_token_env_var.as_deref(),
|
||||||
|
store_mode,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
codex-rs/core/src/mcp/mod.rs
Normal file
1
codex-rs/core/src/mcp/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
@@ -1243,6 +1243,30 @@ pub struct GetHistoryEntryResponseEvent {
|
|||||||
pub struct McpListToolsResponseEvent {
|
pub struct McpListToolsResponseEvent {
|
||||||
/// Fully qualified tool name -> tool definition.
|
/// Fully qualified tool name -> tool definition.
|
||||||
pub tools: std::collections::HashMap<String, McpTool>,
|
pub tools: std::collections::HashMap<String, McpTool>,
|
||||||
|
/// Authentication status for each configured MCP server.
|
||||||
|
pub auth_statuses: std::collections::HashMap<String, McpAuthStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(rename_all = "snake_case")]
|
||||||
|
pub enum McpAuthStatus {
|
||||||
|
Unsupported,
|
||||||
|
NotLoggedIn,
|
||||||
|
BearerToken,
|
||||||
|
OAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for McpAuthStatus {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let text = match self {
|
||||||
|
McpAuthStatus::Unsupported => "Unsupported",
|
||||||
|
McpAuthStatus::NotLoggedIn => "Not logged in",
|
||||||
|
McpAuthStatus::BearerToken => "Bearer token",
|
||||||
|
McpAuthStatus::OAuth => "OAuth",
|
||||||
|
};
|
||||||
|
f.write_str(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response payload for `Op::ListCustomPrompts`.
|
/// Response payload for `Op::ListCustomPrompts`.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ axum = { workspace = true, default-features = false, features = [
|
|||||||
"http1",
|
"http1",
|
||||||
"tokio",
|
"tokio",
|
||||||
] }
|
] }
|
||||||
|
codex-protocol = { workspace = true }
|
||||||
keyring = { workspace = true, features = [
|
keyring = { workspace = true, features = [
|
||||||
"apple-native",
|
"apple-native",
|
||||||
"crypto-rust",
|
"crypto-rust",
|
||||||
|
|||||||
125
codex-rs/rmcp-client/src/auth_status.rs
Normal file
125
codex-rs/rmcp-client/src/auth_status.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use anyhow::Result;
|
||||||
|
use codex_protocol::protocol::McpAuthStatus;
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::OAuthCredentialsStoreMode;
|
||||||
|
use crate::oauth::has_oauth_tokens;
|
||||||
|
|
||||||
|
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version";
|
||||||
|
const OAUTH_DISCOVERY_VERSION: &str = "2024-11-05";
|
||||||
|
|
||||||
|
/// Determine the authentication status for a streamable HTTP MCP server.
|
||||||
|
pub async fn determine_streamable_http_auth_status(
|
||||||
|
server_name: &str,
|
||||||
|
url: &str,
|
||||||
|
bearer_token_env_var: Option<&str>,
|
||||||
|
store_mode: OAuthCredentialsStoreMode,
|
||||||
|
) -> Result<McpAuthStatus> {
|
||||||
|
if bearer_token_env_var.is_some() {
|
||||||
|
return Ok(McpAuthStatus::BearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_oauth_tokens(server_name, url, store_mode)? {
|
||||||
|
return Ok(McpAuthStatus::OAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
match supports_oauth_login(url).await {
|
||||||
|
Ok(true) => Ok(McpAuthStatus::NotLoggedIn),
|
||||||
|
Ok(false) => Ok(McpAuthStatus::Unsupported),
|
||||||
|
Err(error) => {
|
||||||
|
debug!(
|
||||||
|
"failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}"
|
||||||
|
);
|
||||||
|
Ok(McpAuthStatus::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login.
|
||||||
|
async fn supports_oauth_login(url: &str) -> Result<bool> {
|
||||||
|
let base_url = Url::parse(url)?;
|
||||||
|
let client = Client::builder().timeout(DISCOVERY_TIMEOUT).build()?;
|
||||||
|
|
||||||
|
let mut last_error: Option<Error> = None;
|
||||||
|
for candidate_path in discovery_paths(base_url.path()) {
|
||||||
|
let mut discovery_url = base_url.clone();
|
||||||
|
discovery_url.set_path(&candidate_path);
|
||||||
|
|
||||||
|
let response = match client
|
||||||
|
.get(discovery_url.clone())
|
||||||
|
.header(OAUTH_DISCOVERY_HEADER, OAUTH_DISCOVERY_VERSION)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => {
|
||||||
|
last_error = Some(err.into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if response.status() != StatusCode::OK {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match response.json::<OAuthDiscoveryMetadata>().await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => {
|
||||||
|
last_error = Some(err.into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = last_error {
|
||||||
|
debug!("OAuth discovery requests failed for {url}: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OAuthDiscoveryMetadata {
|
||||||
|
#[serde(default)]
|
||||||
|
authorization_endpoint: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
token_endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements RFC 8414 section 3.1 for discovering well-known oauth endpoints.
|
||||||
|
/// This is a requirement for MCP servers to support OAuth.
|
||||||
|
/// https://datatracker.ietf.org/doc/html/rfc8414#section-3.1
|
||||||
|
/// https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/auth.rs#L182
|
||||||
|
fn discovery_paths(base_path: &str) -> Vec<String> {
|
||||||
|
let trimmed = base_path.trim_start_matches('/').trim_end_matches('/');
|
||||||
|
let canonical = "/.well-known/oauth-authorization-server".to_string();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return vec![canonical];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
let mut push_unique = |candidate: String| {
|
||||||
|
if !candidates.contains(&candidate) {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
push_unique(format!("{canonical}/{trimmed}"));
|
||||||
|
push_unique(format!("/{trimmed}/.well-known/oauth-authorization-server"));
|
||||||
|
push_unique(canonical);
|
||||||
|
|
||||||
|
candidates
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod auth_status;
|
||||||
mod find_codex_home;
|
mod find_codex_home;
|
||||||
mod logging_client_handler;
|
mod logging_client_handler;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
@@ -5,6 +6,8 @@ mod perform_oauth_login;
|
|||||||
mod rmcp_client;
|
mod rmcp_client;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
pub use auth_status::determine_streamable_http_auth_status;
|
||||||
|
pub use codex_protocol::protocol::McpAuthStatus;
|
||||||
pub use oauth::OAuthCredentialsStoreMode;
|
pub use oauth::OAuthCredentialsStoreMode;
|
||||||
pub use oauth::StoredOAuthTokens;
|
pub use oauth::StoredOAuthTokens;
|
||||||
pub use oauth::WrappedOAuthTokenResponse;
|
pub use oauth::WrappedOAuthTokenResponse;
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ pub(crate) fn load_oauth_tokens(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_oauth_tokens(
|
||||||
|
server_name: &str,
|
||||||
|
url: &str,
|
||||||
|
store_mode: OAuthCredentialsStoreMode,
|
||||||
|
) -> Result<bool> {
|
||||||
|
Ok(load_oauth_tokens(server_name, url, store_mode)?.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
fn load_oauth_tokens_from_keyring_with_fallback_to_file<K: KeyringStore>(
|
fn load_oauth_tokens_from_keyring_with_fallback_to_file<K: KeyringStore>(
|
||||||
keyring_store: &K,
|
keyring_store: &K,
|
||||||
server_name: &str,
|
server_name: &str,
|
||||||
|
|||||||
@@ -1906,7 +1906,11 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
|
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
|
||||||
self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools));
|
self.add_to_history(history_cell::new_mcp_tools_output(
|
||||||
|
&self.config,
|
||||||
|
ev.tools,
|
||||||
|
&ev.auth_statuses,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) {
|
fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use codex_core::config::Config;
|
|||||||
use codex_core::config_types::McpServerTransportConfig;
|
use codex_core::config_types::McpServerTransportConfig;
|
||||||
use codex_core::config_types::ReasoningSummaryFormat;
|
use codex_core::config_types::ReasoningSummaryFormat;
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
|
use codex_core::protocol::McpAuthStatus;
|
||||||
use codex_core::protocol::McpInvocation;
|
use codex_core::protocol::McpInvocation;
|
||||||
use codex_core::protocol::SessionConfiguredEvent;
|
use codex_core::protocol::SessionConfiguredEvent;
|
||||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||||
@@ -849,7 +850,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
|||||||
/// Render MCP tools grouped by connection using the fully-qualified tool names.
|
/// Render MCP tools grouped by connection using the fully-qualified tool names.
|
||||||
pub(crate) fn new_mcp_tools_output(
|
pub(crate) fn new_mcp_tools_output(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
tools: std::collections::HashMap<String, mcp_types::Tool>,
|
tools: HashMap<String, mcp_types::Tool>,
|
||||||
|
auth_statuses: &HashMap<String, McpAuthStatus>,
|
||||||
) -> PlainHistoryCell {
|
) -> PlainHistoryCell {
|
||||||
let mut lines: Vec<Line<'static>> = vec![
|
let mut lines: Vec<Line<'static>> = vec![
|
||||||
"/mcp".magenta().into(),
|
"/mcp".magenta().into(),
|
||||||
@@ -873,6 +875,10 @@ pub(crate) fn new_mcp_tools_output(
|
|||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
|
let status = auth_statuses
|
||||||
|
.get(server.as_str())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(McpAuthStatus::Unsupported);
|
||||||
lines.push(vec![" • Server: ".into(), server.clone().into()].into());
|
lines.push(vec![" • Server: ".into(), server.clone().into()].into());
|
||||||
let status_line = if cfg.enabled {
|
let status_line = if cfg.enabled {
|
||||||
vec![" • Status: ".into(), "enabled".green()].into()
|
vec![" • Status: ".into(), "enabled".green()].into()
|
||||||
@@ -880,6 +886,7 @@ pub(crate) fn new_mcp_tools_output(
|
|||||||
vec![" • Status: ".into(), "disabled".red()].into()
|
vec![" • Status: ".into(), "disabled".red()].into()
|
||||||
};
|
};
|
||||||
lines.push(status_line);
|
lines.push(status_line);
|
||||||
|
lines.push(vec![" • Auth: ".into(), status.to_string().into()].into());
|
||||||
|
|
||||||
match &cfg.transport {
|
match &cfg.transport {
|
||||||
McpServerTransportConfig::Stdio { command, args, env } => {
|
McpServerTransportConfig::Stdio { command, args, env } => {
|
||||||
|
|||||||
Reference in New Issue
Block a user