Files
codex/codex-rs/ext/extension-api/src/contributors.rs
T
jif 4ec3b8eeea Route hosted Apps MCP through extensions (#27191)
## Stack

- Base: #27184
- This PR is the second vertical and should be reviewed against
`jif/external-plugins-1`, not `main`.

## Why

CCA is moving toward a split runtime where the orchestrator may have no
filesystem or executor, but it still needs to activate remotely hosted
plugin components. HTTP MCP servers are the simplest complete example:
they need configuration and host authentication, but they do not need an
executor process.

The Apps MCP endpoint is currently synthesized by a special-purpose
loader inside the MCP runtime. That works locally, but it leaves hosted
MCP activation outside the extension model being established in #27184.
It also makes the Apps path a poor foundation for plugins whose skills,
MCP servers, connectors, and hooks may come from different sources or
execute in different places.

This PR moves that one behavior behind an extension-owned contribution
while preserving the existing local fallback. It deliberately does not
introduce a generic plugin activation framework.

## What changed

### MCP extension contribution

`codex-extension-api` gains an ordered `McpServerContributor` contract.
A contributor returns typed `Set` or `Remove` overlays for MCP server
configuration; later contributors win for the names they own.

The contract stays at the existing MCP configuration boundary.
Extensions do not create a second connection manager or transport
abstraction.

### Hosted Apps MCP extension

A new `codex-mcp-extension` contributes the reserved `codex_apps` server
from the existing Apps feature, ChatGPT base URL, path override, and
product SKU configuration.

When `apps_mcp_path_override` is enabled for `https://chatgpt.com`, the
resulting streamable HTTP endpoint is
`https://chatgpt.com/backend-api/ps/mcp`. The existing ChatGPT-auth gate
remains authoritative, so this server can run in an orchestrator-only
process without being exposed for API-key sessions.

### One resolved runtime view

`McpManager` now distinguishes three views:

- **configured:** config- and plugin-backed servers before extension
overlays;
- **runtime:** configured servers plus host-installed extension
contributions;
- **effective:** runtime servers after auth gating and compatibility
built-ins.

App-server installs the hosted MCP extension and uses the runtime view
for thread startup, refresh, status, threadless resource reads,
connector discovery, and MCP OAuth lookup. This keeps
`mcpServer/oauth/login` consistent with the servers exposed by the other
MCP APIs. The hosted Apps server itself continues to use existing
ChatGPT host authentication rather than MCP OAuth.

## Compatibility

Hosts that do not install the MCP extension retain the existing Apps MCP
synthesis path. This preserves current local-only, CLI, and
standalone-host behavior while app-server exercises the extension path.

Disabling Apps removes the reserved `codex_apps` entry, and losing
ChatGPT auth removes it from the effective runtime view. Executor
availability is not consulted for this HTTP transport.

## Follow-ups

The next vertical will resolve a manifest-declared stdio MCP server from
an executor-selected plugin root and execute it in the environment that
owns that root. Later verticals can add backend-owned skills, connector
metadata, hooks, durable selection semantics, and incremental local
convergence without changing the component-specific runtime boundaries
introduced here.

## Verification

Focused coverage was added for:

- contributing the hosted Apps MCP at `/backend-api/ps/mcp` without an
executor;
- requiring ChatGPT auth in the effective runtime view;
- removing a reserved configured Apps server when the Apps feature is
disabled.

`cargo check -p codex-app-server -p codex-mcp-extension -p
codex-extension-api -p codex-mcp` passed. Tests and Clippy were not run
locally under the current development instruction; CI provides the full
validation pass.
2026-06-09 22:44:16 +02:00

210 lines
7.8 KiB
Rust

use std::future::Future;
use std::sync::Arc;
use codex_context_fragments::ContextualUserFragment;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TokenUsageInfo;
use codex_tools::ToolCall;
use codex_tools::ToolExecutor;
use crate::ExtensionData;
mod mcp;
mod prompt;
mod thread_lifecycle;
mod tool_lifecycle;
mod turn_input;
mod turn_lifecycle;
pub use mcp::McpServerContribution;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadIdleInput;
pub use thread_lifecycle::ThreadResumeInput;
pub use thread_lifecycle::ThreadStartInput;
pub use thread_lifecycle::ThreadStopInput;
pub use tool_lifecycle::ToolCallOutcome;
pub use tool_lifecycle::ToolCallSource;
pub use tool_lifecycle::ToolFinishInput;
pub use tool_lifecycle::ToolLifecycleFuture;
pub use tool_lifecycle::ToolStartInput;
pub use turn_input::TurnInputContext;
pub use turn_input::TurnInputEnvironment;
pub use turn_lifecycle::TurnAbortInput;
pub use turn_lifecycle::TurnErrorInput;
pub use turn_lifecycle::TurnStartInput;
pub use turn_lifecycle::TurnStopInput;
/// Extension contribution that resolves runtime MCP servers from host config.
///
/// Contributors run in registration order. Later contributions for the same
/// name replace earlier ones. Implementations must contribute only names they
/// own and must apply any source-specific policy before returning a server.
/// Plugin-owned servers and their provenance continue to be resolved by the
/// plugin manager until that ownership moves into an extension explicitly.
#[async_trait::async_trait]
pub trait McpServerContributor<C: Sync>: Send + Sync {
async fn contribute(&self, config: &C) -> Vec<McpServerContribution>;
}
/// Extension contribution that adds prompt fragments during prompt assembly.
pub trait ContextContributor: Send + Sync {
fn contribute<'a>(
&'a self,
session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
) -> std::pin::Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>>;
}
/// Contributor for host-owned thread lifecycle gates.
///
/// Implementations should use these callbacks to seed, rehydrate, or flush
/// extension-private thread state. Heavy dependencies belong on the extension
/// value created by the host, not in these inputs.
#[async_trait::async_trait]
pub trait ThreadLifecycleContributor<C: Sync>: Send + Sync {
/// Called after thread-scoped extension stores are created, before later
/// contributors can read from them.
async fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {}
/// Called after the host constructs a runtime from persisted history.
async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {}
/// Called after the host has drained immediately pending thread work.
///
/// Implementations may use host capabilities captured by the extension to
/// submit follow-up input. The host remains responsible for deciding
/// whether that input starts a turn, is queued, or is ignored.
async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) {}
/// Called before the host drops the thread runtime and thread-scoped store.
async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {}
}
/// Contributor for host-owned turn lifecycle gates.
///
/// Implementations should use these callbacks to seed, observe, or clear
/// extension-private turn state. The host exposes stable identifiers and
/// extension stores instead of core runtime objects.
#[async_trait::async_trait]
pub trait TurnLifecycleContributor: Send + Sync {
/// Called after turn-scoped extension stores are created, before the task
/// for the turn starts running.
async fn on_turn_start(&self, _input: TurnStartInput<'_>) {}
/// Called before the host drops the completed turn runtime and turn store.
async fn on_turn_stop(&self, _input: TurnStopInput<'_>) {}
/// Called after the host aborts a running turn.
async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {}
/// Called when the host observes an error for a running turn.
async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {}
}
/// Extension contribution that can add turn-local model input.
///
/// Implementations should resolve only the model-visible input they own and
/// must preserve authority boundaries for external resources. Expensive or
/// host-specific dependencies belong on the extension value installed by the
/// host, not in this input.
#[async_trait::async_trait]
pub trait TurnInputContributor: Send + Sync {
/// Returns additional contextual fragments for one submitted turn.
async fn contribute(
&self,
input: TurnInputContext,
session_store: &ExtensionData,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
) -> Vec<Box<dyn ContextualUserFragment + Send>>;
}
/// Contributor for host-owned configuration changes.
///
/// Implementations should treat the supplied values as immutable before/after
/// snapshots of the effective thread configuration.
pub trait ConfigContributor<C>: Send + Sync {
/// Called after the host commits a changed thread configuration.
fn on_config_changed(
&self,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
_previous_config: &C,
_new_config: &C,
) {
}
}
/// Contributor for token usage checkpoints reported by the model provider.
///
/// Implementations should keep this callback cheap. The host calls it after
/// updating cached token usage and before emitting the corresponding client
/// token-count notification.
#[async_trait::async_trait]
pub trait TokenUsageContributor: Send + Sync {
/// Called each time the host records token usage from a model response.
async fn on_token_usage(
&self,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
_token_usage: &TokenUsageInfo,
) {
}
}
/// Extension contribution that exposes native tools owned by a feature.
pub trait ToolContributor: Send + Sync {
/// Returns the native tools visible for the supplied extension stores.
fn tools(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>>;
}
/// Contributor for host-owned tool lifecycle gates.
///
/// Implementations should use these callbacks to observe tool execution without
/// inspecting or rewriting tool input/output. Use `ToolContributor` for owning a
/// tool implementation and hooks for policy that needs tool payloads.
pub trait ToolLifecycleContributor: Send + Sync {
/// Called once the host has accepted a tool call for execution.
fn on_tool_start<'a>(&'a self, _input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(std::future::ready(()))
}
/// Called after the tool call returns, is blocked, fails, or is cancelled.
fn on_tool_finish<'a>(&'a self, _input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(std::future::ready(()))
}
}
/// Extension contribution that can claim rendered approval-review prompts.
#[async_trait::async_trait]
pub trait ApprovalReviewContributor: Send + Sync {
async fn contribute(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
prompt: &str,
) -> Option<ReviewDecision>;
}
/// Ordered post-processing contribution for one parsed turn item.
///
/// Implementations may mutate the item before it is emitted and may use the
/// explicitly exposed thread- and turn-lifetime stores when they need durable
/// extension-private state.
#[async_trait::async_trait]
pub trait TurnItemContributor: Send + Sync {
async fn contribute(
&self,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
item: &mut TurnItem,
) -> Result<(), String>;
}