From 769c231aa1d69e2482fda2a51444d7f7a2158e3a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Thu, 4 Jun 2026 15:10:24 -0700 Subject: [PATCH] fix(app-server): expose remote MCP servers in plugin read (#26453) ## Why Remote plugin detail responses include MCP server metadata under `release.mcp_servers`, but Codex did not deserialize or propagate that field. As a result, `plugin/read` always returned an empty `mcpServers` list for remote plugins, so the plugin details pane omitted the MCP Servers section even when the remote plugin declares one. This affects uninstalled plugins as well: the remote detail API is the source of truth and returns MCP server keys without requiring a local plugin bundle. ## What changed - Deserialize MCP server entries from remote plugin detail responses. - Normalize their keys into a sorted, deduplicated list on `RemotePluginDetail`. - Return those keys from app-server `plugin/read` instead of hardcoding an empty list. - Add regression coverage proving an uninstalled remote plugin returns its MCP server names. ## Test plan - `just test -p codex-core-plugins` - `just test -p codex-app-server plugin_read` --- .../src/request_processors/plugins.rs | 2 +- .../app-server/tests/suite/v2/plugin_read.rs | 36 +++++++++++++------ codex-rs/core-plugins/src/remote.rs | 17 +++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 233569485..316353244 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -2026,7 +2026,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a08..9e9e84422 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -122,7 +122,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +148,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +220,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +238,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e18..9b34fd293 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,7 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub mcp_servers: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -417,6 +418,13 @@ struct RemotePluginReleaseResponse { interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +956,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +975,7 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + mcp_servers, }) }