Files
codex/codex-rs/core/src/mcp.rs
T
felixxia-oai 41ed72c32b Add opt-in conversation history retrieval to Guardian reviews (#49036)
## Why

The transcript supplied to Guardian may omit earlier user instructions, restrictions, or revoked permissions. History retrieval lets the reviewer check relevant authorization before approving actions with side effects.

## What changed

- Add the disabled-by-default `guardian_conversation_history_tools` feature. With Apps enabled, expose `user_message.search_messages` and `user_message.read_messages` through the parent's live Apps connection and conversation identity.
- Recheck the parent's current app and tool policy on each call, rejecting disabled tools and calls requiring approval.
- Add retrieval instructions that distinguish user authorization from assistant context and account for later revocations and incomplete results. Allow overrides through `auto_review.experimental_conversation_history_prompt`.
- Limit history responses to an estimated 4,000 tokens by default, configurable through `auto_review.conversation_history_max_output_tokens`, while preserving stricter parent and reviewer limits.
- Allow explicit extension registries for isolated sessions and prevent those sessions from inheriting the implicit Apps connection.

## Testing

Add scenarios covering history tool exposure, parent connection and identity reuse, custom prompts, output truncation, stricter reviewer budgets, and live permission changes on a reused reviewer. Extend configuration coverage for blank prompt overrides.

GitOrigin-RevId: 055a69b113c8548bd58e7d5618ea026ba6d1ffb1
2026-09-28 17:29:52 +00:00

431 lines
17 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use crate::config::Config;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::McpServerConfig;
use codex_connectors::ConnectorRuntimeManager;
use codex_connectors::PluginConnectorSource;
use codex_core_plugins::PluginsManager;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::SelectedPlugin;
use codex_extension_api::SelectedPluginIdentity;
use codex_extension_api::SelectedPluginSnapshot;
use codex_extension_api::SessionIsolation;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::McpEnvironmentAuthority;
use codex_mcp::McpPluginAttribution;
use codex_mcp::McpServerRegistration;
use codex_mcp::McpToolCatalogCache;
use codex_mcp::ToolInfo;
use codex_mcp::codex_apps_mcp_server_config;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
use codex_plugin::AppConnectorId;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps";
/// MCP configuration and capability availability derived from the same inputs.
#[derive(Clone)]
pub(crate) struct McpRuntimeProjection {
pub(crate) config: McpConfig,
pub(crate) plugins_available: bool,
pub(crate) selected_plugins: SelectedPluginSnapshot,
}
pub(crate) enum McpEnvironmentScope<'a> {
/// Controller-level operations without an associated thread.
HostOnly,
/// Every thread selection captured for this operation, including unavailable configurations.
Selected(&'a [TurnEnvironmentSelection]),
}
impl<'a> McpEnvironmentScope<'a> {
pub(crate) fn authority_for(&self, environment_id: &str) -> McpEnvironmentAuthority<'a> {
let Self::Selected(selections) = *self else {
return McpEnvironmentAuthority::Unrestricted;
};
let Some(selection) = selections
.iter()
.find(|selection| selection.environment_id == environment_id)
else {
return if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
McpEnvironmentAuthority::Unrestricted
} else {
McpEnvironmentAuthority::SelectedPluginsOnly
};
};
match &selection.config {
EnvironmentConfigState::FromThread => McpEnvironmentAuthority::Unrestricted,
EnvironmentConfigState::Pending | EnvironmentConfigState::Failed(_) => {
McpEnvironmentAuthority::Unavailable
}
EnvironmentConfigState::Ready(config) => config
.mcp_policy
.as_ref()
.map_or(McpEnvironmentAuthority::Unrestricted, |policy| {
McpEnvironmentAuthority::Restricted(policy)
}),
}
}
}
pub(crate) struct McpThreadIdentity<'a> {
pub(crate) auth_changed: bool,
pub(crate) session_source: &'a SessionSource,
pub(crate) originator: &'a str,
pub(crate) environments: McpEnvironmentScope<'a>,
pub(crate) disabled_plugin_ids: &'a [String],
}
enum OrderedMcpOverlay {
Set(Box<McpServerRegistration>),
Remove {
contributor_id: &'static str,
contribution_order: usize,
name: String,
},
}
#[derive(Clone)]
pub struct McpManager {
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
tool_catalog_cache: McpToolCatalogCache,
}
impl McpManager {
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
Self::new_with_extensions(
plugins_manager,
codex_extension_api::empty_extension_registry(),
ConnectorRuntimeManager::default(),
)
}
/// Creates a manager that resolves host-installed MCP contributions.
pub fn new_with_extensions(
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
) -> Self {
Self {
plugins_manager,
extensions,
codex_apps_tools_cache,
tool_catalog_cache: McpToolCatalogCache::default(),
}
}
pub fn codex_apps_tools_cache(&self) -> ConnectorRuntimeManager<ToolInfo> {
self.codex_apps_tools_cache.clone()
}
pub fn tool_catalog_cache(&self) -> McpToolCatalogCache {
self.tool_catalog_cache.clone()
}
/// Returns the MCP config after applying compatibility built-ins and
/// runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
self.runtime_config_with_context(
McpServerContributionContext::global(config),
// Threadless discovery and control-plane paths have no effective thread
// originator; active-thread tool calls use runtime_config_for_step below.
/*originator*/
None,
McpEnvironmentScope::HostOnly,
&[],
)
.await
.config
}
#[tracing::instrument(name = "mcp.runtime_config.project_for_step", skip_all)]
pub(crate) async fn runtime_config_for_step(
&self,
config: &Config,
thread_init: &ExtensionDataInit,
thread_store: &ExtensionData,
identity: McpThreadIdentity<'_>,
ready_selected_capability_roots: &[SelectedCapabilityRoot],
executor_capability_discovery: Option<&ExecutorCapabilityDiscoverySnapshot>,
) -> McpRuntimeProjection {
self.runtime_config_with_context(
McpServerContributionContext::for_step(
config,
thread_init,
thread_store,
identity.originator,
ready_selected_capability_roots,
executor_capability_discovery,
)
.with_session_source(identity.session_source)
.with_auth_changed(identity.auth_changed),
Some(identity.originator),
identity.environments,
identity.disabled_plugin_ids,
)
.await
}
async fn runtime_config_with_context(
&self,
context: McpServerContributionContext<'_, Config>,
originator: Option<&str>,
environment_scope: McpEnvironmentScope<'_>,
disabled_plugin_ids: &[String],
) -> McpRuntimeProjection {
let config = context.config();
let mut selected_plugin_connector_sources = Vec::new();
let mut selected_plugin_registrations = Vec::new();
let mut selected_plugins = Vec::new();
let mut disabled_plugin_roots = Vec::new();
let mut overlays = Vec::new();
// A contributor can emit multiple ordered actions, so order each action globally rather
// than enumerating contributors.
let mut contribution_order = 0;
let mut plugin_selection_order = 0;
for contributor in self.extensions.mcp_server_contributors() {
// Finish executor plugins before asking for hosted contributions, which may change
// accounts while an executor is still loading.
for SelectedPlugin {
selected_root_id,
plugin_id,
mcp,
} in contributor.selected_plugins(context).await
{
let selection_order = plugin_selection_order;
plugin_selection_order += 1;
let disabled = disabled_plugin_ids.contains(&plugin_id);
if !config.features.enabled(Feature::Plugins) || disabled {
disabled_plugin_roots.push(selected_root_id);
} else {
selected_plugins.push(SelectedPluginIdentity {
selected_root_id: Some(selected_root_id),
plugin_id: plugin_id.clone(),
});
}
let contribution = mcp.await;
if !disabled {
for (name, server) in contribution.servers {
selected_plugin_registrations.push(
McpServerRegistration::from_selected_plugin(
name,
McpPluginAttribution::new(
plugin_id.clone(),
contribution.plugin_display_name.clone(),
),
selection_order,
&contribution.source_environment_id,
server,
),
);
}
}
// Disabled plugins still tell Apps which connectors to hide.
if config.features.enabled(Feature::Plugins)
&& !contribution.connector_ids.is_empty()
{
selected_plugin_connector_sources.push(
PluginConnectorSource::from_connector_ids(
plugin_id,
contribution.plugin_display_name,
contribution.connector_ids.into_iter().map(AppConnectorId),
),
);
}
}
for contribution in contributor.contribute(context).await {
match contribution {
McpServerContribution::Set { name, config } => {
overlays.push(OrderedMcpOverlay::Set(Box::new(
McpServerRegistration::from_extension(
name,
contributor.id(),
contribution_order,
*config,
),
)));
}
McpServerContribution::SetWithProtocolMode {
name,
config,
protocol_mode,
} => {
overlays.push(OrderedMcpOverlay::Set(Box::new(
McpServerRegistration::from_extension(
name,
contributor.id(),
contribution_order,
*config,
)
.with_protocol_mode(protocol_mode),
)));
}
McpServerContribution::HostedApps {
config,
protocol_mode,
} => {
let mut registration = McpServerRegistration::from_hosted_apps(
contributor.id(),
contribution_order,
*config,
);
if let Some(protocol_mode) = protocol_mode {
registration = registration.with_protocol_mode(protocol_mode);
}
overlays.push(OrderedMcpOverlay::Set(Box::new(registration)));
}
McpServerContribution::HostedPluginConnectors {
plugin_id,
plugin_display_name,
connector_ids,
} => {
if config.features.enabled(Feature::Plugins) {
if !disabled_plugin_ids.contains(&plugin_id) {
selected_plugins.push(SelectedPluginIdentity {
selected_root_id: None,
plugin_id: plugin_id.clone(),
});
}
if !connector_ids.is_empty() {
selected_plugin_connector_sources.push(
PluginConnectorSource::from_connector_ids(
plugin_id,
plugin_display_name,
connector_ids.into_iter().map(AppConnectorId),
),
);
}
}
}
McpServerContribution::Remove { name } => {
overlays.push(OrderedMcpOverlay::Remove {
contributor_id: contributor.id(),
contribution_order,
name,
});
}
}
contribution_order += 1;
}
}
let loaded_plugins = self
.plugins_manager
.plugins_for_config(&config.plugins_config_input())
.await;
let host_plugin_connector_sources = loaded_plugins
.capability_summaries()
.iter()
.map(PluginConnectorSource::from);
let connector_snapshot = if config.features.enabled(Feature::Plugins) {
self.plugins_manager.connector_snapshot(
host_plugin_connector_sources.chain(selected_plugin_connector_sources),
disabled_plugin_ids,
)
} else {
Default::default()
};
let loaded_plugins = loaded_plugins.without_plugins(disabled_plugin_ids);
let plugins_available =
!selected_plugins.is_empty() || !loaded_plugins.capability_summaries().is_empty();
let mut mcp_config = config
.to_mcp_config_with_loaded_plugins(&loaded_plugins, selected_plugin_registrations);
let mut catalog = mcp_config.mcp_server_catalog.to_builder();
// Isolated sessions do not inherit the implicit Apps connection.
if mcp_config.apps_enabled
&& context
.thread_init()
.and_then(codex_extension_api::ExtensionDataInit::get::<SessionIsolation>)
.as_deref()
!= Some(&SessionIsolation::Isolated)
{
catalog.register(McpServerRegistration::from_compatibility(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
LEGACY_CODEX_APPS_REGISTRATION_ID,
codex_apps_mcp_server_config(
&mcp_config.chatgpt_base_url,
mcp_config.apps_mcp_product_sku.as_deref(),
originator,
),
));
} else {
catalog.remove_compatibility(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
LEGACY_CODEX_APPS_REGISTRATION_ID,
);
}
for overlay in overlays {
match overlay {
OrderedMcpOverlay::Set(registration) => catalog.register(*registration),
OrderedMcpOverlay::Remove {
contributor_id,
contribution_order,
name,
} => catalog.remove_extension(name, contributor_id, contribution_order),
}
}
let catalog = catalog.build_with_environment_authority(|environment_id| {
environment_scope.authority_for(environment_id)
});
for conflict in catalog.conflicts() {
tracing::warn!(
server = conflict.name,
outcome = ?conflict.outcome,
contenders = ?conflict.contenders,
"conflicting MCP server actions; using resolved catalog outcome"
);
}
mcp_config.mcp_server_catalog = catalog;
mcp_config.connector_snapshot = connector_snapshot;
McpRuntimeProjection {
config: mcp_config,
plugins_available,
selected_plugins: SelectedPluginSnapshot {
plugins: selected_plugins,
disabled_plugin_roots,
},
}
}
/// Returns config- and plugin-backed servers without runtime contributions.
pub async fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
configured_mcp_servers(&mcp_config)
}
/// Returns configured and host-contributed servers before auth gating.
pub async fn runtime_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = self.runtime_config(config).await;
configured_mcp_servers(&mcp_config)
}
/// Returns runtime servers after auth gating and compatibility built-ins.
pub async fn effective_servers(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let mcp_config = self.runtime_config(config).await;
effective_mcp_servers(&mcp_config, auth)
}
}