1use std::collections::HashMap;
2use std::sync::Arc;
3
4use anyhow::Result;
5use codex_config::McpServerAuth;
6use codex_config::McpServerConfig;
7use codex_config::McpServerTransportConfig;
8use codex_config::types::AuthKeyringBackendKind;
9use codex_config::types::OAuthCredentialsStoreMode;
10use codex_exec_server::HttpClient;
11use codex_login::CodexAuth;
12use codex_rmcp_client::McpAuthState;
13use codex_rmcp_client::OAuthProviderError;
14use codex_rmcp_client::determine_streamable_http_auth_status;
15use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client;
16use codex_rmcp_client::discover_streamable_http_oauth;
17use codex_rmcp_client::discover_streamable_http_oauth_with_http_client;
18use futures::FutureExt;
19use futures::future::join_all;
20use tracing::warn;
21
22use crate::runtime::McpRuntimeContext;
23use crate::server::EffectiveMcpServer;
24
25#[derive(Debug, Clone)]
26pub struct McpOAuthLoginConfig {
27 pub url: String,
28 pub http_headers: Option<HashMap<String, String>>,
29 pub env_http_headers: Option<HashMap<String, String>>,
30 pub discovered_scopes: Option<Vec<String>>,
31}
32
33#[derive(Debug)]
34pub enum McpOAuthLoginSupport {
35 Supported(McpOAuthLoginConfig),
36 Unsupported,
37 Unknown(anyhow::Error),
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum McpOAuthScopesSource {
42 Explicit,
43 Configured,
44 Discovered,
45 Empty,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedMcpOAuthScopes {
50 pub scopes: Vec<String>,
51 pub source: McpOAuthScopesSource,
52}
53
54#[derive(Debug, Clone)]
55pub struct McpAuthStatusEntry {
56 pub config: Option<McpServerConfig>,
57 pub auth_state: McpAuthState,
58}
59
60pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
61 let Some(mut config) = oauth_login_candidate(transport) else {
62 return McpOAuthLoginSupport::Unsupported;
63 };
64 match discover_streamable_http_oauth(
65 &config.url,
66 config.http_headers.clone(),
67 config.env_http_headers.clone(),
68 )
69 .await
70 {
71 Ok(Some(discovery)) => {
72 config.discovered_scopes = discovery.scopes_supported;
73 McpOAuthLoginSupport::Supported(config)
74 }
75 Ok(None) => McpOAuthLoginSupport::Unsupported,
76 Err(err) => McpOAuthLoginSupport::Unknown(err),
77 }
78}
79
80pub async fn oauth_login_support_with_http_client(
81 transport: &McpServerTransportConfig,
82 http_client: Arc<dyn HttpClient>,
83) -> McpOAuthLoginSupport {
84 let Some(mut config) = oauth_login_candidate(transport) else {
85 return McpOAuthLoginSupport::Unsupported;
86 };
87 match discover_streamable_http_oauth_with_http_client(
88 &config.url,
89 config.http_headers.clone(),
90 config.env_http_headers.clone(),
91 http_client,
92 )
93 .await
94 {
95 Ok(Some(discovery)) => {
96 config.discovered_scopes = discovery.scopes_supported;
97 McpOAuthLoginSupport::Supported(config)
98 }
99 Ok(None) => McpOAuthLoginSupport::Unsupported,
100 Err(err) => McpOAuthLoginSupport::Unknown(err),
101 }
102}
103
104fn oauth_login_candidate(transport: &McpServerTransportConfig) -> Option<McpOAuthLoginConfig> {
105 let McpServerTransportConfig::StreamableHttp {
106 url,
107 bearer_token_env_var,
108 http_headers,
109 env_http_headers,
110 } = transport
111 else {
112 return None;
113 };
114 if bearer_token_env_var.is_some() {
115 return None;
116 }
117 Some(McpOAuthLoginConfig {
118 url: url.clone(),
119 http_headers: http_headers.clone(),
120 env_http_headers: env_http_headers.clone(),
121 discovered_scopes: None,
122 })
123}
124
125pub async fn discover_supported_scopes(
126 transport: &McpServerTransportConfig,
127) -> Option<Vec<String>> {
128 match oauth_login_support(transport).await {
129 McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
130 McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
131 }
132}
133
134pub async fn discover_supported_scopes_with_http_client(
135 transport: &McpServerTransportConfig,
136 http_client: Arc<dyn HttpClient>,
137) -> Option<Vec<String>> {
138 match oauth_login_support_with_http_client(transport, http_client).await {
139 McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
140 McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
141 }
142}
143
144pub fn resolve_oauth_scopes(
145 explicit_scopes: Option<Vec<String>>,
146 configured_scopes: Option<Vec<String>>,
147 discovered_scopes: Option<Vec<String>>,
148) -> ResolvedMcpOAuthScopes {
149 if let Some(scopes) = explicit_scopes {
150 return ResolvedMcpOAuthScopes {
151 scopes,
152 source: McpOAuthScopesSource::Explicit,
153 };
154 }
155
156 if let Some(scopes) = configured_scopes {
157 return ResolvedMcpOAuthScopes {
158 scopes,
159 source: McpOAuthScopesSource::Configured,
160 };
161 }
162
163 if let Some(scopes) = discovered_scopes
164 && !scopes.is_empty()
165 {
166 return ResolvedMcpOAuthScopes {
167 scopes,
168 source: McpOAuthScopesSource::Discovered,
169 };
170 }
171
172 ResolvedMcpOAuthScopes {
173 scopes: Vec::new(),
174 source: McpOAuthScopesSource::Empty,
175 }
176}
177
178pub fn should_retry_without_scopes(scopes: &ResolvedMcpOAuthScopes, error: &anyhow::Error) -> bool {
179 scopes.source == McpOAuthScopesSource::Discovered
180 && error.downcast_ref::<OAuthProviderError>().is_some()
181}
182
183pub async fn compute_auth_statuses<'a, I>(
184 servers: I,
185 store_mode: OAuthCredentialsStoreMode,
186 keyring_backend_kind: AuthKeyringBackendKind,
187 auth: Option<&CodexAuth>,
188 runtime_context: &McpRuntimeContext,
189) -> HashMap<String, McpAuthStatusEntry>
190where
191 I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
192{
193 let futures = servers.into_iter().map(|(name, server)| {
194 let name = name.clone();
195 let config = server.configured_config().cloned();
196 let runtime_context = runtime_context.clone();
197 let has_runtime_auth = config
198 .as_ref()
199 .is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt))
200 && auth.is_some_and(CodexAuth::uses_codex_backend)
201 && config.as_ref().is_some_and(|config| {
202 matches!(
203 &config.transport,
204 McpServerTransportConfig::StreamableHttp {
205 bearer_token_env_var: None,
206 ..
207 }
208 )
209 });
210 async move {
211 let auth_state = match config.as_ref() {
212 Some(config) => {
213 match compute_auth_status(
214 &name,
215 config,
216 store_mode,
217 keyring_backend_kind,
218 has_runtime_auth,
219 &runtime_context,
220 )
221 .await
222 {
223 Ok(status) => status,
224 Err(error) => {
225 warn!(
226 "failed to determine auth status for MCP server `{name}`: {error:?}"
227 );
228 McpAuthState::Unsupported
229 }
230 }
231 }
232 None => McpAuthState::Unsupported,
233 };
234 let entry = McpAuthStatusEntry { config, auth_state };
235 (name, entry)
236 }
237 });
238
239 join_all(futures).await.into_iter().collect()
240}
241
242async fn compute_auth_status(
243 server_name: &str,
244 config: &McpServerConfig,
245 store_mode: OAuthCredentialsStoreMode,
246 keyring_backend_kind: AuthKeyringBackendKind,
247 has_runtime_auth: bool,
248 runtime_context: &McpRuntimeContext,
249) -> Result<McpAuthState> {
250 if !config.enabled {
251 return Ok(McpAuthState::Unsupported);
252 }
253
254 if has_runtime_auth {
255 return Ok(McpAuthState::BearerToken);
256 }
257
258 match &config.transport {
259 McpServerTransportConfig::Stdio { .. } => Ok(McpAuthState::Unsupported),
260 McpServerTransportConfig::StreamableHttp {
261 url,
262 bearer_token_env_var,
263 http_headers,
264 env_http_headers,
265 } => {
266 if config.is_local_environment() {
267 determine_streamable_http_auth_status(
268 server_name,
269 url,
270 bearer_token_env_var.as_deref(),
271 http_headers.clone(),
272 env_http_headers.clone(),
273 store_mode,
274 keyring_backend_kind,
275 )
276 .boxed()
277 .await
278 } else {
279 let http_client = runtime_context
280 .resolve_http_client(server_name, config)
281 .map_err(anyhow::Error::msg)?;
282 determine_streamable_http_auth_status_with_http_client(
283 server_name,
284 url,
285 bearer_token_env_var.as_deref(),
286 http_headers.clone(),
287 env_http_headers.clone(),
288 store_mode,
289 keyring_backend_kind,
290 http_client,
291 )
292 .boxed()
293 .await
294 }
295 }
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use anyhow::anyhow;
302 use pretty_assertions::assert_eq;
303
304 use super::McpOAuthScopesSource;
305 use super::OAuthProviderError;
306 use super::ResolvedMcpOAuthScopes;
307 use super::resolve_oauth_scopes;
308 use super::should_retry_without_scopes;
309
310 #[test]
311 fn resolve_oauth_scopes_prefers_explicit() {
312 let resolved = resolve_oauth_scopes(
313 Some(vec!["explicit".to_string()]),
314 Some(vec!["configured".to_string()]),
315 Some(vec!["discovered".to_string()]),
316 );
317
318 assert_eq!(
319 resolved,
320 ResolvedMcpOAuthScopes {
321 scopes: vec!["explicit".to_string()],
322 source: McpOAuthScopesSource::Explicit,
323 }
324 );
325 }
326
327 #[test]
328 fn resolve_oauth_scopes_prefers_configured_over_discovered() {
329 let resolved = resolve_oauth_scopes(
330 None,
331 Some(vec!["configured".to_string()]),
332 Some(vec!["discovered".to_string()]),
333 );
334
335 assert_eq!(
336 resolved,
337 ResolvedMcpOAuthScopes {
338 scopes: vec!["configured".to_string()],
339 source: McpOAuthScopesSource::Configured,
340 }
341 );
342 }
343
344 #[test]
345 fn resolve_oauth_scopes_uses_discovered_when_needed() {
346 let resolved = resolve_oauth_scopes(
347 None,
348 None,
349 Some(vec!["discovered".to_string()]),
350 );
351
352 assert_eq!(
353 resolved,
354 ResolvedMcpOAuthScopes {
355 scopes: vec!["discovered".to_string()],
356 source: McpOAuthScopesSource::Discovered,
357 }
358 );
359 }
360
361 #[test]
362 fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() {
363 let resolved = resolve_oauth_scopes(
364 None,
365 Some(Vec::new()),
366 Some(vec!["ignored".into()]),
367 );
368
369 assert_eq!(
370 resolved,
371 ResolvedMcpOAuthScopes {
372 scopes: Vec::new(),
373 source: McpOAuthScopesSource::Configured,
374 }
375 );
376 }
377
378 #[test]
379 fn resolve_oauth_scopes_falls_back_to_empty() {
380 let resolved = resolve_oauth_scopes(
381 None, None,
382 None,
383 );
384
385 assert_eq!(
386 resolved,
387 ResolvedMcpOAuthScopes {
388 scopes: Vec::new(),
389 source: McpOAuthScopesSource::Empty,
390 }
391 );
392 }
393
394 #[test]
395 fn should_retry_without_scopes_only_for_discovered_provider_errors() {
396 let discovered = ResolvedMcpOAuthScopes {
397 scopes: vec!["scope".to_string()],
398 source: McpOAuthScopesSource::Discovered,
399 };
400 let provider_error = anyhow!(OAuthProviderError::new(
401 Some("invalid_scope".to_string()),
402 Some("scope rejected".to_string()),
403 ));
404
405 assert!(should_retry_without_scopes(&discovered, &provider_error));
406
407 let configured = ResolvedMcpOAuthScopes {
408 scopes: vec!["scope".to_string()],
409 source: McpOAuthScopesSource::Configured,
410 };
411 assert!(!should_retry_without_scopes(&configured, &provider_error));
412 assert!(!should_retry_without_scopes(
413 &discovered,
414 &anyhow!("timed out waiting for OAuth callback"),
415 ));
416 }
417}