Use plugin-service MCP as the hosted plugin runtime (#27198)

## Stack

- Base: #27191
- This PR is the third vertical and should be reviewed against
`jif/external-plugins-2`, not `main`.

## Why

#27191 moves the host-owned Apps MCP registration behind an extension
contributor, but deliberately preserves the existing endpoint-selection
feature while that contribution contract lands. App-server can therefore
resolve the server through extensions, yet the hosted plugin endpoint is
still selected through temporary `apps_mcp_path_override` plumbing.

That is not the long-term plugin model. A plugin can bundle skills,
connectors, MCP servers, and hooks, and those components do not all need
the same source or execution environment. In particular, an
authenticated HTTP MCP server can expose plugin capabilities directly
from a backend without an executor or an orchestrator filesystem.

This PR completes that hosted vertical. App-server's MCP extension now
owns the aggregate hosted plugin runtime at `/ps/mcp`. Connector actions
continue to arrive as MCP tools, while backend-provided skills arrive as
MCP resources and use Codex's existing resource list/read paths. No
second backend client, skill filesystem, or generic plugin activation
framework is introduced.

The backend route remains the hosted implementation. This change
replaces Codex's temporary endpoint-selection mechanism, not the service
behind the endpoint.

## What changed

### Hosted plugin runtime

The MCP extension now contributes `codex_apps` as the hosted plugin
runtime rather than as a configurable Apps endpoint:

- `https://chatgpt.com` resolves to
`https://chatgpt.com/backend-api/ps/mcp`;
- a bare custom ChatGPT base resolves to `/api/codex/ps/mcp`;
- the existing product-SKU header and ChatGPT authentication behavior
are preserved;
- executor availability is never consulted for this streamable HTTP
transport.

The same MCP connection carries both component shapes supported by the
hosted endpoint:

- connector actions are discovered and invoked as MCP tools;
- hosted skills are enumerated and read as MCP resources through the
existing `list_mcp_resources` and `read_mcp_resource` paths.

This keeps component access in the subsystem that already owns the
protocol instead of downloading backend skills into an orchestrator
filesystem or inventing a parallel hosted-skill client.

### Explicit runtime ordering

`McpManager` now resolves the reserved `codex_apps` entry in three
ordered phases:

1. install the legacy Apps fallback for compatibility;
2. apply ordered extension `Set` or `Remove` overlays;
3. apply the final ChatGPT-auth gate without synthesizing the server
again.

This ordering is important:

- an ordinary configured or plugin MCP server cannot claim the
auth-bearing `codex_apps` name;
- an extension-contributed hosted runtime wins over the fallback;
- an extension `Remove` remains authoritative;
- a host without the MCP extension retains the legacy Apps endpoint and
current local-only behavior.

The temporary `legacy_apps_mcp_loader_enabled` coordination flag is no
longer needed.

### Remove the path override

The `apps_mcp_path_override` feature and its runtime plumbing are
removed, including:

- the feature registry entry and structured feature config;
- `Config` and `McpConfig` fields;
- config schema output;
- config-lock materialization;
- URL override handling in `codex-mcp`.

Existing boolean and structured forms still deserialize as ignored
compatibility input. They are omitted from new serialized config, and
config-lock comparison normalizes the removed input so older locks
remain replayable.

### App-server coverage

App-server MCP fixtures now serve the hosted route at
`/api/codex/ps/mcp`. Existing resource-read and tool/elicitation flows
therefore exercise the extension-owned endpoint rather than succeeding
through the legacy fallback.

The stack also adds the missing `codex_chatgpt::connectors` re-export
for the manager-backed connector helper introduced in #27191.

## Compatibility

- App-server installs the extension and uses `/ps/mcp` for the hosted
runtime.
- CLI and other hosts that do not install the extension retain the
legacy Apps endpoint.
- Apps disabled or non-ChatGPT authentication removes `codex_apps` from
the effective runtime view.
- Existing local plugins, local skills, executor-selected skills,
configured MCP servers, and MCP OAuth behavior are otherwise unchanged.
- Backend plugin enablement remains account/workspace state owned by the
hosted endpoint; this PR does not add thread-local backend plugin
selection.

## Architectural fit

The stack now proves two independent runtime shapes:

1. #27184 resolves filesystem-backed skills through the executor that
owns a selected root.
2. #27191 and this PR resolve a backend-hosted HTTP MCP through an
extension with no executor.

Together they preserve the intended separation:

- selection identifies a plugin/root when explicit selection is needed;
- each component's owning extension resolves its concrete access
mechanism;
- execution stays with the runtime required by that component;
- existing skills, MCP, connector, and hook subsystems remain the
downstream consumers.

## Planned follow-ups

1. **Executor stdio MCP:** selecting an executor plugin registers a
manifest-declared stdio MCP server and executes it in the environment
that owns the plugin.
2. **Optional backend selection:** only if CCA needs thread-local
selection distinct from backend account/workspace enablement, add a
concrete backend-owned capability location and surface those selected
skills through the skills catalog.
3. **Connector metadata and hooks:** activate those plugin components
through their existing owning subsystems, with executor hooks remaining
environment-bound.
4. **Propagation and persistence:** define explicit resume, fork,
subagent, refresh, and environment-removal semantics once selected roots
have multiple real consumers.
5. **Local convergence:** migrate legacy local skill, MCP, connector,
and hook paths behind their owning extensions one vertical at a time,
then remove duplicate core managers and compatibility plumbing after
parity.

## Verification

Coverage in this change exercises:

- extension-owned `/backend-api/ps/mcp` registration without an
executor;
- preservation of the legacy endpoint in hosts without the extension;
- extension `Set` and `Remove` precedence over the legacy fallback;
- ChatGPT-auth gating for the reserved server;
- hosted MCP resource reads with and without an active thread;
- connector tool invocation and MCP elicitation through the hosted
route;
- ignored boolean and structured forms of the removed path override;
- config-lock replay compatibility for the removed feature.

`cargo check -p codex-features -p codex-mcp-extension -p
codex-app-server` passes. Tests and Clippy were not run locally under
the current development instruction; CI provides the full validation
pass.
This commit is contained in:
jif
2026-06-10 12:54:21 +02:00
committed by GitHub
Unverified
parent 0ffcefaf3d
commit 9cd11e9e62
27 changed files with 334 additions and 325 deletions
-83
View File
@@ -5500,14 +5500,9 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_mcp_product_sku = Some("tpp".to_string());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
assert_eq!(
mcp_config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
let _ = config.features.disable(Feature::Apps);
@@ -8862,84 +8857,6 @@ allow_login_shell = false
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
Ok(())
}
#[tokio::test]
async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features]
apps_mcp_path_override = true
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp"));
Ok(())
}
#[tokio::test]
async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
enabled = true
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
-24
View File
@@ -58,7 +58,6 @@ use codex_config::types::WindowsSandboxModeToml;
use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::CodeModeConfigToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
@@ -931,9 +930,6 @@ pub struct Config {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
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>,
@@ -1415,7 +1411,6 @@ impl Config {
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
codex_home: self.codex_home.to_path_buf(),
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
@@ -1428,7 +1423,6 @@ impl Config {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
@@ -2413,15 +2407,6 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
}
}
fn apps_mcp_path_override_toml_config(
features: Option<&FeaturesToml>,
) -> Option<&AppsMcpPathOverrideConfigToml> {
match features?.apps_mcp_path_override.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> {
match features?.network_proxy.as_ref()? {
FeatureToml::Enabled(_) => None,
@@ -3043,14 +3028,6 @@ impl Config {
resolve_experimental_request_user_input_enabled(&cfg);
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
base.and_then(|config| config.path.as_ref())
.cloned()
.or_else(|| Some("/ps/mcp".to_string()))
} else {
None
};
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
let agent_roles =
@@ -3553,7 +3530,6 @@ impl Config {
chatgpt_base_url: cfg
.chatgpt_base_url
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
apps_mcp_path_override,
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
realtime_audio: cfg
.audio
+3
View File
@@ -125,6 +125,9 @@ fn config_lock_for_comparison(
) -> ConfigLockfileToml {
let mut lockfile = lockfile.clone();
clear_config_lock_debug_controls(&mut lockfile.config);
if let Some(features) = lockfile.config.features.as_mut() {
features.clear_removed_compatibility_entries();
}
if options.allow_codex_version_mismatch {
lockfile.codex_version.clear();
}
+16 -7
View File
@@ -11,6 +11,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_mcp_server_config;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
@@ -40,16 +41,24 @@ impl McpManager {
}
}
/// Returns the MCP config after applying runtime-only extension overlays.
/// Returns the MCP config after applying compatibility built-ins and
/// runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
let mut mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let contributions = self.contributions(config).await;
if contributions
.iter()
.any(|contribution| contribution.name() == CODEX_APPS_MCP_SERVER_NAME)
{
mcp_config.legacy_apps_mcp_loader_enabled = false;
if mcp_config.apps_enabled {
mcp_config.configured_mcp_servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&mcp_config.chatgpt_base_url,
mcp_config.apps_mcp_product_sku.as_deref(),
),
);
} else {
mcp_config
.configured_mcp_servers
.remove(CODEX_APPS_MCP_SERVER_NAME);
}
let contributions = self.contributions(config).await;
Self::apply_to_configured_servers(&contributions, &mut mcp_config.configured_mcp_servers);
mcp_config
}
+26 -5
View File
@@ -2,7 +2,6 @@ use anyhow::Context;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
@@ -149,10 +148,6 @@ fn save_config_resolved_fields(
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
@@ -325,6 +320,32 @@ mod tests {
assert!(message.contains("model = "), "{message}");
}
#[tokio::test]
async fn lock_validation_ignores_removed_apps_mcp_path_override() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
let mut expected_value = toml::Value::try_from(&actual).expect("lock should become TOML");
expected_value["config"]["features"]
.as_table_mut()
.expect("features should be a table")
.insert(
"apps_mcp_path_override".to_string(),
toml::Value::Table(toml::Table::from_iter([
("enabled".to_string(), toml::Value::Boolean(true)),
(
"path".to_string(),
toml::Value::String("/custom/mcp".to_string()),
),
])),
);
let expected: ConfigLockfileToml = expected_value
.try_into()
.expect("lock with removed input should deserialize");
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
.expect("removed compatibility input should not cause lock drift");
}
#[tokio::test]
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
@@ -7,6 +7,8 @@ use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::protocol::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::ReadResourceResult;
@@ -270,7 +272,10 @@ fn normalize_required_string(field: &str, value: String) -> Result<String, Funct
}
}
fn serialize_function_output<T>(payload: T) -> Result<FunctionToolOutput, FunctionCallError>
fn serialize_function_output<T>(
payload: T,
truncation_policy: TruncationPolicy,
) -> Result<FunctionToolOutput, FunctionCallError>
where
T: Serialize,
{
@@ -279,6 +284,9 @@ where
"failed to serialize MCP resource response: {err}"
))
})?;
// Match regular MCP tool outputs by bounding the copy persisted to the
// rollout and injected into model context.
let content = truncate_text(&content, truncation_policy * 1.2);
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
@@ -117,7 +117,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -115,7 +115,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -93,7 +93,7 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -1,6 +1,7 @@
use super::*;
use pretty_assertions::assert_eq;
use rmcp::model::AnnotateAble;
use rmcp::model::ResourceContents;
use serde_json::json;
fn resource(uri: &str, name: &str) -> Resource {
@@ -123,3 +124,39 @@ fn template_with_server_serializes_server_field() {
})
);
}
#[test]
fn serialize_function_output_preserves_small_payload() {
let payload = json!({"server": "hosted", "resources": []});
let expected = serde_json::to_string(&payload).expect("serialize payload");
let output = serialize_function_output(payload, TruncationPolicy::Bytes(1_024))
.expect("serialize function output")
.into_text();
assert_eq!(output, expected);
}
#[test]
fn serialize_function_output_caps_read_resource_payload() {
let truncation_policy = TruncationPolicy::Bytes(8_000);
let payload = ReadResourcePayload {
server: "hosted".to_string(),
uri: "skill://large/SKILL.md".to_string(),
result: ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri: "skill://large/SKILL.md".to_string(),
mime_type: Some("text/markdown".to_string()),
text: "x".repeat(16_000),
meta: None,
}]),
};
let serialized = serde_json::to_string(&payload).expect("serialize payload");
let expected = truncate_text(&serialized, truncation_policy * 1.2);
let output = serialize_function_output(payload, truncation_policy)
.expect("serialize bounded function output")
.into_text();
assert_ne!(output, serialized);
assert_eq!(output, expected);
}