Enrich app/read connector metadata (#34417)

## What changed

- Add `iconUrlDark`, `distributionChannel`, `installUrl`, and
  `pluginDisplayNames` to the experimental `app/read` connector metadata.
- Populate plugin display names from enabled plugins without starting their MCP
  servers.
- Accept both dark-icon field spellings from the batch API and keep the new
  upstream fields optional for compatibility with older responses.

## Testing

- Cover the serialized response fields, legacy batch responses, and plugin
  display-name lookup without MCP startup.

GitOrigin-RevId: 68601a104de839c0f4f4621d33b0e4d08ad88c64
This commit is contained in:
Matthew Zeng
2026-07-20 22:06:50 +00:00
committed by copyberry
parent cf821e8ec8
commit 60272096bc
11 changed files with 336 additions and 22 deletions
@@ -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"
@@ -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"
@@ -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"
@@ -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<AppToolSummary> | 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<string>, toolSummaries: Array<AppToolSummary> | null, };
@@ -202,6 +202,11 @@ pub struct ConnectorMetadata {
pub name: String,
pub description: Option<String>,
pub icon_url: Option<String>,
pub icon_url_dark: Option<String>,
pub distribution_channel: Option<String>,
pub install_url: Option<String>,
#[serde(default)]
pub plugin_display_names: Vec<String>,
pub tool_summaries: Option<Vec<AppToolSummary>>,
}
+9 -1
View File
@@ -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()
@@ -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(),
+186 -17
View File
@@ -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<AppsReadResponse> {
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<Value> {
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<StdMutex<Vec<Value>>>,
mcp_requests: Arc<StdMutex<usize>>,
response: Arc<StdMutex<Value>>,
status: Arc<StdMutex<StatusCode>>,
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<BatchServerState>) -> 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<BatchServerState>,
headers: HeaderMap,
+34
View File
@@ -233,6 +233,10 @@ struct BatchApp {
name: String,
description: Option<String>,
icon_url: Option<String>,
#[serde(default, rename = "icon_dark_url", alias = "icon_url_dark")]
icon_url_dark: Option<String>,
#[serde(default)]
distribution_channel: Option<String>,
#[serde(default)]
tools: Option<Vec<BatchAppToolSummary>>,
}
@@ -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::<BatchApp>(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 {
+4 -2
View File
@@ -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<String>,
pub icon_url: Option<String>,
pub icon_url_dark: Option<String>,
pub distribution_channel: Option<String>,
pub tool_summaries: Option<Vec<ConnectorToolSummary>>,
}
@@ -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,
}
}