mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## What changed Add the disabled-by-default `omit_skill_descriptions` feature flag to omit descriptions from model-visible host, executor, and cloud skill catalogs while retaining names and locators. Adapt catalog introductions, usage instructions, and alias budget accounting to the selected policy. Persist the description policy in catalog snapshots and refresh usage instructions when it changes. Ignore description-only edits in catalog deltas while descriptions are omitted. ## Testing Add coverage for alias token budgets, policy changes with older snapshots, and suppressed description-only updates. Extend scenario snapshots to cover plugin visibility and catalog deduplication with descriptions omitted. GitOrigin-RevId: 5bf1dcee8c04059c7a78998affc1efcdef8ec883
5343 lines
190 KiB
Rust
5343 lines
190 KiB
Rust
use anyhow::Context as _;
|
|
use anyhow::ensure;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
|
use codex_protocol::protocol::TurnEnvironmentRequest;
|
|
use codex_protocol::protocol::TurnEnvironmentRequests;
|
|
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::X_CODEX_ROUTING_HINT_HEADER;
|
|
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_LAUNCHER_META_KEY;
|
|
use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY;
|
|
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
|
|
use codex_mcp::McpProtocolMode;
|
|
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::ServiceTier;
|
|
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::ImageDetail;
|
|
use codex_protocol::models::ImageReference;
|
|
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::ModelServiceTier;
|
|
use codex_protocol::openai_models::ModelVisibility;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_protocol::openai_models::ToolMode;
|
|
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::TurnSettingsUpdate;
|
|
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
|
|
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_protocol::turn_input::TurnInput;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
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;
|
|
#[path = "mcp_sandbox_tests.rs"]
|
|
mod sandbox_tests;
|
|
#[path = "mcp_storage_telemetry_tests.rs"]
|
|
mod storage_telemetry_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(©_output.stdout).trim(),
|
|
String::from_utf8_lossy(©_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,
|
|
startup_readiness: Default::default(),
|
|
supports_parallel_tool_calls: options.supports_parallel_tool_calls,
|
|
tool_input_schema_max_bytes: None,
|
|
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 request = final_mock.single_request();
|
|
let output = 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",
|
|
},
|
|
])
|
|
);
|
|
|
|
let request_body = request.body_json();
|
|
let first_turn_id = request_body["client_metadata"]["turn_id"].clone();
|
|
assert!(first_turn_id.is_string());
|
|
let expected_attribution = json!({
|
|
"status": "complete",
|
|
"sources": [{
|
|
"server_name": "rmcp",
|
|
"tool_name": "image_scenario",
|
|
"first_turn_id": first_turn_id,
|
|
}],
|
|
});
|
|
assert_eq!(
|
|
serde_json::to_value(codex_core::test_support::mcp_attribution_snapshot(
|
|
&fixture.codex
|
|
))?,
|
|
expected_attribution,
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(
|
|
request_body["client_metadata"]["mcp_attribution"]
|
|
.as_str()
|
|
.context("MCP attribution should be included for the OpenAI provider")?,
|
|
)?,
|
|
expected_attribution,
|
|
);
|
|
|
|
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;
|
|
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(TurnEnvironmentRequests::new(
|
|
fixture.config.cwd.clone(),
|
|
vec![TurnEnvironmentRequest {
|
|
config: EnvironmentConfigState::Pending,
|
|
..selection.clone().into_request()
|
|
}],
|
|
)),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
fixture
|
|
.submit_text_turn("start with pending environment configuration")
|
|
.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 {
|
|
environment_hooks: Default::default(),
|
|
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_type: fixture.config.permissions.windows_sandbox_type,
|
|
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?;
|
|
|
|
let response = mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_assistant_message("msg-2", "done"),
|
|
responses::ev_completed("resp-2"),
|
|
]),
|
|
)
|
|
.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(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn future_environment_mcp_policy_applies_on_the_next_turn() -> 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 = responses::mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
responses::sse(vec![
|
|
responses::ev_function_call(
|
|
"pause",
|
|
"request_user_input",
|
|
&json!({
|
|
"questions": [{
|
|
"id": "continue",
|
|
"header": "Continue",
|
|
"question": "Continue?",
|
|
"options": [
|
|
{"label": "Yes (Recommended)", "description": "Continue."},
|
|
{"label": "No", "description": "Stop."}
|
|
]
|
|
}]
|
|
})
|
|
.to_string(),
|
|
),
|
|
responses::ev_completed("paused"),
|
|
]),
|
|
responses::sse(vec![responses::ev_completed("active-done")]),
|
|
responses::sse(vec![responses::ev_completed("next-done")]),
|
|
],
|
|
)
|
|
.await;
|
|
let command = remote_aware_stdio_server_bin()?;
|
|
let allowed_command = command.clone();
|
|
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::DefaultModeRequestUserInput)
|
|
.unwrap();
|
|
insert_mcp_server(
|
|
config,
|
|
"environment_policy",
|
|
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, "environment_policy").await?;
|
|
let selection = fixture.codex.environment_selections().await.remove(0);
|
|
let mut config = EnvironmentConfig {
|
|
environment_hooks: Default::default(),
|
|
allow_login_shell: fixture.config.permissions.allow_login_shell,
|
|
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_type: fixture.config.permissions.windows_sandbox_type,
|
|
use_legacy_landlock: fixture.config.features.use_legacy_landlock(),
|
|
exec_policy: None,
|
|
mcp_policy: Some(EnvironmentMcpPolicy {
|
|
servers: Some(BTreeMap::from([(
|
|
"environment_policy".to_string(),
|
|
McpServerRequirement::Identity {
|
|
identity: McpServerIdentity::Command {
|
|
command: allowed_command,
|
|
},
|
|
},
|
|
)])),
|
|
plugins: None,
|
|
}),
|
|
network_policy: None,
|
|
selected_capability_roots: Vec::new(),
|
|
};
|
|
let settings = |config| ThreadSettingsOverrides {
|
|
environments: Some(TurnEnvironmentRequests::new(
|
|
fixture.config.cwd.clone(),
|
|
vec![TurnEnvironmentRequest {
|
|
config: EnvironmentConfigState::Ready(config),
|
|
..selection.clone().into_request()
|
|
}],
|
|
)),
|
|
..Default::default()
|
|
};
|
|
submit_thread_settings(&fixture.codex, settings(config.clone())).await?;
|
|
fixture
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "pause before continuing".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
let EventMsg::RequestUserInput(request) = wait_for_event(&fixture.codex, |event| {
|
|
matches!(event, EventMsg::RequestUserInput(_))
|
|
})
|
|
.await
|
|
else {
|
|
unreachable!("wait_for_event should return the request-user-input event")
|
|
};
|
|
|
|
config.mcp_policy.as_mut().unwrap().servers = Some(BTreeMap::new());
|
|
submit_thread_settings(&fixture.codex, settings(config)).await?;
|
|
fixture
|
|
.codex
|
|
.submit(Op::UserInputAnswer {
|
|
id: request.turn_id,
|
|
response: RequestUserInputResponse {
|
|
answers: HashMap::from([(
|
|
"continue".to_string(),
|
|
RequestUserInputAnswer {
|
|
answers: vec!["Yes (Recommended)".to_string()],
|
|
},
|
|
)]),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_event(&fixture.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
fixture.submit_text_turn("start the next turn").await?;
|
|
|
|
let tool_is_visible = response
|
|
.requests()
|
|
.iter()
|
|
.map(|request| {
|
|
responses::namespace_child_tool(&request.body_json(), "mcp__environment_policy", "echo")
|
|
.is_some()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(tool_is_visible, vec![true, true, false]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(Some("discovery"), true; "notification_during_listing")]
|
|
#[test_case(Some("pagination"), true; "notification_invalidates_cursor")]
|
|
#[test_case(None, false; "disabled")]
|
|
#[test_case(Some("startup"), true; "notification_during_startup")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovers_and_calls_new_mcp_tool_in_same_turn(
|
|
invalidation: Option<&'static str>,
|
|
enabled: bool,
|
|
) -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary");
|
|
mcp_tool_discovery_scenario(ToolMode::Direct, invalidation, enabled)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
pub(super) async fn mcp_tool_discovery_scenario(
|
|
tool_mode: ToolMode,
|
|
invalidation: Option<&'static str>,
|
|
enabled: bool,
|
|
) -> anyhow::Result<Vec<responses::ResponsesRequest>> {
|
|
let server = responses::start_mock_server().await;
|
|
let command = remote_aware_stdio_server_bin()?;
|
|
let test = test_codex()
|
|
.with_config(move |config| {
|
|
config.code_mode.disable_in_process_fallback = true;
|
|
config
|
|
.features
|
|
.enable(Feature::CodeModeHost)
|
|
.expect("enable Code Mode host");
|
|
if enabled {
|
|
config
|
|
.features
|
|
.enable(Feature::McpToolListChanged)
|
|
.expect("enable tool-list refresh");
|
|
}
|
|
let mut env = HashMap::from([
|
|
("MCP_TEST_DISCOVER_TOOLS".to_string(), "1".to_string()),
|
|
("MCP_TEST_VALUE".to_string(), "discovered".to_string()),
|
|
]);
|
|
if let Some(phase) = invalidation {
|
|
env.insert("MCP_TEST_INVALIDATE_LISTING".to_string(), phase.to_string());
|
|
}
|
|
insert_mcp_server(
|
|
config,
|
|
"discovery",
|
|
stdio_transport(command, Some(env), Vec::new()),
|
|
TestMcpServerOptions {
|
|
environment_id: remote_aware_environment_id(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
})
|
|
.with_model_info_override("gpt-5.5", move |model| {
|
|
model.tool_mode = Some(tool_mode);
|
|
model.supports_search_tool = false;
|
|
model.use_responses_lite = false;
|
|
})
|
|
.build_with_auto_env(&server)
|
|
.await?;
|
|
wait_for_mcp_server(&test.codex, "discovery").await?;
|
|
let mut mocks = Vec::new();
|
|
for (call_id, tool, arguments) in [
|
|
("discover", "search_tools", r#"{"message":"find echo"}"#),
|
|
("invoke", "echo", r#"{"message":"hello"}"#),
|
|
] {
|
|
if !enabled && tool == "echo" {
|
|
break;
|
|
}
|
|
let call = if tool_mode == ToolMode::CodeModeOnly {
|
|
responses::ev_custom_tool_call(
|
|
call_id,
|
|
"exec",
|
|
&format!("text(await tools.mcp__discovery__{tool}({arguments}));"),
|
|
)
|
|
} else {
|
|
responses::ev_function_call_with_namespace(call_id, "mcp__discovery", tool, arguments)
|
|
};
|
|
mocks.push(
|
|
responses::mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created(call_id),
|
|
call,
|
|
responses::ev_completed(call_id),
|
|
]),
|
|
)
|
|
.await,
|
|
);
|
|
}
|
|
mocks.push(responses::mount_sse_once(&server, responses::sse_completed("done")).await);
|
|
test.submit_turn("Discover the echo tool, then call it with hello.")
|
|
.await?;
|
|
let requests = mocks
|
|
.iter()
|
|
.map(responses::ResponseMock::single_request)
|
|
.collect::<Vec<_>>();
|
|
if tool_mode == ToolMode::Direct {
|
|
assert!(
|
|
requests[0]
|
|
.tool_by_name("mcp__discovery", "search_tools")
|
|
.is_some()
|
|
);
|
|
assert_eq!(
|
|
requests[0].tool_by_name("mcp__discovery", "echo").is_some(),
|
|
invalidation == Some("startup")
|
|
);
|
|
if !enabled {
|
|
assert_eq!(
|
|
requests[1].body_json()["tools"],
|
|
requests[0].body_json()["tools"]
|
|
);
|
|
return Ok(requests);
|
|
}
|
|
assert!(requests[1].tool_by_name("mcp__discovery", "echo").is_some());
|
|
let output = requests[2]
|
|
.function_call_output_text("invoke")
|
|
.expect("echo output");
|
|
let (_, payload) = output
|
|
.split_once("\nOutput:\n")
|
|
.expect("wrapped MCP output");
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(payload)?,
|
|
json!({
|
|
"echo": "ECHOING: hello", "env": "discovered",
|
|
})
|
|
);
|
|
} else {
|
|
assert!(
|
|
requests[2]
|
|
.custom_tool_call_output("invoke")
|
|
.to_string()
|
|
.contains("ECHOING: hello")
|
|
);
|
|
}
|
|
Ok(requests)
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
#[test_case(McpProtocolMode::Legacy; "legacy")]
|
|
#[test_case(McpProtocolMode::V20260728; "modern")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn mcp_pagination_preserves_valid_tools_and_rejects_oversized_cursors(
|
|
protocol_mode: McpProtocolMode,
|
|
) -> 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 = "call-second-page-tool";
|
|
let response = mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_function_call_with_namespace(call_id, "mcp__paginated", "sync", "{}"),
|
|
responses::ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let final_mock = mount_sse_once(&server, responses::sse_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| {
|
|
config
|
|
.features
|
|
.set_enabled(
|
|
Feature::Mcp20260728,
|
|
protocol_mode == McpProtocolMode::V20260728,
|
|
)
|
|
.expect("test config should allow MCP protocol selection");
|
|
for (server_name, pagination) in
|
|
[("paginated", "two-pages"), ("rejected", "oversized-cursor")]
|
|
{
|
|
let mut env = HashMap::from([(
|
|
"MCP_TEST_TOOL_PAGINATION".to_string(),
|
|
pagination.to_string(),
|
|
)]);
|
|
if protocol_mode == McpProtocolMode::V20260728 {
|
|
env.insert(
|
|
"CODEX_MCP_PROTOCOL_VERSION".to_string(),
|
|
"2026-07-28".to_string(),
|
|
);
|
|
}
|
|
insert_mcp_server(
|
|
config,
|
|
server_name,
|
|
stdio_transport(command.clone(), Some(env), 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(auto_approved_user_turn(
|
|
&fixture,
|
|
"call the paginated sync tool",
|
|
))
|
|
.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"
|
|
);
|
|
let output = final_mock.single_request().function_call_output(call_id);
|
|
let output_text = output["output"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("expected successful MCP tool output, got {output}"));
|
|
let output_json: Value = serde_json::from_str(split_wall_time_wrapped_output(output_text))?;
|
|
assert_eq!(output_json, json!({"result": "ok"}));
|
|
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(),
|
|
);
|
|
// Wait for discovery instead of skipping the pending optional server.
|
|
// Its startup timeout exceeds every bounded wait in this test.
|
|
config.mcp_optional_startup_grace = Duration::ZERO;
|
|
let mut servers = config.mcp_servers.get().clone();
|
|
let pending = servers
|
|
.get_mut("shutdown_prewarm")
|
|
.expect("pending MCP server");
|
|
pending.startup_timeout_sec = Some(Duration::from_secs(300));
|
|
config
|
|
.mcp_servers
|
|
.set(servers)
|
|
.expect("valid MCP configuration");
|
|
})
|
|
.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")??;
|
|
assert!(
|
|
server
|
|
.wait_for_connections(/*expected*/ 1, Duration::from_secs(2))
|
|
.await,
|
|
"websocket handshake should overlap pending MCP tool startup"
|
|
);
|
|
assert!(
|
|
server.single_connection().is_empty(),
|
|
"prewarm should wait for tools before sending generate=false"
|
|
);
|
|
tokio::time::timeout(Duration::from_secs(2), fixture.codex.shutdown_and_wait())
|
|
.await
|
|
.context("shutdown should not wait for startup prewarm MCP startup")??;
|
|
// Dropping the thread closes its cached socket. Wait for the server to drain preceding frames.
|
|
drop(fixture);
|
|
assert!(
|
|
server
|
|
.wait_for_closed_connections(/*expected*/ 1, Duration::from_secs(3))
|
|
.await,
|
|
"websocket server should drain pending frames after startup cancellation"
|
|
);
|
|
assert_eq!(
|
|
server.single_connection().len(),
|
|
0,
|
|
"blocked MCP discovery must prevent any startup prewarm request"
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum PrewarmMcpScenario {
|
|
Responses,
|
|
ResponsesLite,
|
|
EagerSocketCloses,
|
|
}
|
|
|
|
#[test_case(PrewarmMcpScenario::Responses; "responses")]
|
|
#[test_case(PrewarmMcpScenario::ResponsesLite; "responses lite")]
|
|
#[test_case(PrewarmMcpScenario::EagerSocketCloses; "prewarm reconnects when the eager socket closes")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn startup_prewarm_connects_before_delayed_mcp_tools_then_reuses_socket(
|
|
scenario: PrewarmMcpScenario,
|
|
) -> anyhow::Result<()> {
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"requires a Windows test_stdio_server in the Wine-exec environment"
|
|
);
|
|
skip_if_no_network!(Ok(()));
|
|
let use_responses_lite = scenario == PrewarmMcpScenario::ResponsesLite;
|
|
let close_eager_socket = scenario == PrewarmMcpScenario::EagerSocketCloses;
|
|
|
|
let warmup_events = vec![
|
|
responses::ev_response_created("warm-1"),
|
|
responses::ev_completed("warm-1"),
|
|
];
|
|
let turn_events = vec![
|
|
responses::ev_response_created("turn-1"),
|
|
responses::ev_assistant_message("message-1", "done"),
|
|
responses::ev_completed("turn-1"),
|
|
];
|
|
let requests = vec![warmup_events, turn_events];
|
|
let connections = if close_eager_socket {
|
|
// A connection without scripted requests closes as soon as it is accepted.
|
|
vec![Vec::new(), requests]
|
|
} else {
|
|
vec![requests]
|
|
};
|
|
let server = responses::start_websocket_server(connections).await;
|
|
let command = stdio_server_bin()?;
|
|
let service_tier = ServiceTier::Fast.request_value();
|
|
let fixture = test_codex()
|
|
.with_model("gpt-5.4")
|
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
|
.with_model_info_override("gpt-5.4", move |model| {
|
|
model.supports_search_tool = false;
|
|
model.use_responses_lite = use_responses_lite;
|
|
if use_responses_lite {
|
|
model.tool_mode = Some(ToolMode::CodeMode);
|
|
}
|
|
model.service_tiers.push(ModelServiceTier {
|
|
id: service_tier.to_string(),
|
|
name: "Fast".to_string(),
|
|
description: "Priority processing".to_string(),
|
|
});
|
|
})
|
|
.with_config(move |config| {
|
|
config
|
|
.features
|
|
.enable(Feature::FastMode)
|
|
.expect("enable Fast Mode for routing hint");
|
|
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
|
config.tool_registry.turn_metadata_includes_tool_info = use_responses_lite;
|
|
config.mcp_optional_startup_grace = Duration::ZERO;
|
|
let barrier_file = config.cwd.join("allow-prewarm-mcp-initialize");
|
|
insert_mcp_server(
|
|
config,
|
|
"delayed_prewarm",
|
|
stdio_transport(
|
|
command,
|
|
Some(HashMap::from([(
|
|
"MCP_TEST_INITIALIZE_BARRIER_FILE".to_string(),
|
|
barrier_file.to_string_lossy().to_string(),
|
|
)])),
|
|
Vec::new(),
|
|
),
|
|
TestMcpServerOptions::default(),
|
|
);
|
|
})
|
|
.build_with_websocket_server(&server)
|
|
.await?;
|
|
|
|
assert!(
|
|
server
|
|
.wait_for_connections(/*expected*/ 1, Duration::from_secs(5))
|
|
.await,
|
|
"websocket handshake should finish while MCP discovery is blocked"
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.single_handshake()
|
|
.header(X_CODEX_ROUTING_HINT_HEADER),
|
|
Some(format!("model=gpt-5.4;tier={service_tier}"))
|
|
);
|
|
assert!(
|
|
server.single_connection().is_empty(),
|
|
"prewarm should wait for delayed MCP discovery"
|
|
);
|
|
if close_eager_socket {
|
|
assert!(
|
|
server
|
|
.wait_for_closed_connections(/*expected*/ 1, Duration::from_secs(5))
|
|
.await,
|
|
"the eager socket must close before MCP discovery is released"
|
|
);
|
|
}
|
|
std::fs::write(
|
|
fixture.workspace_path("allow-prewarm-mcp-initialize"),
|
|
b"ready",
|
|
)?;
|
|
wait_for_mcp_server(&fixture.codex, "delayed_prewarm").await?;
|
|
|
|
let namespace = "mcp__delayed_prewarm";
|
|
let connection_index = usize::from(close_eager_socket);
|
|
let prewarm = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
tokio::select! {
|
|
request = server.wait_for_request(connection_index, /*request_index*/ 0) => break request,
|
|
// The reader may observe the close after the initial warmup tries to send.
|
|
_ = tokio::time::sleep(Duration::from_millis(10)), if close_eager_socket => {
|
|
fixture.codex.prewarm().await;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.context("prewarm should send its request after MCP discovery")?
|
|
.body_json();
|
|
assert_eq!(prewarm["type"].as_str(), Some("response.create"));
|
|
assert_eq!(prewarm["generate"].as_bool(), Some(false));
|
|
assert!(prewarm.get("previous_response_id").is_none());
|
|
assert_eq!(prewarm["service_tier"].as_str(), Some(service_tier));
|
|
let metadata: Value = serde_json::from_str(
|
|
prewarm["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.context("prewarm should include turn metadata")?,
|
|
)?;
|
|
if use_responses_lite {
|
|
assert!(
|
|
metadata["tool_namespaces_info"][namespace]["functions"]["echo"].is_object(),
|
|
"Responses Lite prewarm metadata should reflect discovered MCP tools: {metadata:?}"
|
|
);
|
|
}
|
|
|
|
fixture.submit_text_turn("hello").await?;
|
|
let first_turn = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(connection_index, /*request_index*/ 1),
|
|
)
|
|
.await
|
|
.context("first turn should reuse the prewarmed socket")?
|
|
.body_json();
|
|
assert_eq!(first_turn["type"], "response.create");
|
|
assert!(first_turn.get("generate").is_none());
|
|
assert_eq!(first_turn["previous_response_id"], "warm-1");
|
|
let tool_body = if use_responses_lite {
|
|
first_turn["input"]
|
|
.as_array()
|
|
.and_then(|items| items.iter().find(|item| item["type"] == "additional_tools"))
|
|
.context("first Responses Lite turn should declare discovered tools")?
|
|
} else {
|
|
&prewarm
|
|
};
|
|
assert!(
|
|
responses::namespace_child_tool(tool_body, namespace, "echo").is_some(),
|
|
"startup should declare the discovered MCP echo tool before sampling: {tool_body:?}"
|
|
);
|
|
let handshakes = server.handshakes();
|
|
assert_eq!(handshakes.len(), connection_index + 1);
|
|
if close_eager_socket {
|
|
assert!(server.connections()[0].is_empty());
|
|
assert_eq!(
|
|
handshakes[1].header(X_CODEX_ROUTING_HINT_HEADER),
|
|
handshakes[0].header(X_CODEX_ROUTING_HINT_HEADER)
|
|
);
|
|
}
|
|
|
|
fixture.codex.shutdown_and_wait().await?;
|
|
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_model_info_override("gpt-5.4", |model| {
|
|
model.use_responses_lite = false;
|
|
model.supports_image_detail_original = false;
|
|
})
|
|
.with_config(move |config| {
|
|
config
|
|
.features
|
|
.enable(Feature::StepModelSwitching)
|
|
.expect("enable step model switching");
|
|
config
|
|
.features
|
|
.enable(Feature::UnifiedImageBudget)
|
|
.expect("enable unified image budget");
|
|
let models = &mut config.model_catalog.as_mut().expect("model catalog").models;
|
|
let mut current_model = models
|
|
.iter()
|
|
.find(|model| model.slug == "gpt-5.4")
|
|
.expect("initial model")
|
|
.clone();
|
|
current_model.slug = "startup-image-model".to_string();
|
|
current_model.supports_image_detail_original = true;
|
|
models.push(current_model);
|
|
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";
|
|
let mut input = read_only_user_turn(&fixture, prompt);
|
|
let TurnInput::UserInput { content, .. } = &mut input.input else {
|
|
unreachable!("read_only_user_turn creates user input");
|
|
};
|
|
content.push(UserInput::Image {
|
|
image: ImageReference::Inline {
|
|
image_url: OPENAI_PNG.to_string(),
|
|
},
|
|
detail: Some(ImageDetail::High),
|
|
});
|
|
fixture.codex.start_or_steer_turn(input).await?;
|
|
let turn_id = core_test_support::wait_for_event_match(&fixture.codex, |event| match event {
|
|
EventMsg::TurnStarted(started) => Some(started.turn_id.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let (reply, outcome) = tokio::sync::oneshot::channel();
|
|
fixture
|
|
.codex
|
|
.submit(Op::TurnSettings {
|
|
turn_id,
|
|
update: TurnSettingsUpdate {
|
|
model: Some("startup-image-model".to_string()),
|
|
..Default::default()
|
|
},
|
|
reply,
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), outcome).await??,
|
|
TurnSettingsUpdateOutcome::Applied
|
|
);
|
|
|
|
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");
|
|
assert!(
|
|
history.items[..user_prompt_index]
|
|
.iter()
|
|
.any(|item| matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(applied))
|
|
if applied.thread_id == Some(fixture.session_configured.thread_id)
|
|
))
|
|
);
|
|
let RolloutItem::ResponseItem(envelope) = &history.items[user_prompt_index] else {
|
|
unreachable!("found the user prompt");
|
|
};
|
|
let ResponseItem::Message { content, .. } = &envelope.item else {
|
|
unreachable!("found the user message");
|
|
};
|
|
assert!(
|
|
content.contains(&ContentItem::InputImage {
|
|
image: ImageReference::Inline {
|
|
image_url: OPENAI_PNG.to_string()
|
|
},
|
|
detail: Some(ImageDetail::Original),
|
|
}),
|
|
"interrupted input must use the current model's unified image budget"
|
|
);
|
|
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<()>
|
|
{
|
|
use codex_core::StartThreadOptions;
|
|
use core_test_support::exec_server::ExecServerProcess;
|
|
use core_test_support::test_codex::local;
|
|
|
|
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 additional_workspace = tempdir()?;
|
|
let additional_root = AbsolutePathBuf::try_from(additional_workspace.path().to_path_buf())?;
|
|
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
codex_skills_extension::install(&mut extensions, |config: &Config| {
|
|
codex_skills_extension::SkillsExtensionConfig {
|
|
include_instructions: config.include_skill_instructions,
|
|
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
|
|
omit_descriptions: false,
|
|
max_context_tokens: config.skill_max_context_tokens,
|
|
bundled_skills_enabled: config.bundled_skills_enabled(),
|
|
cloud_skill_enabled: config.cloud_skill_enabled,
|
|
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
|
|
}
|
|
});
|
|
let mut fixture = test_codex()
|
|
.with_extensions(Arc::new(extensions.build()))
|
|
.with_config(move |config| {
|
|
config.workspace_roots = vec![config.cwd.clone(), additional_root.clone()];
|
|
config
|
|
.permissions
|
|
.set_workspace_roots(config.workspace_roots.clone());
|
|
*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");
|
|
codex_utils_cargo_bin::copy_executable(&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);
|
|
|
|
// The relative MCP executable exists only in the host workspace. A remote
|
|
// primary must not change its command resolution at startup or refresh.
|
|
let executor = ExecServerProcess::start().await?;
|
|
let remote_workspace = tempdir()?;
|
|
let remote_skill = remote_workspace
|
|
.path()
|
|
.join(".agents/skills/remote-fixture");
|
|
fs::create_dir_all(&remote_skill)?;
|
|
fs::write(
|
|
remote_skill.join("SKILL.md"),
|
|
"---\nname: remote-fixture\ndescription: Remote repository skill fixture\n---\nRemote instructions\n",
|
|
)?;
|
|
let manager = fixture.thread_manager.environment_manager();
|
|
manager.upsert_environment(
|
|
"other".to_string(),
|
|
executor.websocket_url.clone(),
|
|
/*connect_timeout*/ None,
|
|
)?;
|
|
for local_roots in [
|
|
Some(vec![fixture.config.cwd.clone()]),
|
|
Some(Vec::new()),
|
|
None,
|
|
] {
|
|
let mut environments = vec![TurnEnvironmentRequest {
|
|
environment_id: "other".to_string(),
|
|
cwd: PathUri::from_host_native_path(remote_workspace.path())?,
|
|
workspace_roots: vec![PathUri::from_host_native_path(remote_workspace.path())?],
|
|
config: EnvironmentConfigState::FromThread,
|
|
}];
|
|
if let Some(roots) = &local_roots {
|
|
let mut local = local(fixture.config.cwd.clone()).into_request();
|
|
local.workspace_roots = roots.iter().map(PathUri::from_abs_path).collect();
|
|
environments.push(local);
|
|
}
|
|
let started = fixture
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
environments: Some(environments),
|
|
..StartThreadOptions::new(fixture.config.clone())
|
|
})
|
|
.await?;
|
|
fixture.codex = started.thread;
|
|
fixture.session_configured = started.session_configured;
|
|
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
|
|
|
// A fresh thread must still discover the primary executor's repository
|
|
// skills; host routing must not silently replace them with local skills.
|
|
let request = responses::mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![responses::ev_completed("remote-skills")]),
|
|
)
|
|
.await;
|
|
fixture.submit_turn("list repository skills").await?;
|
|
assert!(
|
|
request
|
|
.single_request()
|
|
.message_input_texts("developer")
|
|
.join("\n")
|
|
.contains("Remote repository skill fixture")
|
|
);
|
|
|
|
let initial = call_cwd_tool(&server, &fixture, server_name, "before-refresh").await?;
|
|
assert_cwd_tool_output(&initial, &expected_cwd);
|
|
fixture.codex.submit(Op::RefreshMcpServers).await?;
|
|
let refreshed = call_cwd_tool(&server, &fixture, server_name, "after-refresh").await?;
|
|
assert_cwd_tool_output(&refreshed, &expected_cwd);
|
|
|
|
// Host MCP policy follows explicit local roots, including an empty list,
|
|
// and falls back to the saved host roots only when local is absent.
|
|
for (phase, updated_roots) in [("initial", None), ("revoked", Some(Vec::new()))] {
|
|
let expected_roots = local_roots
|
|
.as_ref()
|
|
.or(updated_roots.as_ref())
|
|
.unwrap_or(&fixture.config.workspace_roots)
|
|
.clone();
|
|
let call_id = format!("host-sandbox-{phase}");
|
|
mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_function_call_with_namespace(
|
|
&call_id,
|
|
&format!("mcp__{server_name}"),
|
|
"sandbox_meta",
|
|
"{}",
|
|
),
|
|
responses::ev_completed("sandbox-meta"),
|
|
]),
|
|
)
|
|
.await;
|
|
let final_request = mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![responses::ev_completed("sandbox-meta-done")]),
|
|
)
|
|
.await;
|
|
fixture
|
|
.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "inspect host MCP permissions".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
permission_profile: Some(PermissionProfile::workspace_write()),
|
|
runtime_workspace_roots: updated_roots,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&fixture.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
let output = final_request
|
|
.single_request()
|
|
.function_call_output(&call_id);
|
|
let metadata: Value = serde_json::from_str(split_wall_time_wrapped_output(
|
|
output["output"]
|
|
.as_str()
|
|
.context("sandbox metadata output")?,
|
|
))?;
|
|
assert_eq!(
|
|
metadata.get(MCP_SANDBOX_STATE_CAPABILITY),
|
|
Some(&serde_json::to_value(SandboxState {
|
|
permission_profile: PermissionProfile::workspace_write()
|
|
.materialize_project_roots_with_workspace_roots(&expected_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,
|
|
use_mxc: false,
|
|
})?),
|
|
);
|
|
}
|
|
}
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("rmcp", false, false, false, Some("catalog policy"), Some("native catalog policy"), false; "both disabled")]
|
|
#[test_case("rmcp", true, false, false, Some("catalog policy"), Some("native catalog policy"), false; "auto review required")]
|
|
#[test_case("rmcp", false, true, false, Some("catalog policy"), Some("native catalog policy"), false; "disabled")]
|
|
#[test_case("rmcp", true, true, false, Some("catalog policy"), Some("native catalog policy"), false; "both enabled")]
|
|
#[test_case("rmcp", false, false, true, Some("catalog policy"), Some("native catalog policy"), false; "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"), false; "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 "), false; "cua repl raw policy")]
|
|
#[test_case("node_repl", false, false, false, None, None, false; "node repl missing policy")]
|
|
#[test_case("cua_repl", false, false, false, Some(""), Some("native retained"), false; "cua repl empty policy")]
|
|
#[test_case("node_repl", false, false, false, Some(" \r\n\t"), Some("native retained"), false; "node repl blank policy")]
|
|
#[test_case("node_repl", false, false, false, None, Some("native retained"), false; "node repl missing browser policy")]
|
|
#[test_case("cua_repl", false, false, false, Some("browser retained"), None, false; "cua repl missing computer policy")]
|
|
#[test_case("node_repl", false, false, false, Some("browser retained"), Some(""), false; "node repl empty computer policy")]
|
|
#[test_case("cua_repl", false, false, false, Some("browser retained"), Some(" \r\n\t"), false; "cua repl blank computer policy")]
|
|
#[test_case("cua_repl", false, false, false, None, None, true; "mxc backend handoff")]
|
|
#[test_case("cua_repl", false, false, true, None, None, true; "mxc preference does not leak to attachment")]
|
|
#[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>,
|
|
prefer_mxc: bool,
|
|
) -> 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.5")
|
|
.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.5")
|
|
.with_config(move |config| {
|
|
config.prefer_mxc = prefer_mxc;
|
|
// The real CLI is explicitly provided; the test harness is not a CLI.
|
|
config.codex_self_exe = Some(
|
|
AbsolutePathBuf::from_absolute_path_checked(
|
|
cargo_bin("codex").expect("codex binary"),
|
|
)
|
|
.expect("absolute codex binary path")
|
|
.into_path_buf(),
|
|
);
|
|
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(TurnEnvironmentRequests::new(
|
|
fixture.config.cwd.clone(),
|
|
vec![TurnEnvironmentRequest {
|
|
config: EnvironmentConfigState::Pending,
|
|
..selection.clone().into_request()
|
|
}],
|
|
)),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
fixture
|
|
.codex
|
|
.environment_ready(
|
|
&selection,
|
|
EnvironmentConfig {
|
|
environment_hooks: Default::default(),
|
|
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_type: fixture.config.permissions.windows_sandbox_type,
|
|
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_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:?}"
|
|
);
|
|
}
|
|
}
|
|
let expected_state = serde_json::to_value(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,
|
|
use_mxc: prefer_mxc && cfg!(windows) && !attachment_owned_permissions,
|
|
})?;
|
|
let expected_launcher = (remote_aware_environment_id()
|
|
== codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID)
|
|
.then_some(fixture.config.codex_self_exe.as_ref())
|
|
.flatten()
|
|
.map(|executable| json!({"codexExecutable": executable}));
|
|
assert_eq!(
|
|
(
|
|
meta.get(MCP_SANDBOX_STATE_CAPABILITY),
|
|
meta.get(MCP_SANDBOX_STATE_META_CAPABILITY),
|
|
meta.get(MCP_SANDBOX_LAUNCHER_META_KEY),
|
|
),
|
|
(
|
|
Some(&expected_state),
|
|
Some(&expected_state),
|
|
expected_launcher.as_ref()
|
|
),
|
|
);
|
|
|
|
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?;
|
|
|
|
let turn_id = core_test_support::wait_for_event_match(&fixture.codex, |event| match event {
|
|
EventMsg::TurnStarted(started) => Some(started.turn_id.clone()),
|
|
_ => None,
|
|
})
|
|
.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 {
|
|
turn_id: turn_id.clone(),
|
|
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,
|
|
mcp_app_ui: 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.turn_id, turn_id);
|
|
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.5")
|
|
.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,
|
|
available_access_programs: 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,
|
|
supports_reasoning_effort_updates: 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 current_config = fixture.codex.config().await;
|
|
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 {
|
|
let _ = fixture
|
|
.codex
|
|
.refresh_runtime_config(current_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?
|
|
);
|
|
let current_config = fixture.codex.config().await;
|
|
let _ = fixture
|
|
.codex
|
|
.refresh_runtime_config(current_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),
|
|
}
|
|
}
|
|
}
|
|
}
|