Files
codex/codex-rs/core/tests/suite/rmcp_client.rs
pmccrary-oai 6af345407d Gate experimental context by model capability at session startup (#43147)
## Why

Experimental context activation previously checked the provider and account eligibility without checking model support. Child sessions also inherited token-budget activation from their parent, even when starting fresh with a different model.

## What changed

- Add `ModelInfo.supports_experimental_context`, defaulting to `false`, and enable it for the bundled `gpt-6-astra` model. Require this capability when activating experimental context.
- Snapshot configured token-budget preferences before startup activation. Restore them for fresh child sessions before applying their starting model's defaults, while history forks retain their parent's activation.
- Pass unresolved token-budget preferences to child sessions so they can use their own model's prompts.

## Testing

Extend coverage for unsupported models, model-switch guidance under explicit and experimental activation, and child configuration from both active and inactive parents. Verify that omitted capability metadata defaults to `false`.

GitOrigin-RevId: 02df9e171682267232fa923d5ea3f7af36527808
2026-09-06 02:28:38 +00:00

4511 lines
158 KiB
Rust

use anyhow::Context as _;
use anyhow::ensure;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::fs;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command as StdCommand;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use codex_config::types::McpServerAuth;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerEnvVar;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_core::EnvironmentConfig;
use codex_core::EnvironmentMcpPolicy;
use codex_core::TurnInputRequest;
use codex_core::config::Config;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::Environment;
use codex_exec_server::HttpRedirectPolicy;
use codex_exec_server::HttpRequestParams;
use codex_features::Feature;
use codex_http_client::HttpClientBuilder;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
use codex_mcp::SandboxState;
use codex_models_manager::manager::RefreshStrategy;
use codex_utils_path_uri::LegacyAppPathString;
use codex_history::RolloutItem;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::mcp_policy::McpServerIdentity;
use codex_protocol::mcp_policy::McpServerRequirement;
use codex_protocol::mcp_policy::PluginMcpRequirements;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ConfirmationPolicies;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpStartupFailureReason;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::user_input::UserInput;
use codex_utils_cargo_bin::cargo_bin;
use codex_utils_path_uri::PathUri;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::assert_regex_match;
use core_test_support::is_remote_test_environment;
use core_test_support::responses;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_no_remote_env;
use core_test_support::skip_if_wine_exec;
use core_test_support::stdio_server_bin;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::test_env;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::test_docker_container_name;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use http::StatusCode;
use image::DynamicImage;
use image::GenericImageView;
use image::ImageBuffer;
use image::Rgba;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use serial_test::serial;
use std::io::Cursor;
use tempfile::tempdir;
use test_case::test_case;
use tokio::process::Child;
use tokio::process::Command;
use tokio::time::Instant;
use tokio::time::sleep;
use wiremock::MockServer;
#[path = "mcp_oauth_refresh_tests.rs"]
mod oauth_refresh_tests;
static OPENAI_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD0AAAA9CAYAAAAeYmHpAAAE6klEQVR4Aeyau44UVxCGx1fZsmRLlm3Zoe0XcGQ5cUiCCIgJeS9CHgAhMkISQnIuGQgJEkBcxLW+nqnZ6uqqc+nuWRC7q/P3qetf9e+MtOwyX25O4Nep6JPyop++0qev9HrfgZ+F6r2DuB/vHOrt/UIkqdDHYvujOW6fO7h/CNEI+a5jc+pBR8uy0jVFsziYu5HtfSUk+Io34q921hLNctFSX0gwww+S8wce8K1LfCU+cYW4888aov8NxqvQILUPPReLOrm6zyLxa4i+6VZuFbJo8d1MOHZm+7VUtB/aIvhPWc/3SWg49JcwFLlHxuXKjtyloo+YNhuW3VS+WPBuUEMvCFKjEDVgFBQHXrnazpqiSxNZCkQ1kYiozsbm9Oz7l4i2Il7vGccGNWAc3XosDrZe/9P3ZnMmzHNEQw4smf8RQ87XEAMsC7Az0Au+dgXerfH4+sHvEc0SYGic8WBBUGqFH2gN7yDrazy7m2pbRTeRmU3+MjZmr1h6LJgPbGy23SI6GlYT0brQ71IY8Us4PNQCm+zepSbaD2BY9xCaAsD9IIj/IzFmKMSdHHonwdZATbTnYREf6/VZGER98N9yCWIvXQwXDoDdhZJoT8jwLnJXDB9w4Sb3e6nK5ndzlkTLnP3JBu4LKkbrYrU69gCVceV0JvpyuW1xlsUVngzhwMetn/XamtTORF9IO5YnWNiyeF9zCAfqR3fUW+vZZKLtgP+ts8BmQRBREAdRDhH3o8QuRh/YucNFz2BEjxbRN6LGzphfKmvP6v6QhqIQyZ8XNJ0W0X83MR1PEcJBNO2KC2Z1TW/v244scp9FwRViZxIOBF0Lctk7ZVSavdLvRlV1hz/ysUi9sr8CIcB3nvWBwA93ykTz18eAYxQ6N/K2DkPA1lv3iXCwmDUT7YkjIby9siXueIJj9H+pzSqJ9oIuJWTUgSSt4WO7o/9GGg0viR4VinNRUDoIj34xoCd6pxD3aK3zfdbnx5v1J3ZNNEJsE0sBG7N27ReDrJc4sFxz7dI/ZAbOmmiKvHBitQXpAdR6+F7v+/ol/tOouUV01EeMZQF2BoQDn6dP4XNr+j9GZEtEK1/L8pFw7bd3a53tsTa7WD+054jOFmPg1XBKPQgnqFfmFcy32ZRvjmiIIQTYFvyDxQ8nH8WIwwGwlyDjDznnilYyFr6njrlZwsKkBpO59A7OwgdzPEWRm+G+oeb7IfyNuzjEEVLrOVxJsxvxwF8kmCM6I2QYmJunz4u4TrADpfl7mlbRTWQ7VmrBzh3+C9f6Grc3YoGN9dg/SXFthpRsT6vobfXRs2VBlgBHXVMLHjDNbIZv1sZ9+X3hB09cXdH1JKViyG0+W9bWZDa/r2f9zAFR71sTzGpMSWz2iI4YssWjWo3REy1MDGjdwe5e0dFSiAC1JakBvu4/CUS8Eh6dqHdU0Or0ioY3W5ClSqDXAy7/6SRfgw8vt4I+tbvvNtFT2kVDhY5+IGb1rCqYaXNF08vSALsXCPmt0kQNqJT1p5eI1mkIV/BxCY1z85lOzeFbPBQHURkkPTlwTYK9gTVE25l84IbFFN+YJDHjdpn0gq6mrHht0dkcjbM4UL9283O5p77GN+SPW/QwVB4IUYg7Or+Kp7naR6qktP98LNF2UxWo9yObPIT9KYg+hK4i56no4rfnM0qeyFf6AwAAAP//trwR3wAAAAZJREFUAwBZ0sR75itw5gAAAABJRU5ErkJggg==";
fn assert_wall_time_line(line: &str) {
assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", line);
}
fn split_wall_time_wrapped_output(output: &str) -> &str {
let (wall_time, rest) = output
.split_once('\n')
.expect("wall-time output should contain an Output section");
assert_wall_time_line(wall_time);
rest.strip_prefix("Output:\n")
.expect("wall-time output should contain Output marker")
}
fn assert_wall_time_header(output: &str) {
let (wall_time, marker) = output
.split_once('\n')
.expect("wall-time header should contain an Output marker");
assert_wall_time_line(wall_time);
assert_eq!(marker, "Output:");
}
fn read_only_user_turn(fixture: &TestCodex, text: impl Into<String>) -> TurnInputRequest {
read_only_user_turn_with_model(fixture, text, fixture.session_configured.model.clone())
}
fn read_only_user_turn_with_model(
fixture: &TestCodex,
text: impl Into<String>,
model: String,
) -> TurnInputRequest {
user_turn_with_permission_profile(fixture, text, model, PermissionProfile::read_only())
}
fn auto_approved_user_turn(fixture: &TestCodex, text: impl Into<String>) -> TurnInputRequest {
user_turn_with_permission_profile(
fixture,
text,
fixture.session_configured.model.clone(),
PermissionProfile::Disabled,
)
}
fn user_turn_with_permission_profile(
fixture: &TestCodex,
text: impl Into<String>,
model: String,
permission_profile: PermissionProfile,
) -> TurnInputRequest {
let cwd = fixture.config.cwd.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, cwd.as_path());
TurnInputRequest::user_input(vec![UserInput::Text {
text: text.into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
})
}
#[derive(Debug, PartialEq, Eq)]
enum McpCallEvent {
Begin(String),
End(String),
}
const REMOTE_MCP_ENVIRONMENT: &str = "remote";
pub(super) fn remote_aware_environment_id() -> String {
if is_remote_test_environment() {
REMOTE_MCP_ENVIRONMENT.to_string()
} else {
codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()
}
}
/// Returns the stdio MCP test server command path for the active test placement.
///
/// Local test runs can execute the host-built test binary directly. Remote-aware
/// runs start MCP stdio through the executor inside Docker, so the host path
/// would be meaningless to the process that actually launches the server. When
/// the remote test environment is active, copy the binary into the executor
/// container and return that in-container path instead.
pub(super) fn remote_aware_stdio_server_bin() -> anyhow::Result<String> {
let bin = stdio_server_bin()?;
let Some(container_name) = test_docker_container_name() else {
return Ok(bin);
};
// Keep the Docker path rewrite scoped to tests that use `build_remote_aware`.
// Other MCP tests still start their stdio server from the orchestrator test
// process, even when the full-ci remote env is present.
//
// Remote-aware MCP tests run the executor inside Docker. The stdio test
// server is built on the host, so hand the executor a copied in-container
// path instead of the host build artifact path.
// Several remote-aware MCP tests can run in parallel; give each copied
// binary its own path so one test cannot replace another test's executable.
copy_binary_to_remote_env(&container_name, Path::new(&bin), "test_stdio_server")
}
/// Builds a collision-resistant in-container path for copied test binaries.
fn unique_remote_path(binary_name: &str) -> anyhow::Result<String> {
let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(format!(
"/tmp/codex-remote-env/{binary_name}-{}-{unique_suffix}",
std::process::id()
))
}
/// Copies a host-built helper binary into the remote test container.
fn copy_binary_to_remote_env(
container_name: &str,
host_path: &Path,
binary_name: &str,
) -> anyhow::Result<String> {
let remote_path = unique_remote_path(binary_name)?;
let mkdir_output = StdCommand::new("docker")
.args([
"exec",
container_name,
"mkdir",
"-p",
"/tmp/codex-remote-env",
])
.output()
.context("create remote MCP test binary directory")?;
ensure!(
mkdir_output.status.success(),
"docker mkdir remote MCP test binary directory failed: stdout={} stderr={}",
String::from_utf8_lossy(&mkdir_output.stdout).trim(),
String::from_utf8_lossy(&mkdir_output.stderr).trim()
);
let container_target = format!("{container_name}:{remote_path}");
let copy_output = StdCommand::new("docker")
.arg("cp")
.arg(host_path)
.arg(&container_target)
.output()
.with_context(|| {
format!(
"copy {} to remote MCP test env",
host_path.to_string_lossy()
)
})?;
ensure!(
copy_output.status.success(),
"docker cp {binary_name} failed: stdout={} stderr={}",
String::from_utf8_lossy(&copy_output.stdout).trim(),
String::from_utf8_lossy(&copy_output.stderr).trim()
);
let chmod_output = StdCommand::new("docker")
.args(["exec", container_name, "chmod", "+x", remote_path.as_str()])
.output()
.with_context(|| format!("mark remote {binary_name} executable"))?;
ensure!(
chmod_output.status.success(),
"docker chmod {binary_name} failed: stdout={} stderr={}",
String::from_utf8_lossy(&chmod_output.stdout).trim(),
String::from_utf8_lossy(&chmod_output.stderr).trim()
);
Ok(remote_path)
}
struct TestMcpServerOptions {
environment_id: String,
auth: McpServerAuth,
supports_parallel_tool_calls: bool,
tool_timeout_sec: Option<Duration>,
}
impl Default for TestMcpServerOptions {
fn default() -> Self {
Self {
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
auth: McpServerAuth::default(),
supports_parallel_tool_calls: false,
tool_timeout_sec: None,
}
}
}
fn stdio_transport(
command: String,
env: Option<HashMap<String, String>>,
env_vars: Vec<McpServerEnvVar>,
) -> McpServerTransportConfig {
stdio_transport_with_cwd(command, env, env_vars, /*cwd*/ None)
}
fn stdio_transport_with_cwd(
command: String,
env: Option<HashMap<String, String>>,
env_vars: Vec<McpServerEnvVar>,
cwd: Option<PathBuf>,
) -> McpServerTransportConfig {
McpServerTransportConfig::Stdio {
command,
args: Vec::new(),
env,
env_vars,
cwd: cwd.map(|cwd| LegacyAppPathString::from_path(&cwd)),
}
}
fn insert_mcp_server(
config: &mut Config,
server_name: &str,
mut transport: McpServerTransportConfig,
options: TestMcpServerOptions,
) {
// Executor stdio has no host-local cwd fallback. Use the fixture's selected
// workspace unless this test supplied a more specific server directory.
if options.environment_id == REMOTE_MCP_ENVIRONMENT
&& let McpServerTransportConfig::Stdio { cwd, .. } = &mut transport
&& cwd.is_none()
{
*cwd = Some(LegacyAppPathString::from_path(config.cwd.as_path()));
}
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport,
auth: options.auth,
environment_id: options.environment_id,
enabled: true,
required: false,
supports_parallel_tool_calls: options.supports_parallel_tool_calls,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: options.tool_timeout_sec,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
}
async fn call_cwd_tool(
server: &MockServer,
fixture: &TestCodex,
server_name: &str,
call_id: &str,
) -> anyhow::Result<Value> {
call_structured_tool(server, fixture, server_name, "cwd", call_id).await
}
async fn call_structured_tool(
server: &MockServer,
fixture: &TestCodex,
server_name: &str,
tool_name: &str,
call_id: &str,
) -> anyhow::Result<Value> {
let namespace = format!("mcp__{server_name}");
mount_sse_once(
server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(fixture, "call the requested rmcp tool"))
.await?;
wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let structured_content = end
.result
.as_ref()
.expect("rmcp tool should return success")
.structured_content
.as_ref()
.expect("structured content")
.clone();
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
Ok(structured_content)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> {
assert_openai_form_capability_advertisement(/*expected*/ true).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> {
assert_openai_form_capability_advertisement(/*expected*/ false).await
}
async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
let server = start_mock_server().await;
let server_name = "capabilities";
let command = stdio_server_bin()?;
let mut builder = test_codex().with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
});
if expected {
builder = builder.with_openai_form_elicitation();
}
let fixture = builder.build(&server).await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let structured = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities",
)
.await?;
assert_eq!(
structured,
json!({ "supportsOpenaiFormElicitation": expected })
);
Ok(())
}
fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) {
let actual_cwd = structured
.get("cwd")
.and_then(Value::as_str)
.expect("cwd tool should return a string cwd");
if is_remote_test_environment() {
assert_eq!(
structured,
&json!({
"cwd": expected_cwd.to_string_lossy(),
})
);
return;
}
// Local Windows can report the same absolute directory through an 8.3 path.
// Canonical paths keep the assertion focused on cwd precedence.
assert_eq!(
Path::new(actual_cwd)
.canonicalize()
.expect("cwd tool path should exist"),
expected_cwd
.canonicalize()
.expect("expected cwd should exist"),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_namespace_instructions_are_preserved_without_hiding_tools() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let expected_description = format!("{}🦀keep the valid MCP server", "é".repeat(499));
let instructions = expected_description.clone();
let response = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let command = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_config(move |config| {
insert_mcp_server(
config,
"bounded",
stdio_transport(
command,
Some(HashMap::from([(
"MCP_TEST_SERVER_INSTRUCTIONS".to_string(),
instructions,
)])),
Vec::new(),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, "bounded").await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "show the bounded MCP tools"))
.await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let body = response.single_request().body_json();
let namespace = body
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| {
tools
.iter()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__bounded"))
})
.expect("valid MCP server should remain exposed");
assert_eq!(
namespace.get("description").and_then(Value::as_str),
Some(expected_description.as_str())
);
assert!(
responses::namespace_child_tool(&body, "mcp__bounded", "echo").is_some(),
"preserving the namespace must not hide a valid MCP tool"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn text_only_mcp_content_uses_content_items() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "content-items-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"image_scenario",
r#"{"scenario":"text_only","caption":"content item fixture result"}"#,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-2"),
]),
)
.await;
let command = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "return content items"))
.await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = final_mock.single_request().function_call_output(call_id);
let header = output["output"][0]["text"]
.as_str()
.expect("first content item should contain the wall-time header");
assert_wall_time_header(header);
assert_eq!(
output["output"],
json!([
{
"type": "input_text",
"text": header,
},
{
"type": "input_text",
"text": "content item fixture result",
},
])
);
server.verify().await;
Ok(())
}
#[test_case(false; "configured servers")]
#[test_case(true; "plugin servers")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn environment_mcp_policy_filters_runtime_config_and_model_tools(
from_plugin: bool,
) -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let command = remote_aware_stdio_server_bin()?;
let allowed_command = command.clone();
let codex_home = Arc::new(tempdir()?);
let test_env = test_env().await?;
if from_plugin {
let plugin_root =
super::plugins::write_sample_plugin_manifest_and_config(codex_home.as_ref());
let plugin_server = json!({
"command": command,
"environment_id": remote_aware_environment_id(),
"cwd": test_env.cwd(),
});
fs::write(
plugin_root.join(".mcp.json"),
serde_json::to_vec(&json!({
"mcpServers": {
"allowed": plugin_server,
"blocked": plugin_server,
},
}))?,
)?;
}
let fixture = test_codex()
.with_home(codex_home)
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_config(move |config| {
if !from_plugin {
for server_name in ["allowed", "blocked"] {
insert_mcp_server(
config,
server_name,
stdio_transport(command.clone(), /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
}
}
insert_mcp_server(
config,
"unselected",
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: "unselected-environment".to_string(),
..Default::default()
},
);
})
.build_with_environment(&server, test_env)
.await?;
let selection = fixture
.codex
.environment_selections()
.await
.into_iter()
.next()
.expect("thread should select its executor environment");
submit_thread_settings(
&fixture.codex,
ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
fixture.config.cwd.clone(),
vec![TurnEnvironmentSelection {
config: EnvironmentConfigState::Pending,
..selection.clone()
}],
)),
..Default::default()
},
)
.await?;
let (pending_config, _) = fixture.codex.current_mcp_config_and_runtime_context().await;
let pending_servers = pending_config.mcp_server_catalog.configured_servers();
assert!(!pending_servers["allowed"].enabled);
assert!(!pending_servers["unselected"].enabled);
let allowed_servers = BTreeMap::from([(
"allowed".to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: allowed_command,
},
},
)]);
let mcp_policy = if from_plugin {
EnvironmentMcpPolicy {
servers: None,
plugins: Some(BTreeMap::from([(
"sample@test".to_string(),
PluginMcpRequirements {
mcp_servers: Some(allowed_servers),
},
)])),
}
} else {
EnvironmentMcpPolicy {
servers: Some(allowed_servers),
plugins: None,
}
};
fixture
.codex
.environment_ready(
&selection,
EnvironmentConfig {
allow_login_shell: true,
workspace_roots: selection.workspace_roots.clone(),
permission_profile: PermissionProfileSnapshot::legacy(
fixture.config.permissions.permission_profile().clone(),
),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&fixture.config),
windows_sandbox_private_desktop: fixture
.config
.permissions
.windows_sandbox_private_desktop,
use_legacy_landlock: fixture.config.features.use_legacy_landlock(),
exec_policy: None,
mcp_policy: Some(mcp_policy),
network_policy: None,
selected_capability_roots: Vec::new(),
},
)
.await?;
let (runtime_config, _) = fixture.codex.current_mcp_config_and_runtime_context().await;
let runtime_servers = runtime_config.mcp_server_catalog.configured_servers();
assert!(!runtime_servers["blocked"].enabled);
assert!(!runtime_servers["unselected"].enabled);
fixture
.codex
.call_mcp_tool(
"allowed",
"echo",
Some(json!({ "message": "ready" })),
/*meta*/ None,
)
.await?;
fixture
.submit_text_turn("show the available MCP tools")
.await?;
let body = response.single_request().body_json();
assert!(responses::namespace_child_tool(&body, "mcp__allowed", "echo").is_some());
assert!(responses::namespace_child_tool(&body, "mcp__blocked", "echo").is_none());
fixture
.codex
.environment_failed(&selection, "environment policy unavailable".to_string())
.await?;
let (failed_config, _) = fixture.codex.current_mcp_config_and_runtime_context().await;
let failed_servers = failed_config.mcp_server_catalog.configured_servers();
assert!(!failed_servers["allowed"].enabled);
Ok(())
}
#[test_case("rmcp", "mcp__rmcp"; "simple name")]
#[test_case("npm:@scope/package.name", "mcp__npm__scope_package_name"; "npm name")]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_server_round_trip(server_name: &'static str, namespace: &str) -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "call-123";
let search_call_id = "search-rmcp-echo";
let namespace = namespace.to_string();
let search_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_tool_search_call(
search_call_id,
&json!({"query": "echo message and environment data"}),
),
responses::ev_completed("resp-1"),
]),
)
.await;
let call_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
"{\"message\":\"ping\"}",
),
responses::ev_completed("resp-2"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
responses::ev_completed("resp-3"),
]),
)
.await;
let expected_env_value = "propagated-env";
let expected_description = format!("{}🦀keep the complete MCP metadata", "é".repeat(11_000));
let instructions = expected_description.clone();
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([
("MCP_TEST_VALUE".to_string(), expected_env_value.to_string()),
("MCP_TEST_SERVER_INSTRUCTIONS".to_string(), instructions),
])),
Vec::new(),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool"))
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("event guard guarantees McpToolCallBegin");
};
assert_eq!(begin.invocation.server, server_name);
assert_eq!(begin.invocation.tool, "echo");
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let result = end
.result
.as_ref()
.expect("rmcp echo tool should return success");
assert_eq!(result.is_error, Some(false));
assert!(
result.content.is_empty(),
"content should default to an empty array"
);
let structured = result
.structured_content
.as_ref()
.expect("structured content");
let map = structured
.as_object()
.expect("structured content should be an object");
let echo_value = map
.get("echo")
.and_then(Value::as_str)
.expect("echo payload present");
assert_eq!(echo_value, "ECHOING: ping");
let env_value = map
.get("env")
.and_then(Value::as_str)
.expect("env snapshot inserted");
assert_eq!(env_value, expected_env_value);
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let search_request = search_mock.single_request().body_json();
let search_description = search_request
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| {
tools
.iter()
.find(|tool| tool.get("type").and_then(Value::as_str) == Some("tool_search"))
})
.and_then(|tool| tool.get("description").and_then(Value::as_str))
.expect("the model should receive a tool search description");
assert!(
search_description.len() < 513 * 1024,
"the complete tool search description must remain bounded"
);
assert!(search_description.contains(&format!("- {server_name}: {expected_description}")));
assert!(search_description.contains("🦀keep the complete MCP metadata"));
let search_output = call_mock
.single_request()
.tool_search_output(search_call_id);
let searched_namespace = search_output
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| {
tools
.iter()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(namespace.as_str()))
})
.expect("tool search should return the RMCP namespace");
assert_eq!(
searched_namespace
.get("description")
.and_then(Value::as_str),
Some(expected_description.as_str())
);
assert!(
responses::namespace_child_tool(&search_output, &namespace, "echo").is_some(),
"tool_search should surface the RMCP echo tool: {search_output:?}"
);
let output_item = final_mock.single_request().function_call_output(call_id);
let output_text = output_item
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a string");
let wrapped_payload = split_wall_time_wrapped_output(output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve structured JSON");
assert_eq!(output_json["echo"], "ECHOING: ping");
assert_eq!(output_json["env"], expected_env_value);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_mcp_tool_names_respect_selected_servers() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "history-echo";
let search_call_id = "search-mcp-echo";
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_tool_search_call(
search_call_id,
&json!({"query": "echo message and environment data"}),
),
responses::ev_completed("resp-1"),
]),
)
.await;
let call_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_function_call_with_namespace(
call_id,
"history",
"echo",
r#"{"message":"ping"}"#,
),
responses::ev_completed("resp-2"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "history echo completed successfully."),
responses::ev_completed("resp-3"),
]),
)
.await;
let command = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_pre_build_hook(move |codex_home| {
fs::write(
codex_home.join("config.toml"),
r#"
[features.non_prefixed_mcp_tool_names]
enabled = true
server_names = ["history", "notes"]
"#,
)
.expect("write MCP namespace configuration");
})
.with_config(move |config| {
for server_name in ["history", "notes", "other"] {
insert_mcp_server(
config,
server_name,
stdio_transport(command.clone(), /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
}
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, "history").await?;
fixture
.submit_turn_with_permission_profile(
"call the history echo tool",
PermissionProfile::read_only(),
)
.await?;
let search_output = call_mock
.single_request()
.tool_search_output(search_call_id);
let mut actual_namespaces = [
"history",
"notes",
"other",
"mcp__history",
"mcp__notes",
"mcp__other",
]
.into_iter()
.filter(|namespace| {
responses::namespace_child_tool(&search_output, namespace, "echo").is_some()
})
.collect::<Vec<_>>();
actual_namespaces.sort_unstable();
assert_eq!(actual_namespaces, ["history", "mcp__other", "notes"]);
let output = final_mock.single_request().function_call_output(call_id);
let output_text = output["output"]
.as_str()
.expect("MCP function-call output should be a string");
let output_json: Value = serde_json::from_str(split_wall_time_wrapped_output(output_text))?;
assert_eq!(output_json["echo"], "ECHOING: ping");
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn modern_mcp_pagination_preserves_valid_tools_and_rejects_oversized_cursors()
-> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let command = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_config(move |config| {
config
.features
.enable(Feature::Mcp20260728)
.expect("test config should allow modern MCP");
for (server_name, pagination) in
[("paginated", "two-pages"), ("rejected", "oversized-cursor")]
{
insert_mcp_server(
config,
server_name,
stdio_transport(
command.clone(),
Some(HashMap::from([
(
"CODEX_MCP_PROTOCOL_VERSION".to_string(),
"2026-07-28".to_string(),
),
(
"MCP_TEST_TOOL_PAGINATION".to_string(),
pagination.to_string(),
),
])),
Vec::new(),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
}
})
.build_with_auto_env(&server)
.await?;
let startup = loop {
let event = fixture.codex.next_event().await?;
if let EventMsg::McpStartupComplete(startup) = event.msg {
break startup;
}
};
assert!(startup.ready.iter().any(|name| name == "paginated"));
let failure = startup
.failed
.iter()
.find(|failure| failure.server == "rejected")
.expect("oversized cursor should reject only its MCP server");
assert!(
failure
.error
.contains("tools/list returned a pagination cursor exceeding 65536 bytes"),
"unexpected MCP startup failure: {}",
failure.error
);
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"show the paginated MCP tools",
))
.await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let body = response.single_request().body_json();
for tool_name in ["echo", "sync"] {
assert!(
responses::namespace_child_tool(&body, "mcp__paginated", tool_name).is_some(),
"expected paginated MCP tool {tool_name} to reach the model"
);
}
assert!(
responses::namespace_child_tool(&body, "mcp__rejected", "echo").is_none(),
"a rejected MCP catalog must not reach the model"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apps_enabled_turn_skips_pending_optional_mcp_without_cached_tools() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let apps_base_url = apps_server.chatgpt_base_url.clone();
let response_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let pending_mcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let pending_mcp_url = format!("http://{}/mcp", pending_mcp_listener.local_addr()?);
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow Apps override");
config.chatgpt_base_url = apps_base_url;
insert_mcp_server(
config,
"pending_optional",
McpServerTransportConfig::StreamableHttp {
url: pending_mcp_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
TestMcpServerOptions::default(),
);
})
.build_with_auto_env(&server)
.await?;
let (_pending_mcp_connection, _) =
tokio::time::timeout(Duration::from_secs(5), pending_mcp_listener.accept())
.await
.context("optional MCP startup should connect before the first turn")??;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let event = fixture
.codex
.next_event()
.await
.context("event stream ended before Codex Apps became ready")?;
if let EventMsg::McpStartupUpdate(update) = event.msg
&& update.server == CODEX_APPS_MCP_SERVER_NAME
&& matches!(update.status, McpStartupStatus::Ready)
{
break Ok::<(), anyhow::Error>(());
}
}
})
.await
.context("Codex Apps should finish starting before the first turn")??;
tokio::time::timeout(Duration::from_secs(5), fixture.submit_turn("hello"))
.await
.context("a pending optional MCP must not block the first turn")??;
let body = response_mock.single_request().body_json();
assert!(body["input"].to_string().contains("<apps_instructions>"));
let tools = body["tools"].as_array().expect("model request tools");
assert!(tools.iter().all(|tool| {
tool.get("name")
.or_else(|| tool.get("type"))
.and_then(Value::as_str)
.is_none_or(|name| !name.starts_with("mcp__pending_optional"))
}));
tokio::time::timeout(Duration::from_secs(2), fixture.codex.shutdown_and_wait())
.await
.context("shutdown should cancel pending optional MCP startup")??;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_cancels_startup_prewarm_waiting_for_mcp_startup() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_websocket_server(vec![vec![vec![
responses::ev_response_created("warm-1"),
responses::ev_completed("warm-1"),
]]])
.await;
let pending_mcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let pending_mcp_url = format!("http://{}/mcp", pending_mcp_listener.local_addr()?);
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
"shutdown_prewarm",
McpServerTransportConfig::StreamableHttp {
url: pending_mcp_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
TestMcpServerOptions::default(),
);
})
.build_with_websocket_server(&server)
.await?;
let (_pending_mcp_connection, _) =
tokio::time::timeout(Duration::from_secs(5), pending_mcp_listener.accept())
.await
.context("startup prewarm should start the MCP connection")??;
tokio::time::timeout(Duration::from_secs(2), fixture.codex.shutdown_and_wait())
.await
.context("shutdown should not wait for startup prewarm MCP startup")??;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
server.connections().is_empty(),
"startup prewarm should not send a websocket request after shutdown"
);
server.shutdown().await;
Ok(())
}
#[derive(Clone, Copy)]
enum InterruptedMcpStartupPhase {
PreSamplingCompaction,
FirstStep,
StartupPrewarm,
}
#[test_case(InterruptedMcpStartupPhase::PreSamplingCompaction; "pre sampling compaction")]
#[test_case(InterruptedMcpStartupPhase::FirstStep; "first step")]
#[test_case(InterruptedMcpStartupPhase::StartupPrewarm; "startup prewarm")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interrupt_during_mcp_startup_preserves_user_input_in_history(
startup_phase: InterruptedMcpStartupPhase,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let pending_mcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let pending_mcp_url = format!("http://{}/mcp", pending_mcp_listener.local_addr()?);
let fixture = test_codex()
.with_config(move |config| {
config.model_provider.supports_websockets =
matches!(startup_phase, InterruptedMcpStartupPhase::StartupPrewarm);
if matches!(
startup_phase,
InterruptedMcpStartupPhase::PreSamplingCompaction
) {
config.model_auto_compact_token_limit = Some(0);
}
insert_mcp_server(
config,
"interrupted_startup",
McpServerTransportConfig::StreamableHttp {
url: pending_mcp_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
TestMcpServerOptions::default(),
);
})
.build_with_auto_env(&server)
.await?;
let (_pending_mcp_connection, _) =
tokio::time::timeout(Duration::from_secs(5), pending_mcp_listener.accept())
.await
.context("MCP startup should connect before the turn is interrupted")??;
let prompt = "keep this interrupted prompt in conversation history";
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, prompt))
.await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnStarted(_))
})
.await;
fixture.codex.submit(Op::Interrupt).await?;
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::TurnAborted(_))
})
.await;
let history = fixture
.codex
.load_history(/*include_archived*/ false)
.await?;
let user_prompt_index = history
.items
.iter()
.position(|item| {
matches!(
item,
RolloutItem::ResponseItem(envelope)
if matches!(
&envelope.item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|item| {
matches!(item, ContentItem::InputText { text } if text == prompt)
})
)
)
})
.expect("an interrupted turn should retain its submitted user prompt");
let interruption_marker_index = history
.items
.iter()
.position(|item| {
matches!(
item,
RolloutItem::ResponseItem(envelope)
if matches!(
&envelope.item,
ResponseItem::Message { content, .. }
if content.iter().any(|item| {
matches!(item, ContentItem::InputText { text } if text.contains("<turn_aborted>"))
})
)
)
})
.expect("an interrupted turn should retain its interruption marker");
assert!(user_prompt_index < interruption_marker_index);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_cwd)]
async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let server_name = "rmcp_configured_cwd";
let expected_cwd = Arc::new(Mutex::new(None::<PathBuf>));
let expected_cwd_for_config = Arc::clone(&expected_cwd);
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_workspace_setup(|cwd, fs| async move {
let configured_cwd = cwd.join("mcp-configured-cwd");
let configured_cwd_uri = PathUri::from_host_native_path(&configured_cwd)?;
fs.create_directory(
&configured_cwd_uri,
CreateDirectoryOptions {
recursive: true,
follow_symlinks: true,
},
/*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
})
.with_config(move |config| {
let configured_cwd = config.cwd.join("mcp-configured-cwd").into_path_buf();
*expected_cwd_for_config
.lock()
.expect("expected cwd lock should not be poisoned") = Some(configured_cwd.clone());
insert_mcp_server(
config,
server_name,
stdio_transport_with_cwd(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
"configured-cwd".to_string(),
)])),
Vec::new(),
Some(configured_cwd),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let expected_cwd = expected_cwd
.lock()
.expect("expected cwd lock should not be poisoned")
.clone()
.expect("test config should record configured MCP cwd");
let structured = call_cwd_tool(&server, &fixture, server_name, "call-configured-cwd").await?;
assert_cwd_tool_output(&structured, &expected_cwd);
server.verify().await;
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_cwd)]
async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> anyhow::Result<()>
{
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let server_name = "rmcp_local_fallback_cwd";
let expected_cwd = Arc::new(Mutex::new(None::<PathBuf>));
let expected_cwd_for_config = Arc::clone(&expected_cwd);
let rmcp_test_server_bin = cargo_bin("test_stdio_server")?;
let relative_server_path = PathBuf::from("mcp-bin").join(
rmcp_test_server_bin
.file_name()
.expect("test stdio server binary should have a file name"),
);
let relative_command = relative_server_path.to_string_lossy().into_owned();
let fixture = test_codex()
.with_config(move |config| {
*expected_cwd_for_config
.lock()
.expect("expected cwd lock should not be poisoned") =
Some(config.cwd.to_path_buf());
let target_bin = config.cwd.join(&relative_server_path).into_path_buf();
let target_dir = target_bin
.parent()
.expect("relative test server path should include a parent");
fs::create_dir_all(target_dir).expect("create relative MCP bin directory");
fs::copy(&rmcp_test_server_bin, &target_bin).expect("copy test stdio server");
insert_mcp_server(
config,
server_name,
stdio_transport(
relative_command,
Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
"local-fallback-cwd".to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let expected_cwd = expected_cwd
.lock()
.expect("expected cwd lock should not be poisoned")
.clone()
.expect("test config should record runtime fallback cwd");
let structured =
call_cwd_tool(&server, &fixture, server_name, "call-local-fallback-cwd").await?;
assert_cwd_tool_output(&structured, &expected_cwd);
server.verify().await;
Ok(())
}
#[test_case("rmcp", false, false, false, Some("catalog policy"), Some("native catalog policy"); "both disabled")]
#[test_case("rmcp", true, false, false, Some("catalog policy"), Some("native catalog policy"); "auto review required")]
#[test_case("rmcp", false, true, false, Some("catalog policy"), Some("native catalog policy"); "disabled")]
#[test_case("rmcp", true, true, false, Some("catalog policy"), Some("native catalog policy"); "both enabled")]
#[test_case("rmcp", false, false, true, Some("catalog policy"), Some("native catalog policy"); "attachment-owned permissions preserve foreign workspace roots")]
#[test_case("node_repl", false, false, false, Some(" # Policy A\r\n{literal} <raw> & café\n"), Some("\t# Native A\n{{literal}} & desktop\r\n"); "node repl raw policy")]
#[test_case("cua_repl", false, false, false, Some("\t# Policy B\n${literal} </policy>\r\n "), Some(" # Native B\r\n<computer> ${native}\n "); "cua repl raw policy")]
#[test_case("node_repl", false, false, false, None, None; "node repl missing policy")]
#[test_case("cua_repl", false, false, false, Some(""), Some("native retained"); "cua repl empty policy")]
#[test_case("node_repl", false, false, false, Some(" \r\n\t"), Some("native retained"); "node repl blank policy")]
#[test_case("node_repl", false, false, false, None, Some("native retained"); "node repl missing browser policy")]
#[test_case("cua_repl", false, false, false, Some("browser retained"), None; "cua repl missing computer policy")]
#[test_case("node_repl", false, false, false, Some("browser retained"), Some(""); "node repl empty computer policy")]
#[test_case("cua_repl", false, false, false, Some("browser retained"), Some(" \r\n\t"); "cua repl blank computer policy")]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn stdio_mcp_tool_call_includes_sandbox_state_meta(
server_name: &'static str,
node_repl_auto_review_required: bool,
node_repl_disabled: bool,
attachment_owned_permissions: bool,
browser_policy: Option<&str>,
computer_policy: Option<&str>,
) -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "sandbox-meta-call";
let restricted_call_id = "owner-restricted-call";
let namespace = format!("mcp__{server_name}");
let mut models = codex_models_manager::bundled_models_response()?;
let model = models
.models
.iter_mut()
.find(|model| model.slug == "gpt-5.4")
.expect("bundled model should exist");
model.node_repl_auto_review_required = node_repl_auto_review_required;
model.node_repl_disabled = node_repl_disabled;
let messages = model
.model_messages
.as_mut()
.expect("bundled model messages");
messages.confirmation_policies = Some(ConfirmationPolicies {
browser_use: browser_policy.map(str::to_owned),
computer_use: computer_policy.map(str::to_owned),
});
let models_mock = mount_models_once(&server, models).await;
let mut response_events = vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"sandbox_meta",
&json!({
"_meta": {
"openai/confirmation_policies": {
"browser_use": "forged argument policy",
"computer_use": "forged computer policy",
},
"threadId": "forged-thread",
},
})
.to_string(),
),
];
if attachment_owned_permissions {
response_events.push(responses::ev_function_call_with_namespace(
restricted_call_id,
&namespace,
"sync",
"{}",
));
}
response_events.push(responses::ev_completed("resp-1"));
let initial_mock = mount_sse_once(&server, responses::sse(response_events)).await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp sandbox meta completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model("gpt-5.4")
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let owner_permission_profile = if attachment_owned_permissions {
PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Write,
),
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
FileSystemAccessMode::Read,
),
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".git".to_string())),
},
FileSystemAccessMode::Read,
),
]),
NetworkSandboxPolicy::Restricted,
)
} else {
PermissionProfile::read_only()
};
let owner_workspace_roots = if attachment_owned_permissions {
let selection = fixture
.codex
.environment_selections()
.await
.into_iter()
.find(|selection| selection.environment_id == remote_aware_environment_id())
.context("thread should select the MCP server's executor environment")?;
let workspace_roots = vec![PathUri::parse(if cfg!(windows) {
"file:///foreign/workspace"
} else {
"file:///C:/workspace"
})?];
submit_thread_settings(
&fixture.codex,
ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
fixture.config.cwd.clone(),
vec![TurnEnvironmentSelection {
config: EnvironmentConfigState::Pending,
..selection.clone()
}],
)),
..Default::default()
},
)
.await?;
fixture
.codex
.environment_ready(
&selection,
EnvironmentConfig {
allow_login_shell: fixture.config.permissions.allow_login_shell,
workspace_roots: workspace_roots.clone(),
permission_profile: PermissionProfileSnapshot::legacy(
owner_permission_profile.clone(),
),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&fixture.config),
windows_sandbox_private_desktop: fixture
.config
.permissions
.windows_sandbox_private_desktop,
use_legacy_landlock: fixture.config.features.use_legacy_landlock(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
},
)
.await?;
workspace_roots
} else {
Vec::new()
};
fixture
.thread_manager
.get_models_manager()
.list_models(
RefreshStrategy::Online,
codex_core::test_support::default_http_client_factory(),
)
.await;
assert_eq!(models_mock.requests().len(), 1);
fixture
.submit_turn_with_permission_profile(
"call the rmcp sandbox_meta tool",
if attachment_owned_permissions {
PermissionProfile::Disabled
} else {
PermissionProfile::read_only()
},
)
.await?;
let initial_request = initial_mock.single_request().body_json();
let response_metadata: Value = serde_json::from_str(
initial_request["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("responses request should include turn metadata"),
)?;
assert_eq!(
response_metadata["node_repl_auto_review_required"],
json!(node_repl_auto_review_required),
);
assert_eq!(
response_metadata["node_repl_disabled"],
json!(node_repl_disabled)
);
let final_request = final_mock.single_request();
if attachment_owned_permissions {
let restricted_output_item = final_request.function_call_output(restricted_call_id);
let restricted_output = restricted_output_item["output"][1]["text"]
.as_str()
.expect("restricted MCP tool should produce a denied call output");
assert!(
restricted_output
.contains("MCP tool call requires approval, but approval policy is never"),
"attachment-owned permissions should deny the mutable MCP tool: {restricted_output}"
);
}
let output_item = final_request.function_call_output(call_id);
let output_text = output_item
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a string");
let wrapped_payload = split_wall_time_wrapped_output(output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve sandbox metadata JSON");
let meta = output_json
.as_object()
.expect("sandbox_meta should return metadata object");
assert_eq!(
output_json.pointer("/x-codex-turn-metadata/node_repl_auto_review_required"),
Some(&json!(node_repl_auto_review_required))
);
assert_eq!(
output_json.pointer("/x-codex-turn-metadata/node_repl_disabled"),
Some(&json!(node_repl_disabled))
);
let expected_policies = match server_name {
"node_repl" | "cua_repl" => Some(match (browser_policy, computer_policy) {
(Some(browser), Some(computer)) => {
json!({"browser_use": browser, "computer_use": computer})
}
(Some(browser), None) => json!({"browser_use": browser}),
(None, Some(computer)) => json!({"computer_use": computer}),
(None, None) => json!({}),
}),
_ => None,
};
assert_eq!(
meta.get("openai/confirmation_policies"),
expected_policies.as_ref(),
);
assert_eq!(
output_json["threadId"],
json!(fixture.session_configured.thread_id.to_string()),
);
assert_eq!(output_json["callId"], json!(call_id));
let sandbox_meta = meta
.get(MCP_SANDBOX_STATE_META_CAPABILITY)
.expect("sandbox state metadata should be present");
let sandbox_state: SandboxState = serde_json::from_value(sandbox_meta.clone())?;
if attachment_owned_permissions {
let workspace_root = &owner_workspace_roots[0];
for path in [workspace_root.clone(), workspace_root.join(".git")?] {
let expected_entry =
FileSystemSandboxEntry::new(path.into(), FileSystemAccessMode::Read);
assert!(
sandbox_state
.permission_profile
.file_system_sandbox_policy()
.entries
.contains(&expected_entry),
"foreign workspace root should retain its owner restriction: {expected_entry:?}"
);
}
}
assert_eq!(
sandbox_state,
SandboxState {
permission_profile: owner_permission_profile
.materialize_project_roots_with_path_uris(&owner_workspace_roots),
codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd),
use_legacy_landlock: false,
}
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let first_call_id = "sync-serial-1";
let second_call_id = "sync-serial-2";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
let args = json!({ "sleep_after_ms": 100 }).to_string();
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp sync tools completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
tool_timeout_sec: Some(Duration::from_secs(2)),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
// Keep this baseline on the mutable sync tool so read-only hints do not
// make the call parallel-safe. Bypass read-only turn permissions so
// approval behavior does not block the scheduling assertion.
.start_or_steer_turn(auto_approved_user_turn(
&fixture,
"call the rmcp sync tool twice",
))
.await?;
let mut call_events = Vec::new();
while call_events.len() < 4 {
let event = wait_for_event(&fixture.codex, |ev| {
matches!(
ev,
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_)
)
})
.await;
match event {
EventMsg::McpToolCallBegin(begin) => {
call_events.push(McpCallEvent::Begin(begin.call_id));
}
EventMsg::McpToolCallEnd(end) => {
call_events.push(McpCallEvent::End(end.call_id));
}
_ => unreachable!("event guard guarantees MCP call events"),
}
}
let event_index = |needle: McpCallEvent| {
call_events
.iter()
.position(|event| event == &needle)
.expect("expected MCP call event")
};
let first_begin = event_index(McpCallEvent::Begin(first_call_id.to_string()));
let first_end = event_index(McpCallEvent::End(first_call_id.to_string()));
let second_begin = event_index(McpCallEvent::Begin(second_call_id.to_string()));
let second_end = event_index(McpCallEvent::End(second_call_id.to_string()));
assert!(
first_end < second_begin || second_end < first_begin,
"default MCP tool calls should run serially; saw events: {call_events:?}"
);
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = final_mock.single_request();
for call_id in [first_call_id, second_call_id] {
let output_text = request
.function_call_output_text(call_id)
.expect("function_call_output present for rmcp sync call");
let wrapped_payload = split_wall_time_wrapped_output(&output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve structured JSON");
assert_eq!(output_json, json!({ "result": "ok" }));
}
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in()
-> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let first_call_id = "sync-read-only-1";
let second_call_id = "sync-read-only-2";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
// The stdio MCP test server holds each sync call at this barrier until both
// calls arrive. A serial scheduler times out inside the server instead of
// returning the structured `{ "result": "ok" }` result asserted below.
let args = json!({
"sleep_after_ms": 100,
"barrier": {
"id": "stdio-mcp-read-only-tool-calls",
"participants": 2,
"timeout_ms": 1_000
}
})
.to_string();
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
first_call_id,
&namespace,
"sync_readonly",
&args,
),
responses::ev_function_call_with_namespace(
second_call_id,
&namespace,
"sync_readonly",
&args,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp sync tools completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
tool_timeout_sec: Some(Duration::from_secs(2)),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp sync_readonly tool twice",
))
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = final_mock.single_request();
for call_id in [first_call_id, second_call_id] {
let output_text = request
.function_call_output_text(call_id)
.expect("function_call_output present for rmcp sync call");
let wrapped_payload = split_wall_time_wrapped_output(&output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve structured JSON");
assert_eq!(output_json, json!({ "result": "ok" }));
}
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let first_call_id = "sync-1";
let second_call_id = "sync-2";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
let args = json!({
"sleep_after_ms": 100,
"barrier": {
"id": "stdio-mcp-parallel-tool-calls",
"participants": 2,
"timeout_ms": 1_000
}
})
.to_string();
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp sync tools completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
auth: Default::default(),
supports_parallel_tool_calls: true,
tool_timeout_sec: Some(Duration::from_secs(2)),
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
// Exercise the server opt-in with the mutable sync tool rather than the
// read-only sync_readonly tool. Bypass read-only turn permissions so
// approval behavior does not block the scheduling assertion.
.start_or_steer_turn(auto_approved_user_turn(
&fixture,
"call the rmcp sync tool twice",
))
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = final_mock.single_request();
for call_id in [first_call_id, second_call_id] {
let output_text = request
.function_call_output_text(call_id)
.expect("function_call_output present for rmcp sync call");
let wrapped_payload = split_wall_time_wrapped_output(&output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve structured JSON");
assert_eq!(output_json, json!({ "result": "ok" }));
}
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_encrypted_content_responses_round_trip() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "encrypted-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"encrypted_output",
"{}",
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp encrypted output tool",
))
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
let output = output_item["output"]
.as_array()
.expect("encrypted MCP output should be content items");
assert_eq!(output.len(), 3);
assert_wall_time_header(
output[0]["text"]
.as_str()
.expect("first encrypted MCP output item should be wall-time text"),
);
assert_eq!(
&output[1..],
&[
json!({
"type": "input_text",
"text": "Lookup completed",
}),
json!({
"type": "encrypted_content",
"encrypted_content": "gAAAA-test",
}),
]
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
// First stream: model decides to call the image tool.
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
responses::ev_completed("resp-1"),
]),
)
.await;
// Second stream: after tool execution, assistant emits a message and completes.
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp image tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
// Build the stdio rmcp server and pass the image as data URL so it can construct ImageContent.
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_model("gpt-5.2")
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp image tool"))
.await?;
// Wait for tool begin/end and final completion.
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("begin");
};
assert_eq!(
begin,
McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation: McpInvocation {
server: server_name.to_string(),
tool: "image".to_string(),
arguments: Some(json!({})),
},
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
app_name: None,
action_name: None,
plugin_id: None,
read_only_hint: Some(true),
},
);
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("end");
};
assert_eq!(end.call_id, call_id);
assert_eq!(
end.invocation,
McpInvocation {
server: server_name.to_string(),
tool: "image".to_string(),
arguments: Some(json!({})),
}
);
let result = end.result.expect("rmcp image tool should return success");
assert_eq!(result.is_error, Some(false));
assert_eq!(result.content.len(), 1);
let base64_only = OPENAI_PNG
.strip_prefix("data:image/png;base64,")
.expect("data url prefix");
let entry = result.content[0].as_object().expect("content object");
assert_eq!(entry.get("type"), Some(&json!("image")));
assert_eq!(entry.get("mimeType"), Some(&json!("image/png")));
assert_eq!(entry.get("data"), Some(&json!(base64_only)));
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
assert_eq!(output_item["type"], "function_call_output");
assert_eq!(output_item["call_id"], call_id);
let output = output_item["output"]
.as_array()
.expect("image MCP output should be content items");
assert_eq!(output.len(), 2);
assert_wall_time_header(
output[0]["text"]
.as_str()
.expect("first MCP image output item should be wall-time text"),
);
assert_eq!(
output[1],
json!({
"type": "input_image",
"image_url": OPENAI_PNG,
"detail": "high"
})
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-resize-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
let original_dimensions = (3000, 2000);
let image = ImageBuffer::from_pixel(
original_dimensions.0,
original_dimensions.1,
Rgba([20, 40, 60, 255]),
);
let mut encoded = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image).write_to(&mut encoded, image::ImageFormat::Png)?;
let image_data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(encoded.into_inner())
);
let tool_arguments = serde_json::to_string(&json!({
"scenario": "image_only",
"data_url": image_data_url,
}))?;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"image_scenario",
&tool_arguments,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp image_scenario tool",
))
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
assert_eq!(output_item["call_id"], call_id);
let output = output_item["output"]
.as_array()
.expect("image MCP output should be content items");
let resized_url = output[1]["image_url"]
.as_str()
.expect("MCP image output should contain a data URL");
assert_eq!(output[1]["detail"], "high");
let (_, resized_base64) = resized_url
.split_once(',')
.expect("resized image should contain a data URL prefix");
let resized_bytes = BASE64_STANDARD.decode(resized_base64)?;
let resized = image::load_from_memory(&resized_bytes)?;
let resized_dimensions = resized.dimensions();
assert_eq!(resized_dimensions, (1920, 1280));
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-original-detail-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"image_scenario",
r#"{"scenario":"image_only_original_detail"}"#,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp original-detail image completed."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_model("gpt-5.4")
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp image_scenario tool",
))
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
let output = output_item["output"]
.as_array()
.expect("image MCP output should be content items");
assert_eq!(output.len(), 2);
assert_wall_time_header(
output[0]["text"]
.as_str()
.expect("first MCP image output item should be wall-time text"),
);
assert_eq!(
output[1],
json!({
"type": "input_image",
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"detail": "original",
})
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-text-only-1";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}");
let text_only_model_slug = "rmcp-text-only-model";
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![ModelInfo {
slug: text_only_model_slug.to_string(),
display_name: "RMCP Text Only".to_string(),
description: Some("Test model without image input support".to_string()),
default_reasoning_level: None,
supported_reasoning_levels: vec![ReasoningEffortPreset {
effort: codex_protocol::openai_models::ReasoningEffort::Medium,
description: "Medium".to_string(),
}],
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
model_messages: None,
include_skills_usage_instructions: false,
include_plugin_usage_instructions: false,
include_apps_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
supports_image_detail_original: false,
context_window: Some(272_000),
max_context_window: None,
auto_compact_token_limit: None,
comp_hash: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: vec![InputModality::Text],
used_fallback_model_metadata: false,
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
auto_review_model_override: None,
model_specialty: None,
tool_mode: None,
multi_agent_version: None,
multi_agent_reasoning_effort: None,
}],
},
)
.await;
// First stream: model decides to call the image tool.
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
responses::ev_completed("resp-1"),
]),
)
.await;
// Second stream: after tool execution, assistant emits a message and completes.
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp image tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.thread_manager
.get_models_manager()
.list_models(
RefreshStrategy::Online,
codex_core::test_support::default_http_client_factory(),
)
.await;
assert_eq!(models_mock.requests().len(), 1);
fixture
.codex
.start_or_steer_turn(read_only_user_turn_with_model(
&fixture,
"call the rmcp image tool",
text_only_model_slug.to_string(),
))
.await?;
wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
let header = output_item["output"][0]["text"]
.as_str()
.expect("first content item should contain the wall-time header");
assert_wall_time_header(header);
assert_eq!(
output_item["output"],
json!([
{"type": "input_text", "text": header},
{
"type": "input_text",
"text": "<image content omitted because you do not support image input>",
},
])
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "call-1234";
let server_name = "rmcp_whitelist";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
"{\"message\":\"ping\"}",
),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let expected_env_value = "propagated-env-from-whitelist";
let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value));
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
/*env*/ None,
vec!["MCP_TEST_VALUE".into()],
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool"))
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("event guard guarantees McpToolCallBegin");
};
assert_eq!(begin.invocation.server, server_name);
assert_eq!(begin.invocation.tool, "echo");
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let result = end
.result
.as_ref()
.expect("rmcp echo tool should return success");
assert_eq!(result.is_error, Some(false));
assert!(
result.content.is_empty(),
"content should default to an empty array"
);
let structured = result
.structured_content
.as_ref()
.expect("structured content");
let map = structured
.as_object()
.expect("structured content should be an object");
let echo_value = map
.get("echo")
.and_then(Value::as_str)
.expect("echo payload present");
assert_eq!(echo_value, "ECHOING: ping");
let env_value = map
.get("env")
.and_then(Value::as_str)
.expect("env snapshot inserted");
assert_eq!(env_value, expected_env_value);
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_env_source)]
async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "call-local-source";
let server_name = "rmcp_local_source";
let namespace = format!("mcp__{server_name}");
let env_name = "MCP_TEST_LOCAL_SOURCE";
let expected_env_value = "propagated-explicit-local-source";
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
&format!(r#"{{"message":"ping","env_var":"{env_name}"}}"#),
),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let _guard = EnvVarGuard::set(env_name, OsStr::new(expected_env_value));
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
/*env*/ None,
vec![McpServerEnvVar::Config {
name: env_name.to_string(),
source: Some("local".to_string()),
}],
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool"))
.await?;
wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let structured = end
.result
.as_ref()
.expect("rmcp echo tool should return success")
.structured_content
.as_ref()
.expect("structured content");
assert_eq!(structured["env"], expected_env_value);
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_env_source)]
async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result<()> {
// TODO(anp): Remove after packaging a Windows stdio test server for Wine exec.
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
skip_if_no_network!(Ok(()));
skip_if_no_remote_env!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "call-remote-source";
let server_name = "rmcp_remote_source";
let namespace = format!("mcp__{server_name}");
let env_name = "MCP_TEST_REMOTE_SOURCE_ONLY";
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
&format!(r#"{{"message":"ping","env_var":"{env_name}"}}"#),
),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
.await;
let _guard = EnvVarGuard::set(env_name, OsStr::new("local-value-should-not-cross"));
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
/*env*/ None,
vec![McpServerEnvVar::Config {
name: env_name.to_string(),
source: Some("remote".to_string()),
}],
),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool"))
.await?;
wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let structured = end
.result
.as_ref()
.expect("rmcp echo tool should return success")
.structured_content
.as_ref()
.expect("structured content");
assert_eq!(structured["env"], Value::Null);
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
server.verify().await;
Ok(())
}
/// Remote runtime websocket URL used by remote-aware MCP integration tests.
const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL";
/// OAuth metadata path served by the Streamable HTTP MCP test server.
const STREAMABLE_HTTP_METADATA_PATH: &str = "/.well-known/oauth-authorization-server/mcp";
/// Streamable HTTP test server plus the process handle needed for cleanup.
struct StreamableHttpTestServer {
server_url: String,
process: StreamableHttpTestServerProcess,
}
/// Tracks whether the Streamable HTTP test server runs on the host or remotely.
enum StreamableHttpTestServerProcess {
Local(Child),
Remote(RemoteStreamableHttpServer),
}
/// Remote Streamable HTTP server process and copied files to remove on drop.
struct RemoteStreamableHttpServer {
container_name: String,
pid: String,
paths_to_remove: Vec<String>,
}
impl Drop for RemoteStreamableHttpServer {
/// Stops the remote process and removes copied test artifacts best-effort.
fn drop(&mut self) {
self.kill();
if self.paths_to_remove.is_empty() {
return;
}
let script = format!("rm -f {}", self.paths_to_remove.join(" "));
let _ = StdCommand::new("docker")
.args(["exec", &self.container_name, "sh", "-lc", &script])
.output();
}
}
impl RemoteStreamableHttpServer {
/// Stops the remote Streamable HTTP test server process.
fn kill(&self) {
let _ = StdCommand::new("docker")
.args(["exec", &self.container_name, "kill", &self.pid])
.output();
}
}
impl StreamableHttpTestServer {
/// Returns the MCP endpoint URL that Codex should connect to.
fn url(&self) -> &str {
&self.server_url
}
/// Stops the local or remote test server and waits for local process exit.
async fn shutdown(mut self) {
match &mut self.process {
StreamableHttpTestServerProcess::Local(child) => match child.try_wait() {
Ok(Some(_)) => {}
Ok(None) => {
let _ = child.kill().await;
}
Err(error) => {
eprintln!("failed to check streamable http server status: {error}");
let _ = child.kill().await;
}
},
StreamableHttpTestServerProcess::Remote(server) => {
server.kill();
}
}
if let StreamableHttpTestServerProcess::Local(child) = &mut self.process
&& let Err(error) = child.wait().await
{
eprintln!("failed to await streamable http server shutdown: {error}");
}
}
}
enum HeadersHelperMode {
None,
Static,
Rotating,
RotatingAuthorization,
}
/// What this tests: Codex can discover and call a Streamable HTTP MCP tool in
/// both local and remote-aware placements, and the tool observes the expected
/// environment value from the server process that actually handled the request.
#[test_case(HeadersHelperMode::None; "plain")]
#[test_case(HeadersHelperMode::Static; "headers helper")]
#[test_case(HeadersHelperMode::Rotating; "headers helper refreshes rejected tool call")]
#[test_case(HeadersHelperMode::RotatingAuthorization; "Authorization helper refreshes rejected tool call")]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_tool_call_round_trip(mode: HeadersHelperMode) -> anyhow::Result<()> {
let with_headers_helper = !matches!(mode, HeadersHelperMode::None);
let helper_authorization = matches!(mode, HeadersHelperMode::RotatingAuthorization);
let refresh_rejected_call = matches!(
mode,
HeadersHelperMode::Rotating | HeadersHelperMode::RotatingAuthorization
);
skip_if_no_network!(Ok(()));
if with_headers_helper && is_remote_test_environment() {
return Ok(());
}
// Phase 1: script the model responses so Codex will call the MCP echo tool
// and then complete the turn after the tool result is returned.
let server = responses::start_mock_server().await;
let call_id = "call-456";
let server_name = "rmcp_http";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
"{\"message\":\"ping\"}",
),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message(
"msg-1",
"rmcp streamable http echo tool completed successfully.",
),
responses::ev_completed("resp-2"),
]),
)
.await;
// Phase 2: start the Streamable HTTP MCP test server in the active
// placement. In full CI this may be the remote environment container; locally
// it is a host process.
let expected_env_value = "propagated-env-http";
let Some(http_server) = start_streamable_http_test_server(
expected_env_value,
helper_authorization.then_some("gateway-token"),
(with_headers_helper && !helper_authorization).then_some("gateway-token"),
)
.await?
else {
return Ok(());
};
let server_url = http_server.url().to_string();
let helper_directory = tempdir()?;
let helper_invocations = helper_directory.path().join("helper-invocations");
let http_headers_helper = with_headers_helper.then(|| {
if refresh_rejected_call {
let authorization_arg = if helper_authorization {
" --authorization"
} else {
""
};
format!(
"\"{}\" --http-headers-helper \"{}\"{authorization_arg}",
cargo_bin("test_streamable_http_server")
.expect("streamable HTTP helper binary")
.display(),
helper_invocations.display(),
)
} else if cfg!(windows) {
r#"echo {"Proxy-Authorization":"Bearer gateway-token"}"#.to_string()
} else {
r#"printf '{"Proxy-Authorization":"Bearer gateway-token"}'"#.to_string()
}
});
// Phase 3: configure Codex with the Streamable HTTP MCP server and build a
// fixture that selects remote MCP placement only when the remote test
// environment is active.
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper,
},
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
if refresh_rejected_call {
let control_url = http_server
.url()
.replace("/mcp", "/test/control/session-post-failure");
let response = HttpClientBuilder::new()
.build_direct()?
.post(control_url)
.bearer_auth("gateway-token")
.json(&json!({ "status": 401, "remaining": 1 }))
.send()
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
// Phase 4: submit the user turn that should trigger the MCP tool call.
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp streamable http echo tool",
))
.await?;
// Phase 5: assert Codex begins the expected tool invocation.
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("event guard guarantees McpToolCallBegin");
};
assert_eq!(begin.invocation.server, server_name);
assert_eq!(begin.invocation.tool, "echo");
// Phase 6: assert the tool result proves the server handled the request and
// propagated the expected environment value.
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let result = end
.result
.as_ref()
.expect("rmcp echo tool should return success");
assert_eq!(result.is_error, Some(false));
assert!(
result.content.is_empty(),
"content should default to an empty array"
);
let structured = result
.structured_content
.as_ref()
.expect("structured content");
let map = structured
.as_object()
.expect("structured content should be an object");
let echo_value = map
.get("echo")
.and_then(Value::as_str)
.expect("echo payload present");
assert_eq!(echo_value, "ECHOING: ping");
let env_value = map
.get("env")
.and_then(Value::as_str)
.expect("env snapshot inserted");
assert_eq!(env_value, expected_env_value);
if refresh_rejected_call {
assert_eq!(fs::read_to_string(helper_invocations)?, "xx");
}
// Phase 7: verify the scripted model calls were consumed and clean up the
// placement-aware MCP server.
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
server.verify().await;
http_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_configured_auth_precedes_chatgpt_auth() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let Some(configured_auth_server) = start_streamable_http_test_server(
"configured-auth",
Some("configured-token"),
/*expected_gateway_token*/ None,
)
.await?
else {
return Ok(());
};
let configured_auth_url = configured_auth_server.url().to_string();
let configured_auth_fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
insert_mcp_server(
config,
"configured_auth",
McpServerTransportConfig::StreamableHttp {
url: configured_auth_url,
bearer_token_env_var: None,
http_headers: Some(HashMap::from([(
"Authorization".to_string(),
"Bearer configured-token".to_string(),
)])),
env_http_headers: None,
http_headers_helper: None,
},
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
auth: McpServerAuth::ChatGpt,
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&configured_auth_fixture.codex, "configured_auth").await?;
drop(configured_auth_fixture);
configured_auth_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_chatgpt_auth_is_not_sent_to_configured_origin() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let untrusted_server = MockServer::start().await;
let untrusted_apps = AppsTestServer::mount(&untrusted_server).await?;
let untrusted_mcp_url = format!("{}/api/codex/ps/mcp", untrusted_apps.chatgpt_base_url);
let untrusted_chatgpt_base_url = untrusted_apps.chatgpt_base_url;
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.chatgpt_base_url = untrusted_chatgpt_base_url;
insert_mcp_server(
config,
"untrusted_origin",
McpServerTransportConfig::StreamableHttp {
url: untrusted_mcp_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
TestMcpServerOptions {
auth: McpServerAuth::ChatGpt,
..Default::default()
},
);
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, "untrusted_origin").await?;
let observed_requests = untrusted_server
.received_requests()
.await
.expect("mock server should capture MCP startup requests")
.into_iter()
.filter(|request| request.url.path() == "/api/codex/ps/mcp")
.filter_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
let method = body.get("method")?.as_str()?.to_string();
let authorization = request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
Some((method, authorization))
})
.collect::<Vec<_>>();
assert_eq!(
observed_requests,
vec![
("initialize".to_string(), None),
("notifications/initialized".to_string(), None),
("tools/list".to_string(), None),
],
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn configured_chatgpt_base_url_does_not_grant_mcp_chatgpt_auth() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let untrusted_server = MockServer::start().await;
let untrusted_apps = AppsTestServer::mount(&untrusted_server).await?;
let untrusted_mcp_url = format!("{}/api/codex/ps/mcp", untrusted_apps.chatgpt_base_url);
let untrusted_chatgpt_base_url = untrusted_apps.chatgpt_base_url;
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_pre_build_hook(move |codex_home| {
fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{untrusted_chatgpt_base_url}"
[mcp_servers.untrusted_origin]
url = "{untrusted_mcp_url}"
auth = "chatgpt"
"#,
),
)
.expect("write attacker-controlled MCP config");
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, "untrusted_origin").await?;
let observed_requests = untrusted_server
.received_requests()
.await
.expect("mock server should capture MCP startup requests")
.into_iter()
.filter(|request| request.url.path() == "/api/codex/ps/mcp")
.filter_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
let method = body.get("method")?.as_str()?.to_string();
let authorization = request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
Some((method, authorization))
})
.collect::<Vec<_>>();
assert_eq!(
observed_requests,
vec![
("initialize".to_string(), None),
("notifications/initialized".to_string(), None),
("tools/list".to_string(), None),
],
);
Ok(())
}
/// This test writes to a fallback credentials file in CODEX_HOME.
/// Ideally, we wouldn't need to serialize the test but it's much more cumbersome to wire CODEX_HOME through the code.
#[test]
#[serial(codex_home)]
fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let handle = std::thread::Builder::new()
.name("streamable_http_with_oauth_round_trip".to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(|| -> anyhow::Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()?;
runtime.block_on(streamable_http_with_oauth_round_trip_impl())
})?;
match handle.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"streamable_http_with_oauth_round_trip thread panicked"
)),
}
}
async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
// Phase 1: script the model responses so Codex will call the OAuth-backed
// MCP echo tool and then finish the turn after receiving the result.
let server = responses::start_mock_server().await;
let call_id = "call-789";
let server_name = "rmcp_http_oauth";
let discovered_server_name = "rmcp_http_oauth_discovered";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-contended", "OAuth sign-in is still pending."),
responses::ev_completed("resp-contended"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message(
"msg-discovered",
"A newly discovered OAuth server is still starting.",
),
responses::ev_completed("resp-discovered"),
]),
)
.await;
let response_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"echo",
"{\"message\":\"ping\"}",
),
responses::ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message(
"msg-1",
"rmcp streamable http oauth echo tool completed successfully.",
),
responses::ev_completed("resp-2"),
]),
)
.await;
// Phase 2: start the Streamable HTTP MCP test server with bearer-token
// enforcement enabled so the client must use stored OAuth credentials.
let expected_env_value = "propagated-env-http-oauth";
let expected_token = "initial-access-token";
let client_id = "test-client-id";
let refresh_token = "initial-refresh-token";
let Some(http_server) = start_streamable_http_test_server(
expected_env_value,
Some(expected_token),
/*expected_gateway_token*/ None,
)
.await?
else {
return Ok(());
};
let server_url = http_server.url().to_string();
// Phase 3: seed an isolated CODEX_HOME with fallback OAuth tokens for this
// server so the test does not share credentials with other suite cases.
let temp_home = Arc::new(tempdir()?);
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str());
let unset_authorization_env_var = format!(
"CODEX_TEST_UNSET_MCP_OAUTH_AUTHORIZATION_{}",
std::process::id()
);
assert!(std::env::var_os(&unset_authorization_env_var).is_none());
let environment_id = remote_aware_environment_id();
let credential_config: McpServerConfig = serde_json::from_value(json!({
"url": &server_url,
"environment_id": &environment_id,
}))?;
let credential_name = credential_config.oauth_credential_name(server_name);
write_fallback_oauth_tokens(
credential_name.as_ref(),
&server_url,
client_id,
"expired-access-token",
refresh_token,
OAuthCredentialExpiry::Expired,
)
.await?;
let discovered_credential_name =
credential_config.oauth_credential_name(discovered_server_name);
write_fallback_oauth_tokens(
discovered_credential_name.as_ref(),
&server_url,
client_id,
expected_token,
refresh_token,
OAuthCredentialExpiry::Valid,
)
.await?;
// Phase 4: configure Codex with the OAuth-backed Streamable HTTP MCP
// server and build the fixture in the active local or remote-aware mode.
let fixture = test_codex()
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_home(temp_home.clone())
.with_config(move |config| {
config
.features
.enable(Feature::McpOAuthRefreshCoordination)
.expect("test config should allow coordinated MCP OAuth refresh");
config.mcp_oauth_credentials_store_mode = OAuthCredentialsStoreMode::Auto;
insert_mcp_server(
config,
server_name,
McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: Some(HashMap::from([(
"Authorization".to_string(),
unset_authorization_env_var,
)])),
http_headers_helper: None,
},
TestMcpServerOptions {
environment_id,
..Default::default()
},
);
})
.build_with_auto_env(&server)
.await?;
// Phase 5: replace rejected credentials as an external OAuth login would.
let recovery_hint = if credential_config.is_local_environment() {
format!("Run `codex mcp login {server_name}`.")
} else {
"Use your client's MCP OAuth sign-in flow.".to_string()
};
let expected_failure = (
format!("The {server_name} MCP server requires OAuth reauthentication. {recovery_hint}"),
Some(McpStartupFailureReason::ReauthenticationRequired),
);
let mut failure = None;
let startup = wait_for_event(&fixture.codex, |event| {
if let EventMsg::McpStartupUpdate(update) = event
&& update.server == server_name
&& let McpStartupStatus::Failed { error, reason } = &update.status
{
failure = Some((error.clone(), *reason));
}
matches!(event, EventMsg::McpStartupComplete(_))
})
.await;
let EventMsg::McpStartupComplete(startup) = startup else {
unreachable!("event guard guarantees McpStartupComplete");
};
assert_eq!(startup.failed.len(), 1);
assert_eq!(startup.failed[0].server, server_name);
assert_eq!(failure, Some(expected_failure.clone()));
let store_lock = fs::OpenOptions::new()
.read(true)
.write(true)
.open(temp_home.path().join("mcp-oauth-locks/file-store.lock"))?;
store_lock.try_lock()?;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"continue while OAuth credentials are locked",
))
.await?;
let (contended_turn, refreshed_failure, refreshed_failed_servers) =
tokio::time::timeout(Duration::from_secs(5), async {
let mut contended_turn = None;
let mut refreshed_failure = None;
let mut refreshed_failed_servers = None;
let mut refreshed_starting = false;
while contended_turn.is_none()
|| refreshed_failure.is_none()
|| refreshed_failed_servers.is_none()
{
match fixture.codex.next_event().await?.msg {
EventMsg::McpStartupUpdate(update) if update.server == server_name => {
match update.status {
McpStartupStatus::Starting => refreshed_starting = true,
McpStartupStatus::Failed { error, reason } => {
assert!(refreshed_starting);
refreshed_failure = Some((error, reason));
}
McpStartupStatus::Ready | McpStartupStatus::Cancelled => {}
}
}
EventMsg::McpStartupComplete(startup) => {
refreshed_failed_servers = Some(startup.failed);
}
EventMsg::TurnComplete(turn) => contended_turn = Some(turn),
_ => {}
}
}
Ok::<_, anyhow::Error>((
contended_turn.expect("turn completion was observed"),
refreshed_failure.expect("failure status was observed"),
refreshed_failed_servers.expect("startup summary was observed"),
))
})
.await
.context("OAuth credential-store contention blocked the user turn or startup status")??;
assert_eq!(refreshed_failure, expected_failure);
assert_eq!(
refreshed_failed_servers
.into_iter()
.map(|failure| failure.server)
.collect::<Vec<_>>(),
vec![server_name.to_string()],
);
assert!(
contended_turn.error.is_none(),
"the user turn failed while OAuth credentials were locked: {:?}",
contended_turn.error,
);
assert_eq!(
contended_turn.last_agent_message.as_deref(),
Some("OAuth sign-in is still pending."),
);
let mut refreshed_config = fixture.config.clone();
let mut refreshed_servers = refreshed_config.mcp_servers.get().clone();
let discovered_server = refreshed_servers
.get(server_name)
.cloned()
.expect("the initial OAuth server should be configured");
refreshed_servers.insert(discovered_server_name.to_string(), discovered_server);
refreshed_config
.mcp_servers
.set(refreshed_servers)
.expect("test MCP servers should accept the discovered OAuth server");
let discovered_turn = tokio::time::timeout(Duration::from_secs(5), async {
fixture
.codex
.refresh_runtime_config(refreshed_config.clone())
.await;
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"continue while a newly discovered OAuth server is starting",
))
.await?;
loop {
if let EventMsg::TurnComplete(turn) = fixture.codex.next_event().await?.msg {
return Ok::<_, anyhow::Error>(turn);
}
}
})
.await
.context("a newly discovered OAuth server blocked the user turn during store contention")??;
assert!(
discovered_turn.error.is_none(),
"the user turn failed while a newly discovered OAuth server was locked: {:?}",
discovered_turn.error,
);
assert_eq!(
discovered_turn.last_agent_message.as_deref(),
Some("A newly discovered OAuth server is still starting."),
);
drop(store_lock);
tokio::time::timeout(
Duration::from_secs(5),
wait_for_mcp_server(&fixture.codex, discovered_server_name),
)
.await
.context("the newly discovered OAuth server did not recover after its store was unlocked")??;
assert!(
codex_rmcp_client::delete_oauth_tokens(
discovered_credential_name.as_ref(),
http_server.url(),
OAuthCredentialsStoreMode::File,
codex_config::types::AuthKeyringBackendKind::default(),
)
.await?
);
fixture.codex.refresh_runtime_config(refreshed_config).await;
let logged_out_startup = tokio::time::timeout(
Duration::from_secs(5),
wait_for_event(&fixture.codex, |event| {
matches!(event, EventMsg::McpStartupComplete(_))
}),
)
.await
.context("MCP startup did not finish after the discovered OAuth server was logged out")?;
let EventMsg::McpStartupComplete(logged_out_startup) = logged_out_startup else {
unreachable!("event guard guarantees McpStartupComplete");
};
assert!(
logged_out_startup
.failed
.iter()
.any(|failure| failure.server == discovered_server_name),
"the logged-out MCP server reused its authenticated connection: {logged_out_startup:?}",
);
write_fallback_oauth_tokens(
credential_name.as_ref(),
http_server.url(),
client_id,
expected_token,
refresh_token,
OAuthCredentialExpiry::Valid,
)
.await?;
// Phase 6: submit the user turn that should invoke the OAuth-backed tool.
fixture
.codex
.start_or_steer_turn(read_only_user_turn(
&fixture,
"call the rmcp streamable http oauth echo tool",
))
.await?;
// Phase 7: assert Codex begins the expected tool invocation.
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(
ev,
EventMsg::McpToolCallBegin(_)
| EventMsg::Error(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
anyhow::bail!("OAuth MCP recovery ended before the tool was called: {begin_event:?}");
};
assert_eq!(begin.invocation.server, server_name);
assert_eq!(begin.invocation.tool, "echo");
// Phase 8: assert the tool result proves the authenticated request reached
// the server and preserved the expected environment value.
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("event guard guarantees McpToolCallEnd");
};
let result = end
.result
.as_ref()
.expect("rmcp echo tool should return success");
assert_eq!(result.is_error, Some(false));
assert!(
result.content.is_empty(),
"content should default to an empty array"
);
let structured = result
.structured_content
.as_ref()
.expect("structured content");
let map = structured
.as_object()
.expect("structured content should be an object");
let echo_value = map
.get("echo")
.and_then(Value::as_str)
.expect("echo payload present");
assert_eq!(echo_value, "ECHOING: ping");
let env_value = map
.get("env")
.and_then(Value::as_str)
.expect("env snapshot inserted");
assert_eq!(env_value, expected_env_value);
// Phase 9: verify the scripted model calls were consumed and clean up the
// placement-aware MCP server.
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = response_mock.single_request().body_json();
assert!(
responses::namespace_child_tool(&request, &namespace, "echo").is_some(),
"the recovered MCP tool must be advertised to the model"
);
server.verify().await;
http_server.shutdown().await;
Ok(())
}
/// Starts the Streamable HTTP MCP test server in the active test placement.
async fn start_streamable_http_test_server(
expected_env_value: &str,
expected_token: Option<&str>,
expected_gateway_token: Option<&str>,
) -> anyhow::Result<Option<StreamableHttpTestServer>> {
let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") {
Ok(path) => path,
Err(err) => {
eprintln!("test_streamable_http_server binary not available, skipping test: {err}");
return Ok(None);
}
};
if let Some(container_name) = test_docker_container_name() {
return Ok(Some(
start_remote_streamable_http_test_server(
&container_name,
&rmcp_http_server_bin,
expected_env_value,
expected_token,
)
.await?,
));
}
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let bind_addr = format!("127.0.0.1:{port}");
let server_url = format!("http://{bind_addr}/mcp");
let mut command = Command::new(&rmcp_http_server_bin);
command
.kill_on_drop(true)
.env("MCP_STREAMABLE_HTTP_BIND_ADDR", &bind_addr)
.env("MCP_TEST_VALUE", expected_env_value);
if let Some(expected_token) = expected_token {
command.env("MCP_EXPECT_BEARER", expected_token);
}
if let Some(expected_gateway_token) = expected_gateway_token {
command.env("MCP_EXPECT_GATEWAY_BEARER", expected_gateway_token);
}
let mut child = command.spawn()?;
wait_for_local_streamable_http_server(&mut child, &server_url, Duration::from_secs(5)).await?;
Ok(Some(StreamableHttpTestServer {
server_url,
process: StreamableHttpTestServerProcess::Local(child),
}))
}
/// Starts the Streamable HTTP MCP test server inside the remote test container.
async fn start_remote_streamable_http_test_server(
container_name: &str,
rmcp_http_server_bin: &Path,
expected_env_value: &str,
expected_token: Option<&str>,
) -> anyhow::Result<StreamableHttpTestServer> {
let remote_path = copy_binary_to_remote_env(
container_name,
rmcp_http_server_bin,
"test_streamable_http_server",
)?;
let bound_addr_file = format!("{remote_path}.addr");
let log_file = format!("{remote_path}.log");
let mut env_assignments = vec![
format!(
"MCP_STREAMABLE_HTTP_BIND_ADDR={}",
sh_single_quote("0.0.0.0:0")
),
format!(
"MCP_STREAMABLE_HTTP_BOUND_ADDR_FILE={}",
sh_single_quote(&bound_addr_file)
),
format!("MCP_TEST_VALUE={}", sh_single_quote(expected_env_value)),
];
if let Some(expected_token) = expected_token {
env_assignments.push(format!(
"MCP_EXPECT_BEARER={}",
sh_single_quote(expected_token)
));
}
let script = format!(
"{} nohup {} > {} 2>&1 < /dev/null & echo $!",
env_assignments.join(" "),
sh_single_quote(&remote_path),
sh_single_quote(&log_file)
);
let start_output = StdCommand::new("docker")
.args(["exec", container_name, "sh", "-lc", &script])
.output()
.context("start remote streamable HTTP MCP test server")?;
ensure!(
start_output.status.success(),
"docker start streamable HTTP MCP test server failed: stdout={} stderr={}",
String::from_utf8_lossy(&start_output.stdout).trim(),
String::from_utf8_lossy(&start_output.stderr).trim()
);
let pid = String::from_utf8(start_output.stdout)
.context("remote streamable HTTP server pid must be utf-8")?
.trim()
.to_string();
ensure!(
!pid.is_empty(),
"remote streamable HTTP server pid is empty"
);
let remote_bind_addr =
wait_for_remote_bound_addr(container_name, &bound_addr_file, Duration::from_secs(5))
.await?;
let container_ip = remote_container_ip(container_name)?;
let server_url = format!("http://{}:{}/mcp", container_ip, remote_bind_addr.port());
// The orchestrator can see the Docker container IP, but the behavior under
// test is whether the remote-side MCP client can reach it. Probe through
// remote HTTP before handing the URL to the Codex fixture.
wait_for_remote_streamable_http_server(&server_url, Duration::from_secs(5)).await?;
if expected_token.is_some() {
wait_for_streamable_http_metadata(&server_url, Duration::from_secs(5)).await?;
}
Ok(StreamableHttpTestServer {
server_url,
process: StreamableHttpTestServerProcess::Remote(RemoteStreamableHttpServer {
container_name: container_name.to_string(),
pid,
paths_to_remove: vec![remote_path, bound_addr_file, log_file],
}),
})
}
/// Single-quotes a value for the small shell snippets sent through Docker.
fn sh_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
/// Waits until the remote test server writes the socket address it bound to.
async fn wait_for_remote_bound_addr(
container_name: &str,
bound_addr_file: &str,
timeout: Duration,
) -> anyhow::Result<SocketAddr> {
let deadline = Instant::now() + timeout;
loop {
let output = StdCommand::new("docker")
.args(["exec", container_name, "cat", bound_addr_file])
.output()
.context("read remote streamable HTTP server bound address")?;
if output.status.success() {
let bound_addr = String::from_utf8(output.stdout)
.context("remote streamable HTTP bound address must be utf-8")?;
return bound_addr
.trim()
.parse()
.context("parse remote streamable HTTP bound address");
}
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for remote streamable HTTP bound address: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
sleep(Duration::from_millis(50)).await;
}
}
/// Reads the container IP that the host-side test process can use.
fn remote_container_ip(container_name: &str) -> anyhow::Result<String> {
let output = StdCommand::new("docker")
.args([
"inspect",
"-f",
"{{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}}",
container_name,
])
.output()
.context("inspect remote MCP test container IP")?;
ensure!(
output.status.success(),
"docker inspect remote MCP test container IP failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
let inspect_output =
String::from_utf8(output.stdout).context("remote MCP test container IP must be utf-8")?;
let ip = inspect_output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or_default()
.to_string();
if ip.is_empty() {
Ok("127.0.0.1".to_string())
} else {
Ok(ip)
}
}
/// Waits for the local Streamable HTTP test server to publish OAuth metadata.
async fn wait_for_local_streamable_http_server(
server_child: &mut Child,
server_url: &str,
timeout: Duration,
) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
let metadata_url = streamable_http_metadata_url(server_url);
let client = HttpClientBuilder::new().build_direct()?;
loop {
if let Some(status) = server_child.try_wait()? {
return Err(anyhow::anyhow!(
"streamable HTTP server exited early with status {status}"
));
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: deadline reached"
));
}
match tokio::time::timeout(remaining, client.get(&metadata_url).send()).await {
Ok(Ok(response)) if response.status() == StatusCode::OK => return Ok(()),
Ok(Ok(response)) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: HTTP {}",
response.status()
));
}
}
Ok(Err(error)) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: {error}"
));
}
}
Err(_) => {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: request timed out"
));
}
}
sleep(Duration::from_millis(50)).await;
}
}
/// Waits for the remote Streamable HTTP test server via remote HTTP.
async fn wait_for_remote_streamable_http_server(
server_url: &str,
timeout: Duration,
) -> anyhow::Result<()> {
let websocket_url = std::env::var(REMOTE_EXEC_SERVER_URL_ENV_VAR).with_context(|| {
format!("{REMOTE_EXEC_SERVER_URL_ENV_VAR} must be set for remote streamable HTTP MCP tests")
})?;
let environment = Environment::create_for_tests(Some(websocket_url))?;
let http_client = environment.get_http_client();
let metadata_url = streamable_http_metadata_url(server_url);
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(anyhow::anyhow!(
"timed out waiting for remote streamable HTTP server metadata at {metadata_url}: deadline reached"
));
}
let request = HttpRequestParams {
method: "GET".to_string(),
url: metadata_url.clone(),
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,
};
match http_client.http_request(request).await {
Ok(response) if response.status == StatusCode::OK.as_u16() => return Ok(()),
Ok(response) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for remote streamable HTTP server metadata at {metadata_url}: HTTP {}",
response.status
));
}
}
Err(error) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for remote streamable HTTP server metadata at {metadata_url}: {error}"
));
}
}
}
sleep(Duration::from_millis(50)).await;
}
}
/// Waits for OAuth metadata from the host-side test process.
async fn wait_for_streamable_http_metadata(
server_url: &str,
timeout: Duration,
) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
let metadata_url = streamable_http_metadata_url(server_url);
let client = HttpClientBuilder::new().build_direct()?;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: deadline reached"
));
}
match tokio::time::timeout(remaining, client.get(&metadata_url).send()).await {
Ok(Ok(response)) if response.status() == StatusCode::OK => return Ok(()),
Ok(Ok(response)) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: HTTP {}",
response.status()
));
}
}
Ok(Err(error)) => {
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: {error}"
));
}
}
Err(_) => {
return Err(anyhow::anyhow!(
"timed out waiting for streamable HTTP server metadata at {metadata_url}: request timed out"
));
}
}
sleep(Duration::from_millis(50)).await;
}
}
/// Builds the OAuth metadata URL for the test Streamable HTTP MCP endpoint.
fn streamable_http_metadata_url(server_url: &str) -> String {
let base_url = server_url.strip_suffix("/mcp").unwrap_or(server_url);
format!("{base_url}{STREAMABLE_HTTP_METADATA_PATH}")
}
enum OAuthCredentialExpiry {
Valid,
Expired,
}
async fn write_fallback_oauth_tokens(
server_name: &str,
server_url: &str,
client_id: &str,
access_token: &str,
refresh_token: &str,
expiry: OAuthCredentialExpiry,
) -> anyhow::Result<()> {
let expires_at = match expiry {
OAuthCredentialExpiry::Valid => SystemTime::now()
.checked_add(Duration::from_secs(3600))
.ok_or_else(|| anyhow::anyhow!("failed to compute expiry time"))?
.duration_since(UNIX_EPOCH)?
.as_millis() as u64,
OAuthCredentialExpiry::Expired => 0,
};
let tokens = serde_json::from_value(json!({
"server_name": server_name,
"url": server_url,
"client_id": client_id,
"issuer": server_url,
"token_response": {
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": refresh_token,
"scope": "profile",
},
"expires_at": expires_at,
}))?;
codex_rmcp_client::save_oauth_tokens(
server_name,
&tokens,
OAuthCredentialsStoreMode::File,
codex_config::types::AuthKeyringBackendKind::default(),
)
.await
}
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}