diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b1981a4cf28b..78348e6a4323 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8890,18 +8890,43 @@ "null" ] }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, "iconUrl": { "type": [ "string", "null" ] }, + "iconUrlDark": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "toolSummaries": { "items": { "$ref": "#/definitions/v2/AppToolSummary" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index cb53e921bbb6..e0cfbc1d9f9e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5068,18 +5068,43 @@ "null" ] }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, "iconUrl": { "type": [ "string", "null" ] }, + "iconUrlDark": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "toolSummaries": { "items": { "$ref": "#/definitions/AppToolSummary" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json index 8ab4ca7747eb..c4c7d48fa7b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json @@ -32,18 +32,43 @@ "null" ] }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, "iconUrl": { "type": [ "string", "null" ] }, + "iconUrlDark": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "toolSummaries": { "items": { "$ref": "#/definitions/AppToolSummary" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts index e62374802a7e..54c18a780086 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts @@ -6,4 +6,4 @@ import type { AppToolSummary } from "./AppToolSummary"; /** * EXPERIMENTAL - metadata returned by app/read. */ -export type ConnectorMetadata = { id: string, name: string, description: string | null, iconUrl: string | null, toolSummaries: Array | null, }; +export type ConnectorMetadata = { id: string, name: string, description: string | null, iconUrl: string | null, iconUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, pluginDisplayNames: Array, toolSummaries: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs index f9b0b3247038..e806ffd7430b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -202,6 +202,11 @@ pub struct ConnectorMetadata { pub name: String, pub description: Option, pub icon_url: Option, + pub icon_url_dark: Option, + pub distribution_channel: Option, + pub install_url: Option, + #[serde(default)] + pub plugin_display_names: Vec, pub tool_summaries: Option>, } diff --git a/codex-rs/app-server/src/app_info.rs b/codex-rs/app-server/src/app_info.rs index c86b4946637b..0360e2175387 100644 --- a/codex-rs/app-server/src/app_info.rs +++ b/codex-rs/app-server/src/app_info.rs @@ -12,6 +12,7 @@ use codex_connectors::AppReview; use codex_connectors::AppScreenshot; use codex_connectors::ConnectorMetadata; use codex_connectors::ConnectorToolSummary; +use codex_connectors::metadata::connector_install_url; /// Converts connector-domain app metadata owned by `codex-connectors` into the app-server wire /// type owned by `codex-app-server-protocol`. @@ -59,20 +60,27 @@ pub(crate) fn app_info_to_api(app: AppInfo) -> ApiAppInfo { /// Converts metadata-only connector data into the app-server wire type. /// /// Keeping this separate from app_info_to_api makes it impossible for app/read to accidentally -/// grow runtime state from the broader app/list shape. +/// expose full runtime tool state from the broader app/list path. pub(crate) fn connector_metadata_to_api(metadata: ConnectorMetadata) -> ApiConnectorMetadata { let ConnectorMetadata { id, name, description, icon_url, + icon_url_dark, + distribution_channel, tool_summaries, } = metadata; + let install_url = Some(connector_install_url(&name, &id)); ApiConnectorMetadata { id, name, description, icon_url, + icon_url_dark, + distribution_channel, + install_url, + plugin_display_names: Vec::new(), tool_summaries: tool_summaries.map(|tools| { tools .into_iter() diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 22aeed2ecd40..74b7db3a2f82 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -81,9 +81,28 @@ impl AppsRequestProcessor { } = connectors::read_connector_metadata(&config, auth, &app_ids, include_tools) .await .map_err(|err| internal_error(format!("failed to read app metadata: {err}")))?; + let loaded_plugins = self + .thread_manager + .plugins_manager() + .plugins_for_config(&config.plugins_config_input()) + .await; + let connector_snapshot = + codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries( + loaded_plugins.capability_summaries(), + ); + let apps = apps + .into_iter() + .map(|metadata| { + let mut app = connector_metadata_to_api(metadata); + app.plugin_display_names = connector_snapshot + .plugin_display_names_for_connector_id(app.id.as_str()) + .to_vec(); + app + }) + .collect(); Ok(Some( AppsReadResponse { - apps: apps.into_iter().map(connector_metadata_to_api).collect(), + apps, missing_app_ids, } .into(), diff --git a/codex-rs/app-server/tests/suite/v2/app_read.rs b/codex-rs/app-server/tests/suite/v2/app_read.rs index a359789d64ce..53e0b8a9f623 100644 --- a/codex-rs/app-server/tests/suite/v2/app_read.rs +++ b/codex-rs/app-server/tests/suite/v2/app_read.rs @@ -16,8 +16,8 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; +use axum::routing::any; use axum::routing::post; -use codex_app_server_protocol::AppToolSummary; use codex_app_server_protocol::AppsReadParams; use codex_app_server_protocol::AppsReadResponse; use codex_app_server_protocol::ConnectorMetadata; @@ -44,11 +44,22 @@ async fn app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata( .plan_type("plus") .chatgpt_account_id("account-123"), )?; + let mut beta_response = app_response( + "beta", + "Beta", + Some("https://files.openai.com/content?id=beta"), + ); + let beta_icon_dark_url = beta_response + .as_object_mut() + .expect("app response is an object") + .remove("icon_dark_url") + .expect("app response contains icon_dark_url"); + beta_response["icon_url_dark"] = beta_icon_dark_url; let state = BatchServerState::new( json!({ "apps": [ app_response("alpha", "Alpha", Some("https://files.openai.com/content?id=alpha")), - app_response("beta", "Beta", Some("https://files.openai.com/content?id=beta")), + beta_response, ] }), &access_token, @@ -81,12 +92,23 @@ async fn app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata( LoginAccountResponse::ChatgptAuthTokens {} ); - let response = read_apps( + let raw_response = read_apps_raw( &mut mcp, vec!["beta", "missing", "alpha", "beta", "forbidden"], /*include_tools*/ true, ) .await?; + assert_eq!( + raw_response, + json!({ + "apps": [ + metadata_json("beta", "Beta", Some("https://files.openai.com/content?id=beta")), + metadata_json("alpha", "Alpha", Some("https://files.openai.com/content?id=alpha")), + ], + "missingAppIds": ["missing", "forbidden"], + }) + ); + let response: AppsReadResponse = serde_json::from_value(raw_response)?; assert_eq!( response, AppsReadResponse { @@ -290,6 +312,89 @@ async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> Ok(()) } +#[tokio::test] +async fn app_read_adds_plugin_display_names_without_starting_mcp() -> Result<()> { + let state = BatchServerState::new( + json!({ + "apps": [ + app_response("alpha", "Alpha", /*icon_url*/ None), + app_response("unclaimed", "Unclaimed", /*icon_url*/ None), + ] + }), + "chatgpt-token", + "codex", + ); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{server_url}" + +[features] +connectors = true +plugins = true + +[plugins."alpha-z@test"] +enabled = true + +[plugins."alpha-a@test"] +enabled = true + +[plugins."disabled@test"] +enabled = false +"#, + ), + )?; + write_plugin_app(codex_home.path(), "alpha-z", "Alpha Z", "alpha")?; + write_plugin_app(codex_home.path(), "alpha-a", "Alpha A", "alpha")?; + write_plugin_app( + codex_home.path(), + "disabled", + "Disabled Plugin", + "unclaimed", + )?; + write_auth(codex_home.path())?; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let response = read_apps( + &mut mcp, + vec!["alpha", "unclaimed"], + /*include_tools*/ false, + ) + .await?; + let mut alpha = metadata_without_tools("alpha", "Alpha", /*icon_url*/ None); + alpha.plugin_display_names = vec!["Alpha A".to_string(), "Alpha Z".to_string()]; + assert_eq!( + response, + AppsReadResponse { + apps: vec![ + alpha, + metadata_without_tools("unclaimed", "Unclaimed", /*icon_url*/ None), + ], + missing_app_ids: Vec::new(), + } + ); + assert_eq!( + state.requests(), + vec![json!({ + "app_ids": ["alpha", "unclaimed"], + "include_tools": false, + })] + ); + assert_eq!(state.mcp_requests(), 0); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn app_read_rejects_more_than_one_hundred_input_ids() -> Result<()> { let codex_home = TempDir::new()?; @@ -321,6 +426,16 @@ async fn read_apps( app_ids: Vec<&str>, include_tools: bool, ) -> Result { + Ok(serde_json::from_value( + read_apps_raw(mcp, app_ids, include_tools).await?, + )?) +} + +async fn read_apps_raw( + mcp: &mut TestAppServer, + app_ids: Vec<&str>, + include_tools: bool, +) -> Result { let request_id = mcp .send_apps_read_request(AppsReadParams { app_ids: app_ids.into_iter().map(str::to_string).collect(), @@ -332,21 +447,29 @@ async fn read_apps( mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - to_response(response) + Ok(response.result) } fn metadata(id: &str, name: &str, icon_url: Option<&str>) -> ConnectorMetadata { - ConnectorMetadata { - id: id.to_string(), - name: name.to_string(), - description: Some(format!("{name} description")), - icon_url: icon_url.map(str::to_string), - tool_summaries: Some(vec![AppToolSummary { - name: format!("{id}_tool"), - title: Some(format!("{name} Tool")), - description: format!("Use {name}"), - }]), - } + serde_json::from_value(metadata_json(id, name, icon_url)).expect("valid app metadata JSON") +} + +fn metadata_json(id: &str, name: &str, icon_url: Option<&str>) -> Value { + json!({ + "id": id, + "name": name, + "description": format!("{name} description"), + "iconUrl": icon_url, + "iconUrlDark": format!("https://files.openai.com/content?id={id}-dark"), + "distributionChannel": "ECOSYSTEM_DIRECTORY", + "installUrl": format!("https://chatgpt.com/apps/{}/{id}", name.to_ascii_lowercase()), + "pluginDisplayNames": [], + "toolSummaries": [{ + "name": format!("{id}_tool"), + "title": format!("{name} Tool"), + "description": format!("Use {name}"), + }], + }) } fn metadata_without_tools(id: &str, name: &str, icon_url: Option<&str>) -> ConnectorMetadata { @@ -362,12 +485,13 @@ fn app_response(id: &str, name: &str, icon_url: Option<&str>) -> Value { "name": name, "description": format!("{name} description"), "icon_url": null, + "icon_dark_url": format!("https://files.openai.com/content?id={id}-dark"), + "distribution_channel": "ECOSYSTEM_DIRECTORY", "tools": [{ "name": format!("{id}_tool"), "title": format!("{name} Tool"), "description": format!("Use {name}"), }], - "distribution_channel": "ECOSYSTEM_DIRECTORY", "branding": { "category": "PRODUCTIVITY", "developer": "Test Developer", @@ -391,7 +515,7 @@ fn app_response(id: &str, name: &str, icon_url: Option<&str>) -> Value { "version": "1.0.0", "version_id": "version-1", "version_notes": "Initial release", - "first_party_type": "test", + "first_party_type": "must-not-escape", "first_party_requires_install": true, "show_in_composer_when_unlinked": true, "subtitle": "must-not-escape", @@ -408,6 +532,33 @@ fn app_response(id: &str, name: &str, icon_url: Option<&str>) -> Value { response } +fn write_plugin_app( + codex_home: &Path, + plugin_name: &str, + display_name: &str, + connector_id: &str, +) -> Result<()> { + let plugin_root = codex_home + .join("plugins/cache/test") + .join(plugin_name) + .join("local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + serde_json::to_vec(&json!({ + "name": plugin_name, + "interface": { "displayName": display_name }, + }))?, + )?; + std::fs::write( + plugin_root.join(".app.json"), + serde_json::to_vec(&json!({ + "apps": { "app": { "id": connector_id } } + }))?, + )?; + Ok(()) +} + fn write_apps_config( codex_home: &Path, base_url: &str, @@ -445,6 +596,7 @@ fn write_auth(codex_home: &Path) -> Result<()> { #[derive(Clone)] struct BatchServerState { requests: Arc>>, + mcp_requests: Arc>, response: Arc>, status: Arc>, access_token: String, @@ -455,6 +607,7 @@ impl BatchServerState { fn new(response: Value, access_token: &str, expected_product_sku: &str) -> Self { Self { requests: Arc::new(StdMutex::new(Vec::new())), + mcp_requests: Arc::new(StdMutex::new(0)), response: Arc::new(StdMutex::new(response)), status: Arc::new(StdMutex::new(StatusCode::OK)), access_token: access_token.to_string(), @@ -475,6 +628,13 @@ impl BatchServerState { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = status; } + + fn mcp_requests(&self) -> usize { + *self + .mcp_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } } async fn start_batch_server(state: BatchServerState) -> Result<(String, JoinHandle<()>)> { @@ -482,6 +642,7 @@ async fn start_batch_server(state: BatchServerState) -> Result<(String, JoinHand let addr = listener.local_addr()?; let router = Router::new() .route("/ps/apps/batch", post(batch_apps)) + .route("/api/codex/ps/mcp", any(unexpected_mcp_request)) .with_state(state); let handle = tokio::spawn(async move { let _ = axum::serve(listener, router).await; @@ -489,6 +650,14 @@ async fn start_batch_server(state: BatchServerState) -> Result<(String, JoinHand Ok((format!("http://{addr}"), handle)) } +async fn unexpected_mcp_request(State(state): State) -> StatusCode { + *state + .mcp_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) += 1; + StatusCode::INTERNAL_SERVER_ERROR +} + async fn batch_apps( State(state): State, headers: HeaderMap, diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 25529c66f215..5d111b9e8045 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -233,6 +233,10 @@ struct BatchApp { name: String, description: Option, icon_url: Option, + #[serde(default, rename = "icon_dark_url", alias = "icon_url_dark")] + icon_url_dark: Option, + #[serde(default)] + distribution_channel: Option, #[serde(default)] tools: Option>, } @@ -250,6 +254,8 @@ fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata { name, description, icon_url, + icon_url_dark, + distribution_channel, tools, } = app; ConnectorMetadata { @@ -257,6 +263,8 @@ fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata { name, description, icon_url, + icon_url_dark, + distribution_channel, tool_summaries: tools.map(|tools| { tools .into_iter() @@ -351,6 +359,32 @@ mod tests { use codex_connectors::metadata::connector_install_url; use codex_plugin::AppConnectorId; use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn batch_app_accepts_missing_optional_metadata() { + let app = serde_json::from_value::(json!({ + "id": "alpha", + "name": "Alpha", + "description": "Alpha description", + "icon_url": null, + "tools": null, + })) + .expect("valid legacy batch app"); + + assert_eq!( + batch_app_to_metadata(app), + ConnectorMetadata { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha description".to_string()), + icon_url: None, + icon_url_dark: None, + distribution_channel: None, + tool_summaries: None, + } + ); + } fn app(id: &str) -> AppInfo { AppInfo { diff --git a/codex-rs/connectors/src/metadata_store.rs b/codex-rs/connectors/src/metadata_store.rs index 354eb7b3ad3b..af3fc827d06b 100644 --- a/codex-rs/connectors/src/metadata_store.rs +++ b/codex-rs/connectors/src/metadata_store.rs @@ -16,14 +16,16 @@ pub struct ConnectorToolSummary { /// Metadata returned by the app batch-read API. /// /// This intentionally excludes connector runtime state, full actions, and model descriptions. -/// Tool summaries contain display text only, and the icon URL is already projected as a public -/// URL by the backend. +/// Tool summaries contain display text only, and icon URLs are already projected as public URLs by +/// the backend. #[derive(Debug, Clone, PartialEq)] pub struct ConnectorMetadata { pub id: String, pub name: String, pub description: Option, pub icon_url: Option, + pub icon_url_dark: Option, + pub distribution_channel: Option, pub tool_summaries: Option>, } diff --git a/codex-rs/connectors/src/metadata_store_tests.rs b/codex-rs/connectors/src/metadata_store_tests.rs index fdc425b7b1cc..d8c33d4574ce 100644 --- a/codex-rs/connectors/src/metadata_store_tests.rs +++ b/codex-rs/connectors/src/metadata_store_tests.rs @@ -10,6 +10,8 @@ fn metadata(id: &str) -> ConnectorMetadata { name: format!("{id} name"), description: None, icon_url: None, + icon_url_dark: None, + distribution_channel: None, tool_summaries: None, } }