mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
fix(apps): persist codex mcp affinity cookies
This commit is contained in:
Generated
+56
@@ -2881,6 +2881,17 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
@@ -2890,6 +2901,24 @@ dependencies = [
|
||||
"futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -3586,6 +3615,15 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
@@ -5497,6 +5535,12 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "local-waker"
|
||||
version = "0.1.4"
|
||||
@@ -7069,6 +7113,16 @@ version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.10.3"
|
||||
@@ -7752,6 +7806,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
||||
@@ -4993,8 +4993,9 @@ impl CodexMessageProcessor {
|
||||
|
||||
let request = request_id.clone();
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let apps_mcp_cookie_store = self.thread_manager.apps_mcp_cookie_store();
|
||||
tokio::spawn(async move {
|
||||
Self::apps_list_task(outgoing, request, params, config).await;
|
||||
Self::apps_list_task(outgoing, request, params, config, apps_mcp_cookie_store).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5003,6 +5004,7 @@ impl CodexMessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: AppsListParams,
|
||||
config: Config,
|
||||
apps_mcp_cookie_store: Arc<codex_core::AppsMcpCookieStore>,
|
||||
) {
|
||||
let AppsListParams {
|
||||
cursor,
|
||||
@@ -5037,12 +5039,14 @@ impl CodexMessageProcessor {
|
||||
let accessible_config = config.clone();
|
||||
let accessible_tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = connectors::list_accessible_connectors_from_mcp_tools_with_options(
|
||||
&accessible_config,
|
||||
force_refetch,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load accessible apps: {err}"));
|
||||
let result =
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_options_and_cookie_store(
|
||||
&accessible_config,
|
||||
force_refetch,
|
||||
Some(apps_mcp_cookie_store),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load accessible apps: {err}"));
|
||||
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -12,11 +14,19 @@ use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::http::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::header::COOKIE;
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::middleware;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppBranding;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
@@ -1016,7 +1026,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let warm_request = mcp
|
||||
.send_apps_list_request(AppsListParams {
|
||||
limit: None,
|
||||
@@ -1201,6 +1210,70 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_force_refetch_reuses_affinity_cookie_across_temp_mcp_clients() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta App".to_string(),
|
||||
description: Some("Beta connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, affinity_cookie_state, server_handle) =
|
||||
start_apps_server_with_affinity_cookie(connectors, tools).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
for _ in 0..2 {
|
||||
let request = mcp
|
||||
.send_apps_list_request(AppsListParams {
|
||||
limit: None,
|
||||
cursor: None,
|
||||
thread_id: None,
|
||||
force_refetch: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request)),
|
||||
)
|
||||
.await??;
|
||||
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
||||
assert_eq!(data.len(), 1);
|
||||
assert!(data.iter().all(|app| app.is_accessible));
|
||||
assert!(next_cursor.is_none());
|
||||
}
|
||||
|
||||
assert!(
|
||||
affinity_cookie_state.request_count.load(Ordering::SeqCst) >= 5,
|
||||
"expected the initial refresh plus a later cookie-gated second-manager request",
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_app_list_updated_notification(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<AppListUpdatedNotification> {
|
||||
@@ -1222,6 +1295,12 @@ struct AppsServerState {
|
||||
expected_account_id: String,
|
||||
response: Arc<StdMutex<serde_json::Value>>,
|
||||
directory_delay: Duration,
|
||||
affinity_cookie_state: Option<Arc<AffinityCookieState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AffinityCookieState {
|
||||
request_count: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1310,6 +1389,32 @@ async fn start_apps_server_with_delays_and_control(
|
||||
tools: Vec<Tool>,
|
||||
directory_delay: Duration,
|
||||
tools_delay: Duration,
|
||||
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
|
||||
start_apps_server(connectors, tools, directory_delay, tools_delay, None).await
|
||||
}
|
||||
|
||||
async fn start_apps_server_with_affinity_cookie(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(String, Arc<AffinityCookieState>, JoinHandle<()>)> {
|
||||
let affinity_cookie_state = Arc::new(AffinityCookieState::default());
|
||||
let (server_url, handle, _server_control) = start_apps_server(
|
||||
connectors,
|
||||
tools,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
Some(Arc::clone(&affinity_cookie_state)),
|
||||
)
|
||||
.await?;
|
||||
Ok((server_url, affinity_cookie_state, handle))
|
||||
}
|
||||
|
||||
async fn start_apps_server(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
directory_delay: Duration,
|
||||
tools_delay: Duration,
|
||||
affinity_cookie_state: Option<Arc<AffinityCookieState>>,
|
||||
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
|
||||
let response = Arc::new(StdMutex::new(
|
||||
json!({ "apps": connectors, "next_token": null }),
|
||||
@@ -1320,6 +1425,7 @@ async fn start_apps_server_with_delays_and_control(
|
||||
expected_account_id: "account-123".to_string(),
|
||||
response: response.clone(),
|
||||
directory_delay,
|
||||
affinity_cookie_state,
|
||||
};
|
||||
let state = Arc::new(state);
|
||||
let server_control = AppsServerControl {
|
||||
@@ -1345,8 +1451,12 @@ async fn start_apps_server_with_delays_and_control(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
.nest_service("/api/codex/apps", mcp_service)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
Arc::clone(&state),
|
||||
require_affinity_cookie,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
@@ -1355,6 +1465,43 @@ async fn start_apps_server_with_delays_and_control(
|
||||
Ok((format!("http://{addr}"), handle, server_control))
|
||||
}
|
||||
|
||||
async fn require_affinity_cookie(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if !request.uri().path().starts_with("/api/codex/apps") {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
let Some(affinity_cookie_state) = state.affinity_cookie_state.as_ref() else {
|
||||
return Ok(next.run(request).await);
|
||||
};
|
||||
|
||||
let request_number = affinity_cookie_state
|
||||
.request_count
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
+ 1;
|
||||
if request_number > 4
|
||||
&& !request
|
||||
.headers()
|
||||
.get(COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("__cflb=sticky"))
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
if request_number <= 4 {
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_static("__cflb=sticky; Path=/"),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -25,6 +25,7 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
|
||||
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_cookie_store;
|
||||
use codex_core::connectors::merge_connectors;
|
||||
use codex_core::connectors::merge_plugin_apps;
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
|
||||
@@ -74,7 +74,7 @@ once_cell = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
reqwest = { workspace = true, features = ["cookies", "json", "stream"] }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"base64",
|
||||
"macros",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
use reqwest::cookie::Jar;
|
||||
|
||||
use crate::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::mcp::codex_apps_mcp_url;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct AppsMcpCookieStoreKey {
|
||||
url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AppsMcpCookieStore {
|
||||
jars: Arc<StdMutex<HashMap<AppsMcpCookieStoreKey, Arc<Jar>>>>,
|
||||
}
|
||||
|
||||
impl AppsMcpCookieStore {
|
||||
pub fn jar_for(&self, config: &Config, auth: Option<&CodexAuth>) -> Arc<Jar> {
|
||||
let token_data = auth.and_then(|auth| auth.get_token_data().ok());
|
||||
self.jar_for_identity(
|
||||
codex_apps_mcp_url(config),
|
||||
token_data
|
||||
.as_ref()
|
||||
.and_then(|token_data| token_data.account_id.clone()),
|
||||
token_data
|
||||
.as_ref()
|
||||
.and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()),
|
||||
token_data
|
||||
.as_ref()
|
||||
.is_some_and(|token_data| token_data.id_token.is_workspace_account()),
|
||||
)
|
||||
}
|
||||
|
||||
fn jar_for_identity(
|
||||
&self,
|
||||
url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
) -> Arc<Jar> {
|
||||
let key = AppsMcpCookieStoreKey {
|
||||
url,
|
||||
account_id,
|
||||
chatgpt_user_id,
|
||||
is_workspace_account,
|
||||
};
|
||||
let mut jars = self
|
||||
.jars
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Arc::clone(jars.entry(key).or_insert_with(|| Arc::new(Jar::default())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jar_for_identity_reuses_matching_key() {
|
||||
let store = AppsMcpCookieStore::default();
|
||||
let left = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-1".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
false,
|
||||
);
|
||||
let right = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-1".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(Arc::ptr_eq(&left, &right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_for_identity_separates_account_boundaries() {
|
||||
let store = AppsMcpCookieStore::default();
|
||||
let left = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-1".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
false,
|
||||
);
|
||||
let right = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-2".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(!Arc::ptr_eq(&left, &right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_for_identity_separates_workspace_boundaries() {
|
||||
let store = AppsMcpCookieStore::default();
|
||||
let left = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-1".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
false,
|
||||
);
|
||||
let right = store.jar_for_identity(
|
||||
"https://example.test/apps".to_string(),
|
||||
Some("account-1".to_string()),
|
||||
Some("user-1".to_string()),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(!Arc::ptr_eq(&left, &right));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
use crate::AppsMcpCookieStore;
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
@@ -356,6 +357,7 @@ impl Codex {
|
||||
pub(crate) async fn spawn(
|
||||
mut config: Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
apps_mcp_cookie_store: Arc<AppsMcpCookieStore>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
@@ -539,6 +541,7 @@ impl Codex {
|
||||
session_configuration,
|
||||
config.clone(),
|
||||
auth_manager.clone(),
|
||||
apps_mcp_cookie_store,
|
||||
models_manager.clone(),
|
||||
exec_policy,
|
||||
tx_event.clone(),
|
||||
@@ -1257,6 +1260,7 @@ impl Session {
|
||||
mut session_configuration: SessionConfiguration,
|
||||
config: Arc<Config>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
apps_mcp_cookie_store: Arc<AppsMcpCookieStore>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
exec_policy: ExecPolicyManager,
|
||||
tx_event: Sender<Event>,
|
||||
@@ -1427,6 +1431,9 @@ impl Session {
|
||||
}
|
||||
|
||||
let auth = auth.as_ref();
|
||||
let apps_mcp_cookie_jar = mcp_servers
|
||||
.contains_key(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then(|| apps_mcp_cookie_store.jar_for(config.as_ref(), auth));
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
let account_id = auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = auth.and_then(CodexAuth::get_account_email);
|
||||
@@ -1612,6 +1619,7 @@ impl Session {
|
||||
&config.permissions.approval_policy,
|
||||
))),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
apps_mcp_cookie_store,
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
@@ -1738,6 +1746,7 @@ impl Session {
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
apps_mcp_cookie_jar,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -3912,6 +3921,13 @@ impl Session {
|
||||
auth.as_ref(),
|
||||
config.as_ref(),
|
||||
);
|
||||
let apps_mcp_cookie_jar = mcp_servers
|
||||
.contains_key(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then(|| {
|
||||
self.services
|
||||
.apps_mcp_cookie_store
|
||||
.jar_for(config.as_ref(), auth.as_ref())
|
||||
});
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
@@ -3934,6 +3950,7 @@ impl Session {
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
apps_mcp_cookie_jar,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
Arc::clone(&parent_session.services.apps_mcp_cookie_store),
|
||||
models_manager,
|
||||
Arc::clone(&parent_session.services.skills_manager),
|
||||
Arc::clone(&parent_session.services.plugins_manager),
|
||||
|
||||
@@ -18,6 +18,7 @@ use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AppsMcpCookieStore;
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
@@ -107,15 +108,48 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
|
||||
force_refetch: bool,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
Ok(
|
||||
list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch)
|
||||
.await?
|
||||
.connectors,
|
||||
list_accessible_connectors_from_mcp_tools_with_options_and_status_and_cookie_store(
|
||||
config,
|
||||
force_refetch,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.connectors,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
||||
list_accessible_connectors_from_mcp_tools_with_options_and_status_and_cookie_store(
|
||||
config,
|
||||
force_refetch,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_cookie_store(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
apps_mcp_cookie_store: Option<Arc<AppsMcpCookieStore>>,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
Ok(
|
||||
list_accessible_connectors_from_mcp_tools_with_options_and_status_and_cookie_store(
|
||||
config,
|
||||
force_refetch,
|
||||
apps_mcp_cookie_store,
|
||||
)
|
||||
.await?
|
||||
.connectors,
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_accessible_connectors_from_mcp_tools_with_options_and_status_and_cookie_store(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
apps_mcp_cookie_store: Option<Arc<AppsMcpCookieStore>>,
|
||||
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
let auth = auth_manager.auth().await;
|
||||
@@ -145,6 +179,11 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
codex_apps_ready: true,
|
||||
});
|
||||
}
|
||||
let apps_mcp_cookie_store =
|
||||
apps_mcp_cookie_store.unwrap_or_else(|| Arc::new(AppsMcpCookieStore::default()));
|
||||
let apps_mcp_cookie_jar = mcp_servers
|
||||
.contains_key(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then(|| apps_mcp_cookie_store.jar_for(config, auth.as_ref()));
|
||||
|
||||
let auth_status_entries =
|
||||
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
|
||||
@@ -169,6 +208,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
apps_mcp_cookie_jar,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
mod arc_monitor;
|
||||
mod apps_mcp_cookie_store;
|
||||
pub mod auth;
|
||||
mod client;
|
||||
mod client_common;
|
||||
@@ -90,6 +91,8 @@ mod seatbelt_permissions;
|
||||
mod thread_manager;
|
||||
pub mod web_search;
|
||||
pub mod windows_sandbox_read_grants;
|
||||
pub use apps_mcp_cookie_store::AppsMcpCookieStore;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use thread_manager::NewThread;
|
||||
pub use thread_manager::ThreadManager;
|
||||
#[deprecated(note = "use ThreadManager")]
|
||||
|
||||
@@ -317,6 +317,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_rmcp_client::SendElicitation;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use reqwest::cookie::Jar;
|
||||
use rmcp::model::ClientCapabilities;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::ElicitationAction;
|
||||
@@ -428,6 +429,7 @@ impl AsyncManagedClient {
|
||||
server_name: String,
|
||||
config: McpServerConfig,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
codex_apps_cookie_jar: Option<Arc<Jar>>,
|
||||
cancel_token: CancellationToken,
|
||||
tx_event: Sender<Event>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
@@ -449,8 +451,15 @@ impl AsyncManagedClient {
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
let client =
|
||||
Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?);
|
||||
let client = Arc::new(
|
||||
make_rmcp_client(
|
||||
&server_name,
|
||||
config.transport,
|
||||
store_mode,
|
||||
codex_apps_cookie_jar,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
match start_server_task(
|
||||
server_name,
|
||||
client,
|
||||
@@ -634,6 +643,7 @@ impl McpConnectionManager {
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
codex_apps_cookie_jar: Option<Arc<Jar>>,
|
||||
) -> (Self, CancellationToken) {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mut clients = HashMap::new();
|
||||
@@ -647,6 +657,11 @@ impl McpConnectionManager {
|
||||
server_origins.insert(server_name.clone(), origin);
|
||||
}
|
||||
let cancel_token = cancel_token.child_token();
|
||||
let server_cookie_jar = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
codex_apps_cookie_jar.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = emit_update(
|
||||
&tx_event,
|
||||
McpStartupUpdateEvent {
|
||||
@@ -667,6 +682,7 @@ impl McpConnectionManager {
|
||||
server_name.clone(),
|
||||
cfg,
|
||||
store_mode,
|
||||
server_cookie_jar,
|
||||
cancel_token.clone(),
|
||||
tx_event.clone(),
|
||||
elicitation_requests.clone(),
|
||||
@@ -1402,6 +1418,7 @@ async fn make_rmcp_client(
|
||||
server_name: &str,
|
||||
transport: McpServerTransportConfig,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
cookie_jar: Option<Arc<Jar>>,
|
||||
) -> Result<RmcpClient, StartupOutcomeError> {
|
||||
match transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
@@ -1435,6 +1452,7 @@ async fn make_rmcp_client(
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
store_mode,
|
||||
cookie_jar,
|
||||
)
|
||||
.await
|
||||
.map_err(StartupOutcomeError::from)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::AppsMcpCookieStore;
|
||||
use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::agent::AgentControl;
|
||||
@@ -54,6 +55,7 @@ impl CodeModeStoreService {
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) apps_mcp_cookie_store: Arc<AppsMcpCookieStore>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) shell_zsh_path: Option<PathBuf>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::AppsMcpCookieStore;
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::ModelProviderInfo;
|
||||
@@ -131,6 +132,7 @@ pub struct ThreadManager {
|
||||
pub(crate) struct ThreadManagerState {
|
||||
threads: Arc<RwLock<HashMap<ThreadId, Arc<CodexThread>>>>,
|
||||
thread_created_tx: broadcast::Sender<ThreadId>,
|
||||
apps_mcp_cookie_store: Arc<AppsMcpCookieStore>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
@@ -163,6 +165,7 @@ impl ThreadManager {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
thread_created_tx,
|
||||
apps_mcp_cookie_store: Arc::new(AppsMcpCookieStore::default()),
|
||||
models_manager: Arc::new(ModelsManager::new(
|
||||
codex_home,
|
||||
auth_manager.clone(),
|
||||
@@ -223,6 +226,7 @@ impl ThreadManager {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
thread_created_tx,
|
||||
apps_mcp_cookie_store: Arc::new(AppsMcpCookieStore::default()),
|
||||
models_manager: Arc::new(ModelsManager::with_provider_for_tests(
|
||||
codex_home,
|
||||
auth_manager.clone(),
|
||||
@@ -265,6 +269,10 @@ impl ThreadManager {
|
||||
self.state.models_manager.clone()
|
||||
}
|
||||
|
||||
pub fn apps_mcp_cookie_store(&self) -> Arc<AppsMcpCookieStore> {
|
||||
Arc::clone(&self.state.apps_mcp_cookie_store)
|
||||
}
|
||||
|
||||
pub async fn list_models(
|
||||
&self,
|
||||
refresh_strategy: crate::models_manager::manager::RefreshStrategy,
|
||||
@@ -600,6 +608,7 @@ impl ThreadManagerState {
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
Arc::clone(&self.apps_mcp_cookie_store),
|
||||
Arc::clone(&self.models_manager),
|
||||
Arc::clone(&self.skills_manager),
|
||||
Arc::clone(&self.plugins_manager),
|
||||
|
||||
@@ -21,6 +21,7 @@ futures = { workspace = true, default-features = false, features = ["std"] }
|
||||
keyring = { workspace = true, features = ["crypto-rust"] }
|
||||
oauth2 = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"cookies",
|
||||
"json",
|
||||
"stream",
|
||||
"rustls-tls",
|
||||
|
||||
@@ -15,6 +15,7 @@ use futures::future::BoxFuture;
|
||||
use futures::stream::BoxStream;
|
||||
use oauth2::TokenResponse;
|
||||
use reqwest::header::ACCEPT;
|
||||
use reqwest::cookie::Jar;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
@@ -395,6 +396,7 @@ enum TransportRecipe {
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
cookie_jar: Option<Arc<Jar>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -504,6 +506,7 @@ impl RmcpClient {
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
cookie_jar: Option<Arc<Jar>>,
|
||||
) -> Result<Self> {
|
||||
let transport_recipe = TransportRecipe::StreamableHttp {
|
||||
server_name: server_name.to_string(),
|
||||
@@ -512,6 +515,7 @@ impl RmcpClient {
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
store_mode,
|
||||
cookie_jar,
|
||||
};
|
||||
let transport = Self::create_pending_transport(&transport_recipe).await?;
|
||||
Ok(Self {
|
||||
@@ -868,6 +872,7 @@ impl RmcpClient {
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
store_mode,
|
||||
cookie_jar,
|
||||
} => {
|
||||
let default_headers =
|
||||
build_default_headers(http_headers.clone(), env_http_headers.clone())?;
|
||||
@@ -892,6 +897,7 @@ impl RmcpClient {
|
||||
initial_tokens.clone(),
|
||||
*store_mode,
|
||||
default_headers.clone(),
|
||||
cookie_jar.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -919,8 +925,7 @@ impl RmcpClient {
|
||||
StreamableHttpClientTransportConfig::with_uri(url.clone())
|
||||
.auth_header(access_token);
|
||||
let http_client =
|
||||
apply_default_headers(reqwest::Client::builder(), &default_headers)
|
||||
.build()?;
|
||||
build_http_client(default_headers.clone(), cookie_jar.clone())?;
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
StreamableHttpResponseClient::new(http_client),
|
||||
http_config,
|
||||
@@ -936,9 +941,7 @@ impl RmcpClient {
|
||||
http_config = http_config.auth_header(bearer_token);
|
||||
}
|
||||
|
||||
let http_client =
|
||||
apply_default_headers(reqwest::Client::builder(), &default_headers)
|
||||
.build()?;
|
||||
let http_client = build_http_client(default_headers, cookie_jar.clone())?;
|
||||
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
StreamableHttpResponseClient::new(http_client),
|
||||
@@ -1122,12 +1125,12 @@ async fn create_oauth_transport_and_runtime(
|
||||
initial_tokens: StoredOAuthTokens,
|
||||
credentials_store: OAuthCredentialsStoreMode,
|
||||
default_headers: HeaderMap,
|
||||
cookie_jar: Option<Arc<Jar>>,
|
||||
) -> Result<(
|
||||
StreamableHttpClientTransport<AuthClient<StreamableHttpResponseClient>>,
|
||||
OAuthPersistor,
|
||||
)> {
|
||||
let http_client =
|
||||
apply_default_headers(reqwest::Client::builder(), &default_headers).build()?;
|
||||
let http_client = build_http_client(default_headers, cookie_jar)?;
|
||||
let mut oauth_state = OAuthState::new(url.to_string(), Some(http_client.clone())).await?;
|
||||
|
||||
oauth_state
|
||||
@@ -1163,3 +1166,15 @@ async fn create_oauth_transport_and_runtime(
|
||||
|
||||
Ok((transport, runtime))
|
||||
}
|
||||
|
||||
fn build_http_client(
|
||||
default_headers: HeaderMap,
|
||||
cookie_jar: Option<Arc<Jar>>,
|
||||
) -> Result<reqwest::Client> {
|
||||
let builder = apply_default_headers(reqwest::Client::builder(), &default_headers);
|
||||
let builder = match cookie_jar {
|
||||
Some(cookie_jar) => builder.cookie_provider(cookie_jar),
|
||||
None => builder,
|
||||
};
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::http::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::COOKIE;
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::middleware;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use futures::FutureExt as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::cookie::Jar;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::ClientCapabilities;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
use rmcp::model::Implementation;
|
||||
use rmcp::model::InitializeRequestParams;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::ProtocolVersion;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::transport::StreamableHttpServerConfig;
|
||||
use rmcp::transport::StreamableHttpService;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const AFFINITY_COOKIE: &str = "__cflb=sticky";
|
||||
|
||||
#[derive(Default)]
|
||||
struct AffinityCookieState {
|
||||
request_count: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CookieToolServer {
|
||||
tools: Arc<Vec<Tool>>,
|
||||
}
|
||||
|
||||
impl CookieToolServer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tools: Arc::new(vec![echo_tool()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for CookieToolServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
..ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
|
||||
{
|
||||
let tools = Arc::clone(&self.tools);
|
||||
async move {
|
||||
Ok(ListToolsResult {
|
||||
tools: (*tools).clone(),
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn echo_tool() -> Tool {
|
||||
let schema: JsonObject = serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
}))
|
||||
.unwrap_or_else(|err| panic!("cookie test tool schema should deserialize: {err}"));
|
||||
|
||||
Tool::new(
|
||||
Cow::Borrowed("echo"),
|
||||
Cow::Borrowed("Echo back the provided message."),
|
||||
Arc::new(schema),
|
||||
)
|
||||
}
|
||||
|
||||
fn init_params() -> InitializeRequestParams {
|
||||
InitializeRequestParams {
|
||||
meta: None,
|
||||
capabilities: ClientCapabilities {
|
||||
experimental: None,
|
||||
extensions: None,
|
||||
roots: None,
|
||||
sampling: None,
|
||||
elicitation: Some(ElicitationCapability {
|
||||
form: Some(FormElicitationCapability {
|
||||
schema_validation: None,
|
||||
}),
|
||||
url: None,
|
||||
}),
|
||||
tasks: None,
|
||||
},
|
||||
client_info: Implementation {
|
||||
name: "codex-test".into(),
|
||||
version: "0.0.0-test".into(),
|
||||
title: Some("Codex rmcp cookie test".into()),
|
||||
description: None,
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
protocol_version: ProtocolVersion::V_2025_06_18,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn streamable_http_client_replays_affinity_cookie_from_initialize_response() -> Result<()> {
|
||||
let affinity_cookie_state = Arc::new(AffinityCookieState::default());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
|| Ok(CookieToolServer::new()),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router =
|
||||
Router::new()
|
||||
.nest_service("/mcp", mcp_service)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
Arc::clone(&affinity_cookie_state),
|
||||
require_affinity_cookie,
|
||||
));
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
let cookie_jar = Arc::new(Jar::default());
|
||||
let client = RmcpClient::new_streamable_http_client(
|
||||
"codex_apps",
|
||||
&format!("http://{addr}/mcp"),
|
||||
Some("bearer-token".to_string()),
|
||||
None,
|
||||
None,
|
||||
OAuthCredentialsStoreMode::File,
|
||||
Some(cookie_jar),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.initialize(
|
||||
init_params(),
|
||||
Some(Duration::from_secs(5)),
|
||||
Box::new(|_, _| {
|
||||
async {
|
||||
Ok(ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: Some(json!({})),
|
||||
})
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let tools = client
|
||||
.list_tools(None, Some(Duration::from_secs(5)))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
tools,
|
||||
ListToolsResult {
|
||||
tools: vec![echo_tool()],
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
affinity_cookie_state.request_count.load(Ordering::SeqCst) >= 2,
|
||||
"expected initialize plus at least one follow-up request",
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn require_affinity_cookie(
|
||||
State(affinity_cookie_state): State<Arc<AffinityCookieState>>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request_number = affinity_cookie_state
|
||||
.request_count
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
+ 1;
|
||||
if request_number > 1
|
||||
&& !request
|
||||
.headers()
|
||||
.get(COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains(AFFINITY_COOKIE))
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
if request_number == 1 {
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_static("__cflb=sticky; Path=/"),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Reference in New Issue
Block a user