mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
4ec3b8eeea
## 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.
294 lines
9.4 KiB
Rust
294 lines
9.4 KiB
Rust
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::time::Duration;
|
|
|
|
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
|
|
|
|
use codex_app_server_protocol::AppInfo;
|
|
use codex_connectors::ConnectorDirectoryCacheContext;
|
|
use codex_connectors::ConnectorDirectoryCacheKey;
|
|
use codex_connectors::DirectoryListResponse;
|
|
use codex_connectors::filter::filter_disallowed_connectors;
|
|
use codex_connectors::merge::merge_connectors;
|
|
use codex_connectors::merge::merge_plugin_connectors;
|
|
use codex_core::config::Config;
|
|
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
|
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
|
|
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
|
|
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
|
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
|
|
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
|
|
pub use codex_core::connectors::with_app_enabled_state;
|
|
use codex_core_plugins::PluginsManager;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::default_client::originator;
|
|
use codex_plugin::AppConnectorId;
|
|
|
|
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
async fn apps_enabled(config: &Config) -> bool {
|
|
let auth_manager =
|
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
|
let auth = auth_manager.auth().await;
|
|
config
|
|
.features
|
|
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|
|
}
|
|
|
|
async fn connector_auth(config: &Config) -> anyhow::Result<CodexAuth> {
|
|
let auth_manager =
|
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
|
let auth = auth_manager
|
|
.auth()
|
|
.await
|
|
.ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
|
|
anyhow::ensure!(
|
|
auth.uses_codex_backend(),
|
|
"ChatGPT connectors require Codex backend auth"
|
|
);
|
|
Ok(auth)
|
|
}
|
|
|
|
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
|
if !apps_enabled(config).await {
|
|
return Ok(Vec::new());
|
|
}
|
|
let (connectors_result, accessible_result) = tokio::join!(
|
|
list_all_connectors(config),
|
|
list_accessible_connectors_from_mcp_tools(config),
|
|
);
|
|
let connectors = connectors_result?;
|
|
let accessible = accessible_result?;
|
|
Ok(with_app_enabled_state(
|
|
merge_connectors_with_accessible(
|
|
connectors, accessible, /*all_connectors_loaded*/ true,
|
|
),
|
|
config,
|
|
))
|
|
}
|
|
|
|
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
|
list_all_connectors_with_options(config, /*force_refetch*/ false).await
|
|
}
|
|
|
|
pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>> {
|
|
if !apps_enabled(config).await {
|
|
return Some(Vec::new());
|
|
}
|
|
|
|
let auth = connector_auth(config).await.ok()?;
|
|
let cache_context = connector_directory_cache_context(config, &auth);
|
|
let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;
|
|
let connectors = merge_plugin_connectors(
|
|
connectors,
|
|
plugin_apps_for_config(config)
|
|
.await
|
|
.into_iter()
|
|
.map(|connector_id| connector_id.0),
|
|
);
|
|
Some(filter_disallowed_connectors(
|
|
connectors,
|
|
originator().value.as_str(),
|
|
))
|
|
}
|
|
|
|
pub async fn list_all_connectors_with_options(
|
|
config: &Config,
|
|
force_refetch: bool,
|
|
) -> anyhow::Result<Vec<AppInfo>> {
|
|
if !apps_enabled(config).await {
|
|
return Ok(Vec::new());
|
|
}
|
|
let auth = connector_auth(config).await?;
|
|
let cache_context = connector_directory_cache_context(config, &auth);
|
|
let connectors = codex_connectors::list_all_connectors_with_options(
|
|
cache_context,
|
|
auth.is_workspace_account(),
|
|
force_refetch,
|
|
|path| async move {
|
|
chatgpt_get_request_with_timeout::<DirectoryListResponse>(
|
|
config,
|
|
path,
|
|
Some(DIRECTORY_CONNECTORS_TIMEOUT),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await?;
|
|
let connectors = merge_plugin_connectors(
|
|
connectors,
|
|
plugin_apps_for_config(config)
|
|
.await
|
|
.into_iter()
|
|
.map(|connector_id| connector_id.0),
|
|
);
|
|
Ok(filter_disallowed_connectors(
|
|
connectors,
|
|
originator().value.as_str(),
|
|
))
|
|
}
|
|
|
|
fn connector_directory_cache_context(
|
|
config: &Config,
|
|
auth: &CodexAuth,
|
|
) -> ConnectorDirectoryCacheContext {
|
|
ConnectorDirectoryCacheContext::new(
|
|
config.codex_home.to_path_buf(),
|
|
ConnectorDirectoryCacheKey::new(
|
|
config.chatgpt_base_url.clone(),
|
|
auth.get_account_id(),
|
|
auth.get_chatgpt_user_id(),
|
|
auth.is_workspace_account(),
|
|
),
|
|
)
|
|
}
|
|
|
|
async fn plugin_apps_for_config(config: &Config) -> Vec<AppConnectorId> {
|
|
let plugins_input = config.plugins_config_input();
|
|
PluginsManager::new(config.codex_home.to_path_buf())
|
|
.plugins_for_config(&plugins_input)
|
|
.await
|
|
.effective_apps()
|
|
}
|
|
|
|
pub fn connectors_for_plugin_apps(
|
|
connectors: Vec<AppInfo>,
|
|
plugin_apps: &[AppConnectorId],
|
|
) -> Vec<AppInfo> {
|
|
let connectors = merge_plugin_connectors(
|
|
connectors,
|
|
plugin_apps
|
|
.iter()
|
|
.map(|connector_id| connector_id.0.clone()),
|
|
);
|
|
let mut connectors_by_id =
|
|
filter_disallowed_connectors(connectors, originator().value.as_str())
|
|
.into_iter()
|
|
.map(|connector| (connector.id.clone(), connector))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
plugin_apps
|
|
.iter()
|
|
.filter_map(|connector_id| connectors_by_id.remove(connector_id.0.as_str()))
|
|
.collect()
|
|
}
|
|
|
|
pub fn merge_connectors_with_accessible(
|
|
connectors: Vec<AppInfo>,
|
|
accessible_connectors: Vec<AppInfo>,
|
|
all_connectors_loaded: bool,
|
|
) -> Vec<AppInfo> {
|
|
let accessible_connectors = if all_connectors_loaded {
|
|
let connector_ids: HashSet<&str> = connectors
|
|
.iter()
|
|
.map(|connector| connector.id.as_str())
|
|
.collect();
|
|
accessible_connectors
|
|
.into_iter()
|
|
.filter(|connector| connector_ids.contains(connector.id.as_str()))
|
|
.collect()
|
|
} else {
|
|
accessible_connectors
|
|
};
|
|
let merged = merge_connectors(connectors, accessible_connectors);
|
|
filter_disallowed_connectors(merged, originator().value.as_str())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_connectors::metadata::connector_install_url;
|
|
use codex_plugin::AppConnectorId;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn app(id: &str) -> AppInfo {
|
|
AppInfo {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
|
|
AppInfo {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some(connector_install_url(id, id)),
|
|
is_accessible,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn excludes_accessible_connectors_not_in_all_when_all_loaded() {
|
|
let merged = merge_connectors_with_accessible(
|
|
vec![app("alpha")],
|
|
vec![app("alpha"), app("beta")],
|
|
/*all_connectors_loaded*/ true,
|
|
);
|
|
assert_eq!(merged, vec![merged_app("alpha", /*is_accessible*/ true)]);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_accessible_connectors_not_in_all_while_all_loading() {
|
|
let merged = merge_connectors_with_accessible(
|
|
vec![app("alpha")],
|
|
vec![app("alpha"), app("beta")],
|
|
/*all_connectors_loaded*/ false,
|
|
);
|
|
assert_eq!(
|
|
merged,
|
|
vec![
|
|
merged_app("alpha", /*is_accessible*/ true),
|
|
merged_app("beta", /*is_accessible*/ true)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connectors_for_plugin_apps_returns_only_requested_plugin_apps() {
|
|
let connectors = connectors_for_plugin_apps(
|
|
vec![app("alpha"), app("beta")],
|
|
&[
|
|
AppConnectorId("gmail".to_string()),
|
|
AppConnectorId("alpha".to_string()),
|
|
AppConnectorId("gmail".to_string()),
|
|
],
|
|
);
|
|
assert_eq!(
|
|
connectors,
|
|
vec![merged_app("gmail", /*is_accessible*/ false), app("alpha")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() {
|
|
let connectors = connectors_for_plugin_apps(
|
|
Vec::new(),
|
|
&[AppConnectorId(
|
|
"asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
|
|
)],
|
|
);
|
|
assert_eq!(connectors, Vec::<AppInfo>::new());
|
|
}
|
|
}
|