Files
codex/codex-rs/codex-mcp/src/server.rs
T
jif c3a479620f Add selected-plugin precedence and attribution to the MCP catalog (#27884)
## Why

**In short:** this PR resolves already-discovered MCP registrations. It
does not read selected plugins or discover their MCP servers.

The resolved MCP catalog currently builds config and auto-discovered
plugin registrations before runtime contributors are applied. A
thread-selected plugin needs a distinct precedence tier in that same
initial resolution pass: otherwise a disabled lower-precedence winner
can leave stale name-level state behind, and the winning MCP tools
cannot be attributed to the selected package reliably.

This PR adds that catalog boundary before executor discovery is
connected.

## What changed

- Added an explicit selected-plugin registration tier between
auto-discovered plugins and explicit config.
- Collected selected-plugin contributions before the initial catalog
build, while leaving compatibility and generic extension overlays in
their existing runtime phase.
- Retained the winning plugin ID and display name directly on
plugin-owned catalog registrations.
- Derived MCP tool provenance from the winning catalog entry instead of
joining against local-only plugin summaries.
- Retained the winning selected server's tool approval policy in the
running connection manager, so a selected registration cannot inherit
approval behavior from a losing local plugin.
- Kept remembered approval session-scoped for selected plugins until
there is an authority-aware persistence contract; Codex will not write
approval back to an unrelated local plugin.
- Preserved existing name-level disabled vetoes for discovered plugins
and config, while keeping a selected package's own disabled registration
scoped to that registration.
- Preserved deterministic selection order and existing config,
compatibility, and extension precedence.

The resulting order is:

```text
auto-discovered plugin
  < selected plugin
  < explicit config
  < compatibility registration
  < extension overlay
```

## Behavior and scope

This is a catalog and provenance change only. No production host
contributes selected-plugin MCP registrations yet, so existing local MCP
behavior remains unchanged.

The stacked follow-up, #27870, installs the executor plugin provider
that produces these registrations. App-server activation remains a
separate final step.

## Verification

Focused tests cover precedence, deterministic selected-plugin conflicts,
disabled-veto behavior across catalog phases, managed requirements
before selected-plugin resolution, winning-server approval policy, and
attribution when local and selected packages share an ID or server name.
CI owns execution of the test suite.
2026-06-15 11:10:51 +02:00

116 lines
3.4 KiB
Rust

use std::collections::HashMap;
use codex_config::AppToolApproval;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
/// The runtime launch strategy for an effective MCP server.
#[derive(Debug, Clone)]
pub(crate) enum McpServerLaunch {
Configured(Box<McpServerConfig>),
}
/// MCP server after runtime additions have been applied.
#[derive(Debug, Clone)]
pub struct EffectiveMcpServer {
launch: McpServerLaunch,
}
impl EffectiveMcpServer {
pub fn configured(config: McpServerConfig) -> Self {
Self {
launch: McpServerLaunch::Configured(Box::new(config)),
}
}
pub(crate) fn launch(&self) -> &McpServerLaunch {
&self.launch
}
pub fn configured_config(&self) -> Option<&McpServerConfig> {
match &self.launch {
McpServerLaunch::Configured(config) => Some(config.as_ref()),
}
}
pub fn enabled(&self) -> bool {
match &self.launch {
McpServerLaunch::Configured(config) => config.enabled,
}
}
pub fn required(&self) -> bool {
match &self.launch {
McpServerLaunch::Configured(config) => config.required,
}
}
}
/// Transport origin retained for metrics and diagnostics after server launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum McpServerOrigin {
Stdio,
StreamableHttp(String),
}
impl McpServerOrigin {
pub fn as_str(&self) -> &str {
match self {
Self::Stdio => "stdio",
Self::StreamableHttp(origin) => origin,
}
}
fn from_transport(transport: &McpServerTransportConfig) -> Option<Self> {
match transport {
McpServerTransportConfig::StreamableHttp { url, .. } => {
let parsed = url::Url::parse(url).ok()?;
Some(Self::StreamableHttp(parsed.origin().ascii_serialization()))
}
McpServerTransportConfig::Stdio { .. } => Some(Self::Stdio),
}
}
}
/// Semantic metadata that must survive after the server is launched.
#[derive(Debug, Clone)]
pub(crate) struct McpServerMetadata {
pub pollutes_memory: bool,
pub origin: Option<McpServerOrigin>,
pub supports_parallel_tool_calls: bool,
pub default_tools_approval_mode: Option<AppToolApproval>,
pub tool_approval_modes: HashMap<String, AppToolApproval>,
}
impl McpServerMetadata {
pub fn tool_approval_mode(&self, tool_name: &str) -> AppToolApproval {
self.tool_approval_modes
.get(tool_name)
.copied()
.or(self.default_tools_approval_mode)
.unwrap_or_default()
}
}
impl From<&EffectiveMcpServer> for McpServerMetadata {
fn from(server: &EffectiveMcpServer) -> Self {
match server.launch() {
McpServerLaunch::Configured(config) => Self {
pollutes_memory: true,
origin: McpServerOrigin::from_transport(&config.transport),
supports_parallel_tool_calls: config.supports_parallel_tool_calls,
default_tools_approval_mode: config.default_tools_approval_mode,
tool_approval_modes: config
.tools
.iter()
.filter_map(|(name, config)| {
config
.approval_mode
.map(|approval_mode| (name.clone(), approval_mode))
})
.collect(),
},
}
}
}