Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/app-server/tests/suite/v2/app_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1516,7 +1516,7 @@ async fn start_apps_server_with_delays_and_control_inner(
get(workspace_settings_response),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
.nest_service("/api/codex/ps/mcp", mcp_service);

let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/tests/suite/v2/mcp_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async fn start_resource_apps_mcp_server() -> Result<(String, JoinHandle<()>)> {
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let router = Router::new().nest_service("/api/codex/apps", mcp_service);
let router = Router::new().nest_service("/api/codex/ps/mcp", mcp_service);
let apps_server_handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
.nest_service("/api/codex/ps/mcp", mcp_service);

let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/tests/suite/v2/plugin_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@ async fn start_apps_server(
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
.nest_service("/api/codex/ps/mcp", mcp_service);

let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/tests/suite/v2/plugin_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1914,7 +1914,7 @@ async fn start_apps_server(
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
.nest_service("/api/codex/ps/mcp", mcp_service);

let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/codex-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ pub use mcp::configured_mcp_servers;
pub use mcp::effective_mcp_servers;
pub use mcp::effective_mcp_servers_from_configured;
pub use mcp::host_owned_codex_apps_enabled;
pub use mcp::hosted_plugin_runtime_mcp_server_config;
pub use mcp::tool_plugin_provenance;
pub use mcp::with_codex_apps_mcp;

pub use mcp::McpServerStatusSnapshot;
pub use mcp::McpSnapshotDetail;
Expand Down
83 changes: 37 additions & 46 deletions codex-rs/codex-mcp/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,6 @@ pub struct McpPermissionPromptAutoApproveContext {
pub struct McpConfig {
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
pub chatgpt_base_url: String,
/// Optional path override for the host-owned apps MCP server.
pub apps_mcp_path_override: Option<String>,
/// Optional product SKU forwarded to the host-owned apps MCP server.
pub apps_mcp_product_sku: Option<String>,
/// Codex home directory used for MCP OAuth state and app-tool cache files.
Expand All @@ -128,22 +126,18 @@ pub struct McpConfig {
pub use_legacy_landlock: bool,
/// Whether the app MCP integration is enabled by config.
///
/// ChatGPT auth is checked separately at runtime before the host-owned apps
/// MCP server is added.
/// ChatGPT auth is checked separately before a materialized host-owned Apps
/// server can be used.
pub apps_enabled: bool,
/// Whether to synthesize the legacy host-owned Apps MCP server.
///
/// Hosts that install an MCP extension for this server disable the legacy
/// loader and contribute the server through the normal runtime overlay.
pub legacy_apps_mcp_loader_enabled: bool,
/// Whether model-visible MCP tool namespaces should keep the legacy
/// `mcp__` prefix.
pub prefix_mcp_tool_names: bool,
/// Client-side elicitation capabilities advertised during MCP initialization.
pub client_elicitation_capability: ElicitationCapability,
/// Config-backed MCP servers keyed by server name.
/// Materialized MCP servers keyed by server name.
///
/// Runtime-only additions are merged later by [`effective_mcp_servers`].
/// A host may add compatibility built-ins and extension overlays before
/// calling runtime entry points in this crate.
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
/// Winning plugin owner for plugin-provided MCP servers, keyed by server name.
pub plugin_ids_by_mcp_server_name: HashMap<String, String>,
Expand Down Expand Up @@ -218,32 +212,6 @@ impl ToolPluginProvenance {
}
}

pub fn with_codex_apps_mcp(
mut servers: HashMap<String, EffectiveMcpServer>,
auth: Option<&CodexAuth>,
config: &McpConfig,
) -> HashMap<String, EffectiveMcpServer> {
if !config.legacy_apps_mcp_loader_enabled {
if !host_owned_codex_apps_enabled(config, auth) {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
return servers;
}
if host_owned_codex_apps_enabled(config, auth) {
servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
EffectiveMcpServer::configured(codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
config.apps_mcp_product_sku.as_deref(),
)),
);
} else {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
servers
}

pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
}
Expand All @@ -259,16 +227,23 @@ pub fn effective_mcp_servers(
effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
}

/// Converts a materialized server map to its auth-gated runtime view.
///
/// Compatibility built-ins and extension overlays must already be reflected in
/// `configured_servers`; this function does not synthesize missing servers.
pub fn effective_mcp_servers_from_configured(
configured_servers: HashMap<String, McpServerConfig>,
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let servers = configured_servers
let mut servers = configured_servers
.into_iter()
.map(|(name, server)| (name, EffectiveMcpServer::configured(server)))
.collect::<HashMap<_, _>>();
with_codex_apps_mcp(servers, auth, config)
if !host_owned_codex_apps_enabled(config, auth) {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
servers
}

pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
Expand Down Expand Up @@ -436,7 +411,7 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String {
base_url
}

fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String {
fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
let base_url = normalize_codex_apps_base_url(base_url);
let (base_url, default_path) = if base_url.contains("/backend-api") {
(base_url, "wham/apps")
Expand All @@ -445,18 +420,34 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio
} else {
(format!("{base_url}/api/codex"), "apps")
};
let path = apps_mcp_path_override
.unwrap_or(default_path)
.trim_start_matches('/');
format!("{base_url}/{path}")
format!("{base_url}/{default_path}")
}

pub fn codex_apps_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_path_override: Option<&str>,
apps_mcp_product_sku: Option<&str>,
) -> McpServerConfig {
let url = codex_apps_mcp_url_for_base_url(chatgpt_base_url, apps_mcp_path_override);
mcp_server_config_for_url(
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
apps_mcp_product_sku,
)
}

/// Builds the ChatGPT-hosted plugin runtime served by plugin-service.
pub fn hosted_plugin_runtime_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_product_sku: Option<&str>,
) -> McpServerConfig {
let base_url = normalize_codex_apps_base_url(chatgpt_base_url);
let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
base_url
} else {
format!("{base_url}/api/codex")
};
mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)
}

fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig {
let http_headers = apps_mcp_product_sku.map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
});
Expand Down
83 changes: 15 additions & 68 deletions codex-rs/codex-mcp/src/mcp/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
chatgpt_base_url: "https://chatgpt.com".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
codex_home,
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(),
Expand All @@ -27,7 +26,6 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
apps_enabled: false,
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
Expand Down Expand Up @@ -178,52 +176,27 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
#[test]
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chatgpt.com/backend-api",
/*apps_mcp_path_override*/ None,
),
codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"),
"https://chatgpt.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chat.openai.com",
/*apps_mcp_path_override*/ None,
),
codex_apps_mcp_url_for_base_url("https://chat.openai.com"),
"https://chat.openai.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080/api/codex",
/*apps_mcp_path_override*/ None,
),
codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"),
"http://localhost:8080/api/codex/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080",
/*apps_mcp_path_override*/ None,
),
codex_apps_mcp_url_for_base_url("http://localhost:8080"),
"http://localhost:8080/api/codex/apps"
);
}

#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();

let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config);
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));

config.apps_enabled = true;

servers = with_codex_apps_mcp(servers, Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
let config =
codex_apps_mcp_server_config("https://chatgpt.com", /*apps_mcp_product_sku*/ None);
let url = match &config.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url,
_ => panic!("expected streamable http transport for codex apps"),
Expand All @@ -232,42 +205,9 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
}

#[test]
fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();

let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
let url = match &config.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url,
_ => panic!("expected streamable http transport for codex apps"),
};

assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp");
}

#[test]
fn codex_apps_server_config_forwards_configured_product_sku_header() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_mcp_product_sku = Some("tpp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();

let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
let config = codex_apps_mcp_server_config("https://chatgpt.com", Some("tpp"));

match &config.transport {
McpServerTransportConfig::StreamableHttp {
Expand All @@ -289,7 +229,7 @@ fn codex_apps_server_config_forwards_configured_product_sku_header() {
}

#[tokio::test]
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
async fn effective_mcp_servers_preserve_runtime_servers() {
let codex_home = tempfile::tempdir().expect("tempdir");
let mut config = test_mcp_config(codex_home.path().to_path_buf());
config.apps_enabled = true;
Expand Down Expand Up @@ -345,6 +285,13 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_product_sku.as_deref(),
),
);

let effective = effective_mcp_servers(&config, Some(&auth));

Expand Down
29 changes: 26 additions & 3 deletions codex-rs/config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use schemars::schema::ObjectValidation;
use schemars::schema::RootSchema;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use schemars::schema::SubschemaValidation;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
Expand Down Expand Up @@ -46,9 +47,7 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
if feature.id == codex_features::Feature::AppsMcpPathOverride {
validation.properties.insert(
feature.key.to_string(),
schema_gen.subschema_for::<codex_features::FeatureToml<
codex_features::AppsMcpPathOverrideConfigToml,
>>(),
removed_apps_mcp_path_override_schema(schema_gen),
);
continue;
}
Expand Down Expand Up @@ -76,6 +75,30 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(object)
}

fn removed_apps_mcp_path_override_schema(schema_gen: &mut SchemaGenerator) -> Schema {
let mut config_validation = ObjectValidation::default();
config_validation
.properties
.insert("enabled".to_string(), schema_gen.subschema_for::<bool>());
config_validation
.properties
.insert("path".to_string(), schema_gen.subschema_for::<String>());
config_validation.additional_properties = Some(Box::new(Schema::Bool(false)));

let config = Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Object.into()),
object: Some(Box::new(config_validation)),
..Default::default()
});
Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
any_of: Some(vec![schema_gen.subschema_for::<bool>(), config]),
..Default::default()
})),
..Default::default()
})
}

/// Schema for the `[mcp_servers]` map using the raw input shape.
pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
let mut object = SchemaObject {
Expand Down
Loading
Loading