mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Support OAuth for HTTP MCP servers from selected executor plugins (#28529)
## Why #28522 routes selected-plugin HTTP MCP traffic through the owning executor, but OAuth bootstrap and refresh still used host-local clients. Executor-only servers therefore cannot complete discovery or login through the same network boundary as the MCP connection. ## What changed - adapt `codex_exec_server::HttpClient` to RMCP 1.8's `OAuthHttpClient` contract - let RMCP own discovery, dynamic registration, PKCE, token exchange, and refresh - route auth status, persisted-token startup, and app-server login through the server runtime while preserving the existing local discovery path - add optional `threadId` to `mcpServer/oauth/login` and echo it in the completion notification - implement RMCP's redirect policy and 1 MiB OAuth response limit over executor HTTP - cover selected-thread OAuth discovery and login through an executor-only route Depends on #28522.
This commit is contained in:
Generated
-1
@@ -3757,7 +3757,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"codex-api",
|
||||
"codex-client",
|
||||
"codex-config",
|
||||
"codex-exec-server",
|
||||
"codex-keyring-store",
|
||||
|
||||
@@ -1619,6 +1619,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeoutSecs": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+12
@@ -12026,6 +12026,12 @@
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -12050,6 +12056,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeoutSecs": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
|
||||
+12
@@ -8430,6 +8430,12 @@
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -8454,6 +8460,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeoutSecs": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
|
||||
+6
@@ -12,6 +12,12 @@
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeoutSecs": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
|
||||
Generated
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type McpServerOauthLoginCompletedNotification = { name: string, success: boolean, error?: string, };
|
||||
export type McpServerOauthLoginCompletedNotification = { name: string, threadId: string | null, success: boolean, error?: string, };
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type McpServerOauthLoginParams = { name: string, scopes?: Array<string> | null, timeoutSecs?: bigint | null, };
|
||||
export type McpServerOauthLoginParams = { name: string, threadId?: string | null, scopes?: Array<string> | null, timeoutSecs?: bigint | null, };
|
||||
|
||||
@@ -1913,6 +1913,7 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
params: v2::McpServerOauthLoginParams {
|
||||
name: "server-a".to_string(),
|
||||
thread_id: None,
|
||||
scopes: None,
|
||||
timeout_secs: None,
|
||||
},
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct McpServerRefreshResponse {}
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerOauthLoginParams {
|
||||
pub name: String,
|
||||
#[ts(optional = nullable)]
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
@@ -215,6 +217,7 @@ pub struct McpToolCallProgressNotification {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerOauthLoginCompletedNotification {
|
||||
pub name: String,
|
||||
pub thread_id: Option<String>,
|
||||
pub success: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
|
||||
@@ -229,7 +229,7 @@ Example with notification opt-out:
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`.
|
||||
@@ -1934,7 +1934,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
|
||||
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
|
||||
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, threadId, success, error? }`.
|
||||
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`.
|
||||
|
||||
### 1) Check auth state
|
||||
|
||||
@@ -380,7 +380,7 @@ use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::McpServerStatusSnapshot;
|
||||
use codex_mcp::McpSnapshotDetail;
|
||||
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
|
||||
use codex_mcp::discover_supported_scopes;
|
||||
use codex_mcp::discover_supported_scopes_with_http_client;
|
||||
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
|
||||
use codex_mcp::resolve_oauth_scopes;
|
||||
use codex_memories_write::clear_memory_roots_contents;
|
||||
@@ -428,7 +428,7 @@ use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use codex_protocol::user_input::UserInput as CoreInputItem;
|
||||
use codex_rmcp_client::perform_oauth_login_return_url;
|
||||
use codex_rmcp_client::perform_oauth_login_return_url_with_http_client;
|
||||
use codex_rollout::is_persisted_rollout_item;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_rollout::state_db::reconcile_rollout;
|
||||
|
||||
@@ -113,19 +113,37 @@ impl McpRequestProcessor {
|
||||
&self,
|
||||
params: McpServerOauthLoginParams,
|
||||
) -> Result<McpServerOauthLoginResponse, JSONRPCErrorError> {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let McpServerOauthLoginParams {
|
||||
name,
|
||||
thread_id,
|
||||
scopes,
|
||||
timeout_secs,
|
||||
} = params;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let effective_servers = self
|
||||
.thread_manager
|
||||
.mcp_manager()
|
||||
.effective_servers(&config, auth.as_ref())
|
||||
.await;
|
||||
let (config, mcp_config) = match thread_id.as_deref() {
|
||||
Some(thread_id) => {
|
||||
let (_, thread) = self.load_thread(thread_id).await?;
|
||||
let thread_config = thread.config().await;
|
||||
let config = self
|
||||
.config_manager
|
||||
.load_latest_config_for_thread(thread_config.as_ref())
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?;
|
||||
let mcp_config = thread.runtime_mcp_config(&config).await;
|
||||
(config, mcp_config)
|
||||
}
|
||||
None => {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let mcp_config = self
|
||||
.thread_manager
|
||||
.mcp_manager()
|
||||
.runtime_config(&config)
|
||||
.await;
|
||||
(config, mcp_config)
|
||||
}
|
||||
};
|
||||
let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
let Some(server) = effective_servers
|
||||
.get(&name)
|
||||
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
|
||||
@@ -149,15 +167,26 @@ impl McpRequestProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_context = McpRuntimeContext::new(
|
||||
self.thread_manager.environment_manager(),
|
||||
config.cwd.to_path_buf(),
|
||||
);
|
||||
let http_client = runtime_context
|
||||
.resolve_http_client(&name, server)
|
||||
.map_err(|err| {
|
||||
internal_error(format!("failed to resolve MCP server runtime: {err}"))
|
||||
})?;
|
||||
|
||||
let discovered_scopes = if scopes.is_none() && server.scopes.is_none() {
|
||||
discover_supported_scopes(&server.transport).await
|
||||
discover_supported_scopes_with_http_client(&server.transport, Arc::clone(&http_client))
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resolved_scopes =
|
||||
resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes);
|
||||
|
||||
let handle = perform_oauth_login_return_url(
|
||||
let handle = perform_oauth_login_return_url_with_http_client(
|
||||
&name,
|
||||
&url,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -170,11 +199,13 @@ impl McpRequestProcessor {
|
||||
timeout_secs,
|
||||
config.mcp_oauth_callback_port,
|
||||
config.mcp_oauth_callback_url.as_deref(),
|
||||
http_client,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?;
|
||||
let authorization_url = handle.authorization_url().to_string();
|
||||
let notification_name = name.clone();
|
||||
let notification_thread_id = thread_id;
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -186,6 +217,7 @@ impl McpRequestProcessor {
|
||||
let notification = ServerNotification::McpServerOauthLoginCompleted(
|
||||
McpServerOauthLoginCompletedNotification {
|
||||
name: notification_name,
|
||||
thread_id: notification_thread_id,
|
||||
success,
|
||||
error,
|
||||
},
|
||||
|
||||
@@ -1883,6 +1883,7 @@ impl PluginRequestProcessor {
|
||||
let notification = ServerNotification::McpServerOauthLoginCompleted(
|
||||
McpServerOauthLoginCompletedNotification {
|
||||
name: notification_name,
|
||||
thread_id: None,
|
||||
success,
|
||||
error,
|
||||
},
|
||||
|
||||
@@ -2,10 +2,14 @@ use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::CapabilityRootLocation;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -45,6 +49,8 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const EXECUTOR_HTTP_MCP_URL: &str = "http://executor-only.invalid/mcp";
|
||||
const HTTP_MCP_SERVER_NAME: &str = "executor_http";
|
||||
const MCP_SERVER_NAME: &str = "executor_demo";
|
||||
const OAUTH_MCP_SERVER_NAME: &str = "executor_oauth";
|
||||
const EXECUTOR_OAUTH_MCP_URL: &str = "http://oauth-only.invalid/oauth-mcp";
|
||||
const EXECUTOR_ENV_NAME: &str = "MCP_EXECUTOR_MARKER";
|
||||
const EXECUTOR_ENV_VALUE: &str = "executor-only";
|
||||
const EXECUTOR_ID: &str = "executor-1";
|
||||
@@ -56,12 +62,35 @@ async fn selected_executor_plugin_exposes_its_mcps_only_to_that_thread() -> Resu
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let http_listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let http_addr = http_listener.local_addr()?;
|
||||
let http_server_config = StreamableHttpServerConfig::default()
|
||||
.with_allowed_hosts(["executor-only.invalid", "oauth-only.invalid"]);
|
||||
let http_mcp_service = StreamableHttpService::new(
|
||||
|| Ok(ExecutorHttpMcpServer),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default().with_allowed_hosts(["executor-only.invalid"]),
|
||||
http_server_config.clone(),
|
||||
);
|
||||
let http_router = Router::new().nest_service("/mcp", http_mcp_service);
|
||||
let oauth_mcp_service = StreamableHttpService::new(
|
||||
|| Ok(ExecutorHttpMcpServer),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
http_server_config,
|
||||
);
|
||||
let oauth_metadata = json!({
|
||||
"authorization_endpoint": "https://oauth-only.invalid/authorize",
|
||||
"token_endpoint": "https://oauth-only.invalid/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
});
|
||||
let http_router = Router::new()
|
||||
.route(
|
||||
"/.well-known/oauth-authorization-server/oauth-mcp",
|
||||
get(move || {
|
||||
let metadata = oauth_metadata.clone();
|
||||
async move { Json(metadata) }
|
||||
}),
|
||||
)
|
||||
.nest_service("/mcp", http_mcp_service)
|
||||
.nest_service("/oauth-mcp", oauth_mcp_service);
|
||||
let http_server_handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(http_listener, http_router).await;
|
||||
});
|
||||
@@ -117,6 +146,12 @@ HTTP_PROXY = {http_proxy}
|
||||
"url": EXECUTOR_HTTP_MCP_URL,
|
||||
"environment_id": "local",
|
||||
"startup_timeout_sec": 10,
|
||||
},
|
||||
(OAUTH_MCP_SERVER_NAME): {
|
||||
"url": EXECUTOR_OAUTH_MCP_URL,
|
||||
"environment_id": "local",
|
||||
"oauth": {"clientId": "executor-oauth-client"},
|
||||
"startup_timeout_sec": 10,
|
||||
}
|
||||
}
|
||||
}))?,
|
||||
@@ -158,6 +193,44 @@ startup_timeout_sec = 10
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request_id = app_server
|
||||
.send_raw_request(
|
||||
"mcpServer/oauth/login",
|
||||
Some(json!({
|
||||
"name": OAUTH_MCP_SERVER_NAME,
|
||||
"threadId": selected_thread.clone(),
|
||||
"timeoutSecs": 1,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: McpServerOauthLoginResponse = to_response(response)?;
|
||||
assert!(
|
||||
response
|
||||
.authorization_url
|
||||
.starts_with("https://oauth-only.invalid/authorize?")
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.authorization_url
|
||||
.contains("client_id=executor-oauth-client")
|
||||
);
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
app_server.read_stream_until_notification_message("mcpServer/oauthLogin/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: McpServerOauthLoginCompletedNotification =
|
||||
serde_json::from_value(notification.params.expect("notification params"))?;
|
||||
assert_eq!(
|
||||
completed.thread_id.as_deref(),
|
||||
Some(selected_thread.as_str())
|
||||
);
|
||||
|
||||
let namespace = format!("mcp__{MCP_SERVER_NAME}");
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&responses_server,
|
||||
@@ -269,15 +342,18 @@ startup_timeout_sec = 10
|
||||
.iter()
|
||||
.any(|name| name == HTTP_MCP_SERVER_NAME)
|
||||
);
|
||||
assert!(
|
||||
selected_server_names
|
||||
.iter()
|
||||
.any(|name| name == OAUTH_MCP_SERVER_NAME)
|
||||
);
|
||||
|
||||
let unselected_thread =
|
||||
start_thread(&mut app_server, /*selected_capability_roots*/ None).await?;
|
||||
let unselected_server_names = mcp_server_names(&mut app_server, unselected_thread).await?;
|
||||
assert!(
|
||||
unselected_server_names
|
||||
.iter()
|
||||
.all(|name| { name != MCP_SERVER_NAME && name != HTTP_MCP_SERVER_NAME })
|
||||
);
|
||||
assert!(unselected_server_names.iter().all(|name| {
|
||||
name != MCP_SERVER_NAME && name != HTTP_MCP_SERVER_NAME && name != OAUTH_MCP_SERVER_NAME
|
||||
}));
|
||||
|
||||
http_server_handle.abort();
|
||||
let _ = http_server_handle.await;
|
||||
|
||||
@@ -18,8 +18,10 @@ use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_global_mcp_servers;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_login::AuthManager;
|
||||
use codex_mcp::McpOAuthLoginSupport;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::ResolvedMcpOAuthScopes;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
use codex_mcp::discover_supported_scopes;
|
||||
@@ -558,6 +560,10 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
auth.as_ref(),
|
||||
&McpRuntimeContext::new(
|
||||
Arc::new(EnvironmentManager::without_environments()),
|
||||
config.cwd.to_path_buf(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ pub use mcp::McpOAuthScopesSource;
|
||||
pub use mcp::ResolvedMcpOAuthScopes;
|
||||
pub use mcp::compute_auth_statuses;
|
||||
pub use mcp::discover_supported_scopes;
|
||||
pub use mcp::discover_supported_scopes_with_http_client;
|
||||
pub use mcp::oauth_login_support;
|
||||
pub use mcp::oauth_login_support_with_http_client;
|
||||
pub use mcp::resolve_oauth_scopes;
|
||||
pub use mcp::should_retry_without_scopes;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_config::McpServerAuth;
|
||||
@@ -6,15 +7,19 @@ use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use codex_rmcp_client::OAuthProviderError;
|
||||
use codex_rmcp_client::determine_streamable_http_auth_status;
|
||||
use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client;
|
||||
use codex_rmcp_client::discover_streamable_http_oauth;
|
||||
use codex_rmcp_client::discover_streamable_http_oauth_with_http_client;
|
||||
use futures::FutureExt;
|
||||
use futures::future::join_all;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::runtime::McpRuntimeContext;
|
||||
use crate::server::EffectiveMcpServer;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -53,6 +58,50 @@ pub struct McpAuthStatusEntry {
|
||||
}
|
||||
|
||||
pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
|
||||
let Some(mut config) = oauth_login_candidate(transport) else {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
};
|
||||
match discover_streamable_http_oauth(
|
||||
&config.url,
|
||||
config.http_headers.clone(),
|
||||
config.env_http_headers.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(discovery)) => {
|
||||
config.discovered_scopes = discovery.scopes_supported;
|
||||
McpOAuthLoginSupport::Supported(config)
|
||||
}
|
||||
Ok(None) => McpOAuthLoginSupport::Unsupported,
|
||||
Err(err) => McpOAuthLoginSupport::Unknown(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn oauth_login_support_with_http_client(
|
||||
transport: &McpServerTransportConfig,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> McpOAuthLoginSupport {
|
||||
let Some(mut config) = oauth_login_candidate(transport) else {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
};
|
||||
match discover_streamable_http_oauth_with_http_client(
|
||||
&config.url,
|
||||
config.http_headers.clone(),
|
||||
config.env_http_headers.clone(),
|
||||
http_client,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(discovery)) => {
|
||||
config.discovered_scopes = discovery.scopes_supported;
|
||||
McpOAuthLoginSupport::Supported(config)
|
||||
}
|
||||
Ok(None) => McpOAuthLoginSupport::Unsupported,
|
||||
Err(err) => McpOAuthLoginSupport::Unknown(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_login_candidate(transport: &McpServerTransportConfig) -> Option<McpOAuthLoginConfig> {
|
||||
let McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
@@ -60,24 +109,17 @@ pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAu
|
||||
env_http_headers,
|
||||
} = transport
|
||||
else {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
return None;
|
||||
};
|
||||
|
||||
if bearer_token_env_var.is_some() {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
}
|
||||
|
||||
match discover_streamable_http_oauth(url, http_headers.clone(), env_http_headers.clone()).await
|
||||
{
|
||||
Ok(Some(discovery)) => McpOAuthLoginSupport::Supported(McpOAuthLoginConfig {
|
||||
url: url.clone(),
|
||||
http_headers: http_headers.clone(),
|
||||
env_http_headers: env_http_headers.clone(),
|
||||
discovered_scopes: discovery.scopes_supported,
|
||||
}),
|
||||
Ok(None) => McpOAuthLoginSupport::Unsupported,
|
||||
Err(err) => McpOAuthLoginSupport::Unknown(err),
|
||||
return None;
|
||||
}
|
||||
Some(McpOAuthLoginConfig {
|
||||
url: url.clone(),
|
||||
http_headers: http_headers.clone(),
|
||||
env_http_headers: env_http_headers.clone(),
|
||||
discovered_scopes: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn discover_supported_scopes(
|
||||
@@ -89,6 +131,16 @@ pub async fn discover_supported_scopes(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn discover_supported_scopes_with_http_client(
|
||||
transport: &McpServerTransportConfig,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Option<Vec<String>> {
|
||||
match oauth_login_support_with_http_client(transport, http_client).await {
|
||||
McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
|
||||
McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_oauth_scopes(
|
||||
explicit_scopes: Option<Vec<String>>,
|
||||
configured_scopes: Option<Vec<String>>,
|
||||
@@ -133,6 +185,7 @@ pub async fn compute_auth_statuses<'a, I>(
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
auth: Option<&CodexAuth>,
|
||||
runtime_context: &McpRuntimeContext,
|
||||
) -> HashMap<String, McpAuthStatusEntry>
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
|
||||
@@ -140,6 +193,7 @@ where
|
||||
let futures = servers.into_iter().map(|(name, server)| {
|
||||
let name = name.clone();
|
||||
let config = server.configured_config().cloned();
|
||||
let runtime_context = runtime_context.clone();
|
||||
let has_runtime_auth = config
|
||||
.as_ref()
|
||||
.is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt))
|
||||
@@ -162,6 +216,7 @@ where
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
has_runtime_auth,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -193,6 +248,7 @@ async fn compute_auth_status(
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
has_runtime_auth: bool,
|
||||
runtime_context: &McpRuntimeContext,
|
||||
) -> Result<McpAuthStatus> {
|
||||
if !config.enabled {
|
||||
return Ok(McpAuthStatus::Unsupported);
|
||||
@@ -210,17 +266,35 @@ async fn compute_auth_status(
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
determine_streamable_http_auth_status(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var.as_deref(),
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
)
|
||||
.boxed()
|
||||
.await
|
||||
if config.is_local_environment() {
|
||||
determine_streamable_http_auth_status(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var.as_deref(),
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
)
|
||||
.boxed()
|
||||
.await
|
||||
} else {
|
||||
let http_client = runtime_context
|
||||
.resolve_http_client(server_name, config)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
determine_streamable_http_auth_status_with_http_client(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var.as_deref(),
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
http_client,
|
||||
)
|
||||
.boxed()
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ pub use auth::McpOAuthScopesSource;
|
||||
pub use auth::ResolvedMcpOAuthScopes;
|
||||
pub use auth::compute_auth_statuses;
|
||||
pub use auth::discover_supported_scopes;
|
||||
pub use auth::discover_supported_scopes_with_http_client;
|
||||
pub use auth::oauth_login_support;
|
||||
pub use auth::oauth_login_support_with_http_client;
|
||||
pub use auth::resolve_oauth_scopes;
|
||||
pub use auth::should_retry_without_scopes;
|
||||
|
||||
@@ -309,6 +311,7 @@ pub async fn read_mcp_resource(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind,
|
||||
auth,
|
||||
&runtime_context,
|
||||
)
|
||||
.await;
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
@@ -378,6 +381,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind,
|
||||
auth,
|
||||
&runtime_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::time::Duration;
|
||||
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::ReqwestHttpClient;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use serde::Deserialize;
|
||||
@@ -81,6 +83,20 @@ impl McpRuntimeContext {
|
||||
config.environment_id
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolves the HTTP capability owned by the server's configured environment.
|
||||
pub fn resolve_http_client(
|
||||
&self,
|
||||
server_name: &str,
|
||||
config: &codex_config::McpServerConfig,
|
||||
) -> Result<Arc<dyn HttpClient>, String> {
|
||||
Ok(self
|
||||
.resolve_server_environment(server_name, config)?
|
||||
.map_or_else(
|
||||
|| Arc::new(ReqwestHttpClient) as Arc<dyn HttpClient>,
|
||||
|environment| environment.get_http_client(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
|
||||
|
||||
@@ -246,11 +246,14 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
});
|
||||
}
|
||||
|
||||
let runtime_context =
|
||||
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
|
||||
let auth_status_entries = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
auth.as_ref(),
|
||||
&runtime_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -270,7 +273,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
PermissionProfile::default(),
|
||||
// Connector discovery is threadless. Use an actually configured env if
|
||||
// one exists, but do not reintroduce the old hidden-local fallback.
|
||||
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
|
||||
runtime_context,
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
|
||||
@@ -271,13 +271,6 @@ impl Session {
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
|
||||
let mcp_servers =
|
||||
effective_mcp_servers_from_configured(mcp_servers, &mcp_config, auth.as_ref());
|
||||
let auth_statuses = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let environment_manager = self.services.turn_environments.environment_manager();
|
||||
// TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment cwd
|
||||
// values can be used without falling back to the legacy host cwd.
|
||||
@@ -291,6 +284,14 @@ impl Session {
|
||||
turn_context.cwd.to_path_buf()
|
||||
});
|
||||
let mcp_runtime_context = McpRuntimeContext::new(environment_manager, cwd);
|
||||
let auth_statuses = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
auth.as_ref(),
|
||||
&mcp_runtime_context,
|
||||
)
|
||||
.await;
|
||||
let mcp_startup_cancellation_token = {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
|
||||
@@ -648,6 +648,10 @@ impl Session {
|
||||
let config_for_mcp = Arc::clone(&config);
|
||||
let mcp_manager_for_mcp = Arc::clone(&mcp_manager);
|
||||
let mcp_thread_init_for_startup = &mcp_thread_init;
|
||||
let mcp_runtime_context_for_auth = McpRuntimeContext::new(
|
||||
Arc::clone(&environment_manager),
|
||||
session_configuration.cwd().to_path_buf(),
|
||||
);
|
||||
let auth_and_mcp_fut = async move {
|
||||
let auth = auth_manager_clone.auth().await;
|
||||
let mcp_config = mcp_manager_for_mcp
|
||||
@@ -660,6 +664,7 @@ impl Session {
|
||||
config_for_mcp.mcp_oauth_credentials_store_mode,
|
||||
config_for_mcp.auth_keyring_backend_kind(),
|
||||
auth.as_ref(),
|
||||
&mcp_runtime_context_for_auth,
|
||||
)
|
||||
.await;
|
||||
(auth, mcp_servers, auth_statuses, tool_plugin_provenance)
|
||||
|
||||
@@ -24,6 +24,7 @@ use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
@@ -2934,6 +2935,7 @@ async fn wait_for_remote_streamable_http_server(
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: Some(remaining.as_millis().clamp(1, 1_000) as u64),
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
};
|
||||
|
||||
@@ -419,6 +419,17 @@ pub struct HttpHeader {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Redirect behavior for an executor-side HTTP request.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HttpRedirectPolicy {
|
||||
/// Follow redirects using the HTTP client's normal limits.
|
||||
#[default]
|
||||
Follow,
|
||||
/// Return the redirect response without following its location.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Executor-side HTTP request envelope.
|
||||
///
|
||||
/// This intentionally stays transport-shaped rather than MCP-shaped so callers
|
||||
@@ -443,6 +454,9 @@ pub struct HttpRequestParams {
|
||||
/// millisecond deadline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Whether the executor should follow HTTP redirects.
|
||||
#[serde(default)]
|
||||
pub redirect_policy: HttpRedirectPolicy,
|
||||
/// Caller-chosen stream id for `http/request/bodyDelta` notifications.
|
||||
///
|
||||
/// The id must remain unique on a connection until the terminal body delta
|
||||
|
||||
@@ -24,6 +24,7 @@ use super::response_body_stream::send_body_delta;
|
||||
use crate::HttpClient;
|
||||
use crate::client::ExecServerError;
|
||||
use crate::protocol::HttpHeader;
|
||||
use crate::protocol::HttpRedirectPolicy;
|
||||
use crate::protocol::HttpRequestBodyDeltaNotification;
|
||||
use crate::protocol::HttpRequestParams;
|
||||
use crate::protocol::HttpRequestResponse;
|
||||
@@ -50,13 +51,20 @@ pub(crate) struct ReqwestHttpRequestRunner {
|
||||
}
|
||||
|
||||
impl ReqwestHttpClient {
|
||||
fn build_client(timeout_ms: Option<u64>) -> Result<reqwest::Client, ExecServerError> {
|
||||
fn build_client(
|
||||
timeout_ms: Option<u64>,
|
||||
redirect_policy: HttpRedirectPolicy,
|
||||
) -> Result<reqwest::Client, ExecServerError> {
|
||||
let builder = match timeout_ms {
|
||||
None => reqwest::Client::builder(),
|
||||
Some(timeout_ms) => {
|
||||
reqwest::Client::builder().timeout(Duration::from_millis(timeout_ms))
|
||||
}
|
||||
};
|
||||
let builder = match redirect_policy {
|
||||
HttpRedirectPolicy::Follow => builder,
|
||||
HttpRedirectPolicy::Stop => builder.redirect(reqwest::redirect::Policy::none()),
|
||||
};
|
||||
build_reqwest_client_with_custom_ca(builder)
|
||||
.map_err(|error| ExecServerError::HttpRequest(error.to_string()))
|
||||
}
|
||||
@@ -68,7 +76,7 @@ impl HttpClient for ReqwestHttpClient {
|
||||
params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<HttpRequestResponse, ExecServerError>> {
|
||||
async move {
|
||||
let runner = ReqwestHttpRequestRunner::new(params.timeout_ms)
|
||||
let runner = ReqwestHttpRequestRunner::new(params.timeout_ms, params.redirect_policy)
|
||||
.map_err(|error| ExecServerError::HttpRequest(error.message))?;
|
||||
let (response, _) = runner
|
||||
.run(HttpRequestParams {
|
||||
@@ -87,7 +95,7 @@ impl HttpClient for ReqwestHttpClient {
|
||||
params: HttpRequestParams,
|
||||
) -> BoxFuture<'_, Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError>> {
|
||||
async move {
|
||||
let runner = ReqwestHttpRequestRunner::new(params.timeout_ms)
|
||||
let runner = ReqwestHttpRequestRunner::new(params.timeout_ms, params.redirect_policy)
|
||||
.map_err(|error| ExecServerError::HttpRequest(error.message))?;
|
||||
let (response, pending_stream) = runner
|
||||
.run(HttpRequestParams {
|
||||
@@ -111,8 +119,11 @@ impl HttpClient for ReqwestHttpClient {
|
||||
}
|
||||
|
||||
impl ReqwestHttpRequestRunner {
|
||||
pub(crate) fn new(timeout_ms: Option<u64>) -> Result<Self, JSONRPCErrorError> {
|
||||
let client = ReqwestHttpClient::build_client(timeout_ms)
|
||||
pub(crate) fn new(
|
||||
timeout_ms: Option<u64>,
|
||||
redirect_policy: HttpRedirectPolicy,
|
||||
) -> Result<Self, JSONRPCErrorError> {
|
||||
let client = ReqwestHttpClient::build_client(timeout_ms, redirect_policy)
|
||||
.map_err(|error| internal_error(error.to_string()))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ pub use protocol::FsWalkResponse;
|
||||
pub use protocol::FsWriteFileParams;
|
||||
pub use protocol::FsWriteFileResponse;
|
||||
pub use protocol::HttpHeader;
|
||||
pub use protocol::HttpRedirectPolicy;
|
||||
pub use protocol::HttpRequestBodyDeltaNotification;
|
||||
pub use protocol::HttpRequestParams;
|
||||
pub use protocol::HttpRequestResponse;
|
||||
|
||||
@@ -211,7 +211,7 @@ impl ExecServerHandler {
|
||||
if stream_response {
|
||||
self.reserve_http_body_stream(&http_request_id).await?;
|
||||
}
|
||||
let response = ReqwestHttpRequestRunner::new(params.timeout_ms)?
|
||||
let response = ReqwestHttpRequestRunner::new(params.timeout_ms, params.redirect_policy)?
|
||||
.run(params)
|
||||
.await;
|
||||
if response.is_err() && stream_response {
|
||||
|
||||
@@ -6,6 +6,7 @@ use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_exec_server::ExecServerClient;
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
use codex_exec_server::HttpRequestBodyDeltaNotification;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpRequestResponse;
|
||||
@@ -63,6 +64,7 @@ async fn http_request_forces_buffered_request_params() -> Result<()> {
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "ignored-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}
|
||||
@@ -91,6 +93,7 @@ async fn http_request_forces_buffered_request_params() -> Result<()> {
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "ignored-stream-id".to_string(),
|
||||
stream_response: true,
|
||||
}),
|
||||
@@ -131,6 +134,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta
|
||||
}],
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -186,6 +190,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-2".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -215,6 +220,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta
|
||||
}],
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -253,6 +259,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -289,6 +296,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -322,6 +330,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-2".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -348,6 +357,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -372,6 +382,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
};
|
||||
@@ -411,6 +422,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -430,6 +442,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-2".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -474,6 +487,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
})
|
||||
@@ -495,6 +509,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -541,6 +556,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -580,6 +596,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-2".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -615,6 +632,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -647,6 +665,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -691,6 +710,7 @@ async fn http_response_body_stream_fails_when_transport_disconnects() -> Result<
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -717,6 +737,7 @@ async fn http_response_body_stream_fails_when_transport_disconnects() -> Result<
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -759,6 +780,7 @@ async fn http_response_body_stream_reports_disconnect_when_queue_is_full() -> Re
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -796,6 +818,7 @@ async fn http_response_body_stream_reports_disconnect_when_queue_is_full() -> Re
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
@@ -852,6 +875,7 @@ async fn http_response_body_stream_reports_backpressure_truncation() -> Result<(
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "http-1".to_string(),
|
||||
stream_response: true,
|
||||
}
|
||||
@@ -894,6 +918,7 @@ async fn http_response_body_stream_reports_backpressure_truncation() -> Result<(
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "caller-stream-id".to_string(),
|
||||
stream_response: false,
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
use codex_exec_server::HttpRequestBodyDeltaNotification;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpRequestResponse;
|
||||
@@ -63,6 +64,7 @@ async fn exec_server_http_request_buffers_response_body() -> anyhow::Result<()>
|
||||
}],
|
||||
body: Some(b"request-body".to_vec().into()),
|
||||
timeout_ms: Some(5_000),
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
@@ -108,6 +110,61 @@ async fn exec_server_http_request_buffers_response_body() -> anyhow::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this tests: OAuth callers can inspect redirect responses without the
|
||||
/// executor following the Location header.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_server_http_request_can_stop_at_redirects() -> anyhow::Result<()> {
|
||||
let mut server = exec_server().await?;
|
||||
initialize_exec_server(&mut server).await?;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base_url = format!("http://{}", listener.local_addr()?);
|
||||
let http_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url: format!("{base_url}/redirect"),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: Some(5_000),
|
||||
redirect_policy: HttpRedirectPolicy::Stop,
|
||||
request_id: "redirect-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
assert_eq!(captured.request_line, "GET /redirect HTTP/1.1");
|
||||
respond_with_status_and_headers(
|
||||
captured.stream,
|
||||
"302 Found",
|
||||
&[("location", &format!("{base_url}/final"))],
|
||||
b"redirect",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?;
|
||||
assert_eq!(
|
||||
(
|
||||
response.status,
|
||||
response_header(&response.headers, "location"),
|
||||
response.body.into_inner(),
|
||||
),
|
||||
(302, Some(format!("{base_url}/final")), b"redirect".to_vec(),)
|
||||
);
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
.await
|
||||
.is_err(),
|
||||
"redirect target should not be requested"
|
||||
);
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this tests: a real exec-server websocket `http/request` can return
|
||||
/// response headers immediately and stream the response body as ordered
|
||||
/// `http/request/bodyDelta` notifications.
|
||||
@@ -132,6 +189,7 @@ async fn exec_server_http_request_streams_response_body_notifications() -> anyho
|
||||
}],
|
||||
body: None,
|
||||
timeout_ms: Some(5_000),
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "stream-1".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
@@ -218,6 +276,7 @@ async fn exec_server_http_request_rejects_duplicate_stream_request_ids() -> anyh
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "stream-dup".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
@@ -241,6 +300,7 @@ async fn exec_server_http_request_rejects_duplicate_stream_request_ids() -> anyh
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "stream-dup".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
@@ -295,6 +355,7 @@ async fn exec_server_http_request_honors_optional_timeout() -> anyhow::Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
@@ -320,6 +381,7 @@ async fn exec_server_http_request_honors_optional_timeout() -> anyhow::Result<()
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: Some(10),
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
|
||||
@@ -16,7 +16,6 @@ axum = { workspace = true, default-features = false, features = [
|
||||
] }
|
||||
base64 = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-keyring-store = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use futures::FutureExt;
|
||||
use reqwest::Client;
|
||||
@@ -13,6 +15,7 @@ use tracing::debug;
|
||||
|
||||
use crate::oauth::StoredOAuthTokenStatus;
|
||||
use crate::oauth::oauth_token_status;
|
||||
use crate::oauth_http_client::OAuthHttpClientAdapter;
|
||||
use crate::utils::apply_default_headers;
|
||||
use crate::utils::build_default_headers;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
@@ -25,6 +28,11 @@ pub struct StreamableHttpOAuthDiscovery {
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
enum AuthStatusCheck {
|
||||
Complete(McpAuthStatus),
|
||||
Discover(HeaderMap),
|
||||
}
|
||||
|
||||
/// Determine the authentication status for a streamable HTTP MCP server.
|
||||
pub async fn determine_streamable_http_auth_status(
|
||||
server_name: &str,
|
||||
@@ -35,24 +43,100 @@ pub async fn determine_streamable_http_auth_status(
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
) -> Result<McpAuthStatus> {
|
||||
let default_headers = match auth_status_before_discovery(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
)? {
|
||||
AuthStatusCheck::Complete(status) => return Ok(status),
|
||||
AuthStatusCheck::Discover(default_headers) => default_headers,
|
||||
};
|
||||
|
||||
determine_auth_status_from_discovery(
|
||||
server_name,
|
||||
url,
|
||||
discover_streamable_http_oauth_with_headers(url, &default_headers).await,
|
||||
)
|
||||
}
|
||||
|
||||
/// Determine authentication status while routing OAuth discovery through the
|
||||
/// provided HTTP client.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn determine_streamable_http_auth_status_with_http_client(
|
||||
server_name: &str,
|
||||
url: &str,
|
||||
bearer_token_env_var: Option<&str>,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<McpAuthStatus> {
|
||||
let default_headers = match auth_status_before_discovery(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
)? {
|
||||
AuthStatusCheck::Complete(status) => return Ok(status),
|
||||
AuthStatusCheck::Discover(default_headers) => default_headers,
|
||||
};
|
||||
determine_auth_status_from_discovery(
|
||||
server_name,
|
||||
url,
|
||||
discover_streamable_http_oauth_with_headers_and_http_client(
|
||||
url,
|
||||
default_headers,
|
||||
http_client,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_status_before_discovery(
|
||||
server_name: &str,
|
||||
url: &str,
|
||||
bearer_token_env_var: Option<&str>,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
) -> Result<AuthStatusCheck> {
|
||||
if bearer_token_env_var.is_some() {
|
||||
return Ok(McpAuthStatus::BearerToken);
|
||||
return Ok(AuthStatusCheck::Complete(McpAuthStatus::BearerToken));
|
||||
}
|
||||
|
||||
let default_headers = build_default_headers(http_headers, env_http_headers)?;
|
||||
if default_headers.contains_key(AUTHORIZATION) {
|
||||
return Ok(McpAuthStatus::BearerToken);
|
||||
return Ok(AuthStatusCheck::Complete(McpAuthStatus::BearerToken));
|
||||
}
|
||||
|
||||
match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? {
|
||||
StoredOAuthTokenStatus::Usable => return Ok(McpAuthStatus::OAuth),
|
||||
StoredOAuthTokenStatus::Usable => {
|
||||
return Ok(AuthStatusCheck::Complete(McpAuthStatus::OAuth));
|
||||
}
|
||||
StoredOAuthTokenStatus::AuthorizationRequired => {
|
||||
return Ok(McpAuthStatus::NotLoggedIn);
|
||||
return Ok(AuthStatusCheck::Complete(McpAuthStatus::NotLoggedIn));
|
||||
}
|
||||
StoredOAuthTokenStatus::Missing => {}
|
||||
}
|
||||
|
||||
match discover_streamable_http_oauth_with_headers(url, &default_headers).await {
|
||||
Ok(AuthStatusCheck::Discover(default_headers))
|
||||
}
|
||||
|
||||
fn determine_auth_status_from_discovery(
|
||||
server_name: &str,
|
||||
url: &str,
|
||||
discovery: Result<Option<StreamableHttpOAuthDiscovery>>,
|
||||
) -> Result<McpAuthStatus> {
|
||||
match discovery {
|
||||
Ok(Some(_)) => Ok(McpAuthStatus::NotLoggedIn),
|
||||
Ok(None) => Ok(McpAuthStatus::Unsupported),
|
||||
Err(error) => {
|
||||
@@ -82,6 +166,17 @@ pub async fn discover_streamable_http_oauth(
|
||||
discover_streamable_http_oauth_with_headers(url, &default_headers).await
|
||||
}
|
||||
|
||||
pub async fn discover_streamable_http_oauth_with_http_client(
|
||||
url: &str,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
|
||||
let default_headers = build_default_headers(http_headers, env_http_headers)?;
|
||||
discover_streamable_http_oauth_with_headers_and_http_client(url, default_headers, http_client)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn discover_streamable_http_oauth_with_headers(
|
||||
url: &str,
|
||||
default_headers: &HeaderMap,
|
||||
@@ -92,6 +187,25 @@ async fn discover_streamable_http_oauth_with_headers(
|
||||
let client = apply_default_headers(builder, default_headers).build()?;
|
||||
let mut authorization_manager = AuthorizationManager::new(url).await?;
|
||||
authorization_manager.with_client(client)?;
|
||||
discover_streamable_http_oauth_with_manager(&authorization_manager).await
|
||||
}
|
||||
|
||||
async fn discover_streamable_http_oauth_with_headers_and_http_client(
|
||||
url: &str,
|
||||
default_headers: HeaderMap,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
|
||||
let authorization_manager = AuthorizationManager::new_with_oauth_http_client(
|
||||
url,
|
||||
Arc::new(OAuthHttpClientAdapter::new(http_client, default_headers)),
|
||||
)
|
||||
.await?;
|
||||
discover_streamable_http_oauth_with_manager(&authorization_manager).await
|
||||
}
|
||||
|
||||
async fn discover_streamable_http_oauth_with_manager(
|
||||
authorization_manager: &AuthorizationManager,
|
||||
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
|
||||
match authorization_manager.discover_metadata().boxed().await {
|
||||
Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery {
|
||||
scopes_supported: normalize_scopes(metadata.scopes_supported),
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_api::SharedAuthProvider;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpResponseBodyStream;
|
||||
use futures::StreamExt;
|
||||
@@ -135,6 +136,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
headers: protocol_headers(&headers),
|
||||
body: Some(body.into()),
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: true,
|
||||
})
|
||||
@@ -261,6 +263,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
headers: protocol_headers(&headers),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})
|
||||
@@ -330,6 +333,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
headers: protocol_headers(&headers),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
redirect_policy: HttpRedirectPolicy::Follow,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: true,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ mod http_client_adapter;
|
||||
mod in_process_transport;
|
||||
mod logging_client_handler;
|
||||
mod oauth;
|
||||
mod oauth_http_client;
|
||||
mod perform_oauth_login;
|
||||
mod program_resolver;
|
||||
mod rmcp_client;
|
||||
@@ -13,7 +14,9 @@ mod utils;
|
||||
|
||||
pub use auth_status::StreamableHttpOAuthDiscovery;
|
||||
pub use auth_status::determine_streamable_http_auth_status;
|
||||
pub use auth_status::determine_streamable_http_auth_status_with_http_client;
|
||||
pub use auth_status::discover_streamable_http_oauth;
|
||||
pub use auth_status::discover_streamable_http_oauth_with_http_client;
|
||||
pub use auth_status::supports_oauth_login;
|
||||
pub use codex_protocol::protocol::McpAuthStatus;
|
||||
pub use in_process_transport::InProcessTransportFactory;
|
||||
@@ -26,6 +29,7 @@ pub use perform_oauth_login::OAuthProviderError;
|
||||
pub use perform_oauth_login::OauthLoginHandle;
|
||||
pub use perform_oauth_login::perform_oauth_login;
|
||||
pub use perform_oauth_login::perform_oauth_login_return_url;
|
||||
pub use perform_oauth_login::perform_oauth_login_return_url_with_http_client;
|
||||
pub use perform_oauth_login::perform_oauth_login_silent;
|
||||
pub use rmcp::model::ElicitationAction;
|
||||
pub use rmcp_client::Elicitation;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRedirectPolicy;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use oauth2::HttpRequest;
|
||||
use oauth2::HttpResponse;
|
||||
use reqwest::header::HeaderMap;
|
||||
use rmcp::transport::auth::OAuthHttpClient;
|
||||
use rmcp::transport::auth::OAuthHttpClientError;
|
||||
use rmcp::transport::auth::OAuthHttpClientFuture;
|
||||
use rmcp::transport::auth::OAuthHttpRedirectPolicy;
|
||||
use rmcp::transport::auth::OAuthHttpRequest;
|
||||
|
||||
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
|
||||
static NEXT_OAUTH_REQUEST_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct OAuthHttpClientAdapter {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
default_headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl OAuthHttpClientAdapter {
|
||||
pub(crate) fn new(http_client: Arc<dyn HttpClient>, default_headers: HeaderMap) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
default_headers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_request(
|
||||
&self,
|
||||
request: HttpRequest,
|
||||
redirect_policy: OAuthHttpRedirectPolicy,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<HttpResponse, OAuthHttpClientError> {
|
||||
let redirect_policy = match redirect_policy {
|
||||
OAuthHttpRedirectPolicy::Follow => HttpRedirectPolicy::Follow,
|
||||
OAuthHttpRedirectPolicy::Stop => HttpRedirectPolicy::Stop,
|
||||
_ => {
|
||||
return Err(OAuthHttpClientError::new(
|
||||
"unsupported OAuth HTTP redirect policy",
|
||||
));
|
||||
}
|
||||
};
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut headers = self.default_headers.clone();
|
||||
for name in parts.headers.keys() {
|
||||
headers.remove(name);
|
||||
}
|
||||
headers.extend(parts.headers);
|
||||
let headers = headers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
Ok(HttpHeader {
|
||||
name: name.as_str().to_string(),
|
||||
value: value
|
||||
.to_str()
|
||||
.map_err(|error| OAuthHttpClientError::new(error.to_string()))?
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, OAuthHttpClientError>>()?;
|
||||
let timeout_ms = timeout.map(|timeout| {
|
||||
u64::try_from(timeout.as_millis())
|
||||
.unwrap_or(u64::MAX)
|
||||
.max(1)
|
||||
});
|
||||
let request_id = NEXT_OAUTH_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let (response, mut body_stream) = self
|
||||
.http_client
|
||||
.http_request_stream(HttpRequestParams {
|
||||
method: parts.method.to_string(),
|
||||
url: parts.uri.to_string(),
|
||||
headers,
|
||||
body: (!body.is_empty()).then_some(body.into()),
|
||||
timeout_ms,
|
||||
redirect_policy,
|
||||
request_id: format!("oauth-request-{request_id}"),
|
||||
stream_response: true,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| OAuthHttpClientError::new(error.to_string()))?;
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = body_stream
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|error| OAuthHttpClientError::new(error.to_string()))?
|
||||
{
|
||||
if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() {
|
||||
return Err(OAuthHttpClientError::new(format!(
|
||||
"OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
let mut builder = oauth2::http::Response::builder().status(response.status);
|
||||
for header in response.headers {
|
||||
builder = builder.header(header.name, header.value);
|
||||
}
|
||||
builder
|
||||
.body(body)
|
||||
.map_err(|error| OAuthHttpClientError::new(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthHttpClient for OAuthHttpClientAdapter {
|
||||
fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
|
||||
Box::pin(self.execute_request(request.request, request.redirect_policy, request.timeout))
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use reqwest::ClientBuilder;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::ReqwestHttpClient;
|
||||
use reqwest::Url;
|
||||
use rmcp::transport::AuthorizationManager;
|
||||
use rmcp::transport::AuthorizationSession;
|
||||
use rmcp::transport::auth::OAuthClientConfig;
|
||||
use rmcp::transport::auth::OAuthHttpClient;
|
||||
use rmcp::transport::auth::OAuthState;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
@@ -26,15 +28,16 @@ use urlencoding::decode;
|
||||
use crate::StoredOAuthTokens;
|
||||
use crate::WrappedOAuthTokenResponse;
|
||||
use crate::oauth::compute_expires_at_millis;
|
||||
use crate::oauth_http_client::OAuthHttpClientAdapter;
|
||||
use crate::save_oauth_tokens;
|
||||
use crate::utils::apply_default_headers;
|
||||
use crate::utils::build_default_headers;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
|
||||
struct OauthHeaders {
|
||||
struct OAuthHttpContext {
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
struct CallbackServerGuard {
|
||||
@@ -154,16 +157,17 @@ async fn perform_oauth_login_with_browser_output(
|
||||
callback_url: Option<&str>,
|
||||
emit_browser_url: bool,
|
||||
) -> Result<()> {
|
||||
let headers = OauthHeaders {
|
||||
let http_context = OAuthHttpContext {
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
http_client: Arc::new(ReqwestHttpClient),
|
||||
};
|
||||
OauthLoginFlow::new(
|
||||
server_name,
|
||||
server_url,
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
headers,
|
||||
http_context,
|
||||
scopes,
|
||||
oauth_client_id,
|
||||
oauth_resource,
|
||||
@@ -192,16 +196,51 @@ pub async fn perform_oauth_login_return_url(
|
||||
callback_port: Option<u16>,
|
||||
callback_url: Option<&str>,
|
||||
) -> Result<OauthLoginHandle> {
|
||||
let headers = OauthHeaders {
|
||||
perform_oauth_login_return_url_with_http_client(
|
||||
server_name,
|
||||
server_url,
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
scopes,
|
||||
oauth_client_id,
|
||||
oauth_resource,
|
||||
timeout_secs,
|
||||
callback_port,
|
||||
callback_url,
|
||||
Arc::new(ReqwestHttpClient),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn perform_oauth_login_return_url_with_http_client(
|
||||
server_name: &str,
|
||||
server_url: &str,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
scopes: &[String],
|
||||
oauth_client_id: Option<&str>,
|
||||
oauth_resource: Option<&str>,
|
||||
timeout_secs: Option<i64>,
|
||||
callback_port: Option<u16>,
|
||||
callback_url: Option<&str>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<OauthLoginHandle> {
|
||||
let http_context = OAuthHttpContext {
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
http_client,
|
||||
};
|
||||
let flow = OauthLoginFlow::new(
|
||||
server_name,
|
||||
server_url,
|
||||
store_mode,
|
||||
keyring_backend_kind,
|
||||
headers,
|
||||
http_context,
|
||||
scopes,
|
||||
oauth_client_id,
|
||||
oauth_resource,
|
||||
@@ -456,7 +495,7 @@ impl OauthLoginFlow {
|
||||
server_url: &str,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
headers: OauthHeaders,
|
||||
http_context: OAuthHttpContext,
|
||||
scopes: &[String],
|
||||
oauth_client_id: Option<&str>,
|
||||
oauth_resource: Option<&str>,
|
||||
@@ -487,17 +526,18 @@ impl OauthLoginFlow {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
spawn_callback_server(server, tx, callback_path);
|
||||
|
||||
let OauthHeaders {
|
||||
let OAuthHttpContext {
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} = headers;
|
||||
http_client,
|
||||
} = http_context;
|
||||
let default_headers = build_default_headers(http_headers, env_http_headers)?;
|
||||
let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?;
|
||||
let oauth_http_client = Arc::new(OAuthHttpClientAdapter::new(http_client, default_headers));
|
||||
|
||||
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
|
||||
let oauth_state = start_authorization(
|
||||
server_url,
|
||||
http_client,
|
||||
oauth_http_client,
|
||||
&scope_refs,
|
||||
&redirect_uri,
|
||||
oauth_client_id,
|
||||
@@ -620,22 +660,23 @@ impl OauthLoginFlow {
|
||||
|
||||
async fn start_authorization(
|
||||
server_url: &str,
|
||||
http_client: reqwest::Client,
|
||||
http_client: Arc<dyn OAuthHttpClient>,
|
||||
scopes: &[&str],
|
||||
redirect_uri: &str,
|
||||
oauth_client_id: Option<&str>,
|
||||
) -> Result<OAuthState> {
|
||||
let Some(oauth_client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty())
|
||||
else {
|
||||
let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?;
|
||||
let mut oauth_state =
|
||||
OAuthState::new_with_oauth_http_client(server_url, http_client).await?;
|
||||
oauth_state
|
||||
.start_authorization(scopes, redirect_uri, Some("Codex"))
|
||||
.await?;
|
||||
return Ok(oauth_state);
|
||||
};
|
||||
|
||||
let mut auth_manager = AuthorizationManager::new(server_url).await?;
|
||||
auth_manager.with_client(http_client)?;
|
||||
let mut auth_manager =
|
||||
AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?;
|
||||
let metadata = auth_manager.discover_metadata().await?;
|
||||
auth_manager.set_metadata(metadata);
|
||||
auth_manager.configure_client(
|
||||
@@ -668,15 +709,20 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use codex_exec_server::ReqwestHttpClient;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::Url;
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use super::CallbackOutcome;
|
||||
use super::OAuthHttpClientAdapter;
|
||||
use super::OAuthProviderError;
|
||||
use super::append_callback_id_to_redirect_uri;
|
||||
use super::append_query_param;
|
||||
@@ -727,7 +773,10 @@ mod tests {
|
||||
let base_url = spawn_oauth_metadata_server().await;
|
||||
let oauth_state = start_authorization(
|
||||
&format!("{base_url}/mcp"),
|
||||
reqwest::Client::new(),
|
||||
Arc::new(OAuthHttpClientAdapter::new(
|
||||
Arc::new(ReqwestHttpClient),
|
||||
HeaderMap::new(),
|
||||
)),
|
||||
&[],
|
||||
"http://127.0.0.1/callback",
|
||||
Some("eci-prd-pub-codex-123"),
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::time::Instant;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_exec_server::HttpClient;
|
||||
@@ -68,11 +67,11 @@ use crate::in_process_transport::InProcessTransportFactory;
|
||||
use crate::load_oauth_tokens;
|
||||
use crate::oauth::OAuthPersistor;
|
||||
use crate::oauth::StoredOAuthTokens;
|
||||
use crate::oauth_http_client::OAuthHttpClientAdapter;
|
||||
use crate::stdio_server_launcher::StdioServerCommand;
|
||||
use crate::stdio_server_launcher::StdioServerLauncher;
|
||||
use crate::stdio_server_launcher::StdioServerProcessHandle;
|
||||
use crate::stdio_server_launcher::StdioServerTransport;
|
||||
use crate::utils::apply_default_headers;
|
||||
use crate::utils::build_default_headers;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
|
||||
@@ -1166,16 +1165,12 @@ async fn create_oauth_transport_and_runtime(
|
||||
StreamableHttpClientTransport<AuthClient<StreamableHttpClientAdapter>>,
|
||||
OAuthPersistor,
|
||||
)> {
|
||||
let mut builder = apply_default_headers(reqwest::Client::builder(), &default_headers);
|
||||
if let Some(tls_config) = maybe_build_rustls_client_config_with_custom_ca()? {
|
||||
builder = builder.tls_backend_preconfigured(tls_config.as_ref().clone());
|
||||
}
|
||||
let oauth_metadata_client = builder.build()?;
|
||||
// TODO(aibrahim): teach OAuth bootstrap and refresh to use the same
|
||||
// shared HTTP client abstraction instead of always creating the local
|
||||
// reqwest metadata client here.
|
||||
let oauth_http_client = Arc::new(OAuthHttpClientAdapter::new(
|
||||
http_client.clone(),
|
||||
default_headers.clone(),
|
||||
));
|
||||
let mut oauth_state =
|
||||
OAuthState::new(url.to_string(), Some(oauth_metadata_client.clone())).await?;
|
||||
OAuthState::new_with_oauth_http_client(url.to_string(), oauth_http_client).await?;
|
||||
|
||||
oauth_state
|
||||
.set_credentials(
|
||||
|
||||
Reference in New Issue
Block a user