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.
This commit is contained in:
jif
2026-06-15 11:10:51 +02:00
committed by GitHub
parent dfd03ea01b
commit c3a479620f
16 changed files with 585 additions and 89 deletions
+83 -12
View File
@@ -5,18 +5,58 @@ use std::collections::HashMap;
use codex_config::McpServerConfig;
/// Plugin identity retained with an MCP registration for tool attribution.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct McpPluginAttribution {
plugin_id: String,
display_name: String,
}
impl McpPluginAttribution {
pub fn new(plugin_id: String, display_name: String) -> Self {
Self {
plugin_id,
display_name,
}
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
pub fn display_name(&self) -> &str {
&self.display_name
}
}
/// The component that declared an MCP server registration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerSource {
Plugin { plugin_id: String },
/// A plugin discovered through the process-wide legacy plugin manager.
Plugin(McpPluginAttribution),
/// A plugin explicitly selected for this thread through a capability root.
SelectedPlugin(McpPluginAttribution),
Config,
Compatibility { id: String },
Extension { id: String },
Compatibility {
id: String,
},
Extension {
id: String,
},
}
impl McpServerSource {
fn disabled_registration_is_name_veto(&self) -> bool {
// A selected package's policy applies to its registration, not to a higher runtime source
// that happens to use the same logical server name.
!matches!(self, Self::SelectedPlugin(_))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum RegistrationPrecedence {
Plugin(Reverse<usize>),
SelectedPlugin(Reverse<usize>),
Config,
Compatibility,
Extension(usize),
@@ -26,9 +66,10 @@ impl RegistrationPrecedence {
fn tier(self) -> u8 {
match self {
Self::Plugin(_) => 0,
Self::Config => 1,
Self::Compatibility => 2,
Self::Extension(_) => 3,
Self::SelectedPlugin(_) => 1,
Self::Config => 2,
Self::Compatibility => 3,
Self::Extension(_) => 4,
}
}
}
@@ -54,18 +95,33 @@ impl McpServerRegistration {
pub fn from_plugin(
name: String,
plugin_id: String,
attribution: McpPluginAttribution,
plugin_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Plugin { plugin_id },
McpServerSource::Plugin(attribution),
config,
RegistrationPrecedence::Plugin(Reverse(plugin_order)),
)
}
/// Registers a thread-selected plugin above discovered plugins and below config.
pub fn from_selected_plugin(
name: String,
attribution: McpPluginAttribution,
selection_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::SelectedPlugin(attribution),
config,
RegistrationPrecedence::SelectedPlugin(Reverse(selection_order)),
)
}
pub fn from_compatibility(
name: String,
id: impl Into<String>,
@@ -236,10 +292,14 @@ impl McpCatalogBuilder {
.filter_map(|(name, action)| match action {
CatalogAction::Register(registration) => {
let mut registration = *registration;
// Effective disabled winners remain name-scoped vetoes for later overlays.
let persist_disabled_name =
registration.source.disabled_registration_is_name_veto();
if !registration.config.enabled || disabled_server_names.contains(&name) {
registration.config.enabled = false;
disabled_server_names.insert(name.clone());
if persist_disabled_name {
// Preserve legacy disabled winners across later runtime overlays.
disabled_server_names.insert(name.clone());
}
}
Some((
name,
@@ -311,11 +371,15 @@ impl ResolvedMcpCatalog {
.collect()
}
pub fn plugin_ids_by_server_name(&self) -> HashMap<String, String> {
/// Returns package attribution for each winning plugin-owned server.
pub fn plugin_attributions_by_server_name(&self) -> HashMap<String, McpPluginAttribution> {
self.servers
.iter()
.filter_map(|(name, server)| match server.source() {
McpServerSource::Plugin { plugin_id } => Some((name.clone(), plugin_id.clone())),
McpServerSource::Plugin(attribution)
| McpServerSource::SelectedPlugin(attribution) => {
Some((name.clone(), attribution.clone()))
}
McpServerSource::Config
| McpServerSource::Compatibility { .. }
| McpServerSource::Extension { .. } => None,
@@ -323,6 +387,13 @@ impl ResolvedMcpCatalog {
.collect()
}
/// Returns the names of winning servers supplied by thread-selected plugins.
pub(crate) fn selected_plugin_server_names(&self) -> impl Iterator<Item = &str> {
self.servers.iter().filter_map(|(name, server)| {
matches!(server.source(), McpServerSource::SelectedPlugin(_)).then_some(name.as_str())
})
}
pub fn conflicts(&self) -> &[McpServerConflict] {
&self.conflicts
}
+151 -13
View File
@@ -8,6 +8,7 @@ use codex_config::McpServerToolConfig;
use codex_config::McpServerTransportConfig;
use pretty_assertions::assert_eq;
use super::McpPluginAttribution;
use super::McpServerConflict;
use super::McpServerConflictAction;
use super::McpServerRegistration;
@@ -44,10 +45,16 @@ fn server(url: &str) -> McpServerConfig {
}
}
fn plugin(plugin_id: &str) -> McpPluginAttribution {
McpPluginAttribution::new(plugin_id.to_string(), plugin_id.to_string())
}
fn plugin_source(plugin_id: &str) -> McpServerSource {
McpServerSource::Plugin {
plugin_id: plugin_id.to_string(),
}
McpServerSource::Plugin(plugin(plugin_id))
}
fn selected_plugin_source(plugin_id: &str) -> McpServerSource {
McpServerSource::SelectedPlugin(plugin(plugin_id))
}
fn compatibility_source(id: &str) -> McpServerSource {
@@ -69,8 +76,8 @@ fn remove(source: McpServerSource) -> McpServerConflictAction {
#[test]
fn source_precedence_preserves_the_winning_registration() {
let extension = server("https://extension.example/mcp");
let mut plugin = server("https://plugin.example/mcp");
plugin.enabled = false;
let mut plugin_server = server("https://plugin.example/mcp");
plugin_server.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
@@ -80,13 +87,13 @@ fn source_precedence_preserves_the_winning_registration() {
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"plugin@test".to_string(),
plugin("plugin@test"),
/*plugin_order*/ 0,
plugin,
plugin_server,
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"other-plugin@test".to_string(),
plugin("other-plugin@test"),
/*plugin_order*/ 1,
server("https://other-plugin.example/mcp"),
));
@@ -110,7 +117,7 @@ fn source_precedence_preserves_the_winning_registration() {
}
);
assert_eq!(resolved.config(), &extension);
assert!(catalog.plugin_ids_by_server_name().is_empty());
assert!(catalog.plugin_attributions_by_server_name().is_empty());
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
@@ -178,18 +185,50 @@ fn disabled_winner_remains_a_veto_when_the_catalog_is_extended() {
);
}
#[test]
fn disabled_discovered_plugin_remains_a_veto_for_runtime_overlays() {
let mut disabled = server("https://plugin.example/mcp");
disabled.enabled = false;
let mut expected = server("https://extension.example/mcp");
expected.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
plugin("plugin@test"),
/*plugin_order*/ 0,
disabled,
));
let mut builder = builder.build().to_builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
server("https://extension.example/mcp"),
));
let resolved = builder.build();
assert_eq!(
resolved.server("docs"),
Some(&super::ResolvedMcpServer {
source: extension_source("hosted"),
config: expected,
})
);
}
#[test]
fn earlier_plugin_wins_with_an_explicit_conflict() {
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"alpha@test".to_string(),
plugin("alpha@test"),
/*plugin_order*/ 0,
server("https://alpha.example/mcp"),
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"beta@test".to_string(),
plugin("beta@test"),
/*plugin_order*/ 1,
server("https://beta.example/mcp"),
));
@@ -197,8 +236,8 @@ fn earlier_plugin_wins_with_an_explicit_conflict() {
let catalog = builder.build();
assert_eq!(
catalog.plugin_ids_by_server_name(),
HashMap::from([("docs".to_string(), "alpha@test".to_string())])
catalog.plugin_attributions_by_server_name(),
HashMap::from([("docs".to_string(), plugin("alpha@test"))])
);
assert_eq!(
catalog.conflicts(),
@@ -213,6 +252,105 @@ fn earlier_plugin_wins_with_an_explicit_conflict() {
);
}
#[test]
fn selected_plugins_override_discovered_plugins_but_not_config() {
let selected = server("https://selected-alpha.example/mcp");
let mut discovered = server("https://local.example/mcp");
discovered.enabled = false;
discovered.default_tools_approval_mode = Some(AppToolApproval::Auto);
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
plugin("local@test"),
/*plugin_order*/ 0,
discovered,
));
builder.register(McpServerRegistration::from_selected_plugin(
"docs".to_string(),
plugin("selected-beta"),
/*selection_order*/ 1,
server("https://selected-beta.example/mcp"),
));
builder.register(McpServerRegistration::from_selected_plugin(
"docs".to_string(),
plugin("selected-alpha"),
/*selection_order*/ 0,
selected.clone(),
));
let catalog = builder.build();
assert_eq!(
catalog.server("docs"),
Some(&super::ResolvedMcpServer {
source: selected_plugin_source("selected-alpha"),
config: selected,
})
);
assert_eq!(
catalog.plugin_attributions_by_server_name(),
HashMap::from([("docs".to_string(), plugin("selected-alpha"))])
);
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: register(selected_plugin_source("selected-alpha")),
contenders: vec![
register(selected_plugin_source("selected-beta")),
register(selected_plugin_source("selected-alpha")),
],
}]
);
let mut builder = catalog.to_builder();
let configured = server("https://config.example/mcp");
builder.register(McpServerRegistration::from_config(
"docs".to_string(),
configured.clone(),
));
let catalog = builder.build();
assert_eq!(
catalog.server("docs"),
Some(&super::ResolvedMcpServer {
source: McpServerSource::Config,
config: configured,
})
);
}
#[test]
fn disabled_selected_plugin_does_not_veto_runtime_overlays() {
let mut disabled = server("https://selected.example/mcp");
disabled.enabled = false;
let extension = server("https://extension.example/mcp");
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_selected_plugin(
"docs".to_string(),
plugin("selected"),
/*selection_order*/ 0,
disabled,
));
let mut builder = builder.build().to_builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
extension.clone(),
));
let resolved = builder.build();
assert_eq!(
resolved.server("docs"),
Some(&super::ResolvedMcpServer {
source: extension_source("hosted"),
config: extension,
})
);
}
#[test]
fn equal_precedence_uses_insertion_order_not_source_identity() {
let mut builder = ResolvedMcpCatalog::builder();
@@ -381,6 +381,22 @@ impl McpConnectionManager {
.plugin_id_for_mcp_server_name(server_name)
}
pub fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
self.tool_plugin_provenance
.is_selected_plugin_mcp_server(server_name)
}
pub fn tool_approval_mode(
&self,
server_name: &str,
tool_name: &str,
) -> codex_config::AppToolApproval {
self.server_metadata
.get(server_name)
.map(|metadata| metadata.tool_approval_mode(tool_name))
.unwrap_or_default()
}
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME
}
@@ -12,14 +12,18 @@ use crate::elicitation::elicitation_is_rejected_by_policy;
use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError;
use crate::server::EffectiveMcpServer;
use crate::server::McpServerMetadata;
use crate::server::McpServerOrigin;
use crate::tools::ToolFilter;
use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use crate::tools::normalize_tools_for_model_with_prefix;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::AppToolApproval;
use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::types::AuthKeyringBackendKind;
use codex_exec_server::EnvironmentManager;
use codex_protocol::ToolName;
@@ -1128,6 +1132,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() {
"https://docs.example".to_string(),
)),
supports_parallel_tool_calls: true,
default_tools_approval_mode: None,
tool_approval_modes: HashMap::new(),
},
);
manager.clients.insert(
@@ -1150,6 +1156,28 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() {
assert_eq!(tool.server_origin.as_deref(), Some("https://docs.example"));
}
#[test]
fn server_metadata_preserves_tool_approval_policy() {
let mut config = crate::codex_apps_mcp_server_config(
"https://docs.example",
/*apps_mcp_product_sku*/ None,
);
config.default_tools_approval_mode = Some(AppToolApproval::Prompt);
config.tools.insert(
"search".to_string(),
McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
},
);
let metadata = McpServerMetadata::from(&EffectiveMcpServer::configured(config));
assert_eq!(metadata.tool_approval_mode("read"), AppToolApproval::Prompt);
assert_eq!(
metadata.tool_approval_mode("search"),
AppToolApproval::Approve
);
}
#[tokio::test]
async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
+1
View File
@@ -12,6 +12,7 @@ pub use runtime::SandboxState;
pub use tools::ToolInfo;
pub use catalog::McpCatalogBuilder;
pub use catalog::McpPluginAttribution;
pub use catalog::McpServerConflict;
pub use catalog::McpServerConflictAction;
pub use catalog::McpServerRegistration;
+31 -14
View File
@@ -12,6 +12,7 @@ pub use auth::should_retry_without_scopes;
pub(crate) mod auth;
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::time::Duration;
@@ -141,7 +142,8 @@ pub struct McpConfig {
pub client_elicitation_capability: ElicitationCapability,
/// Resolved MCP registrations keyed by logical server name.
pub mcp_server_catalog: ResolvedMcpCatalog,
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
/// Plugin metadata used to attribute connector tools to plugin display names.
/// MCP registrations retain their own package attribution in the catalog.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -150,6 +152,7 @@ pub struct ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
plugin_ids_by_mcp_server_name: HashMap<String, String>,
selected_plugin_mcp_server_names: HashSet<String>,
}
impl ToolPluginProvenance {
@@ -173,9 +176,12 @@ impl ToolPluginProvenance {
.map(String::as_str)
}
pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
self.selected_plugin_mcp_server_names.contains(server_name)
}
fn from_config(config: &McpConfig) -> Self {
let mut tool_plugin_provenance = Self::default();
let plugin_ids_by_mcp_server_name = config.mcp_server_catalog.plugin_ids_by_server_name();
for plugin in &config.plugin_capability_summaries {
for connector_id in &plugin.app_connector_ids {
tool_plugin_provenance
@@ -184,18 +190,31 @@ impl ToolPluginProvenance {
.or_default()
.push(plugin.display_name.clone());
}
for server_name in plugin.mcp_server_names.iter().filter(|server_name| {
plugin_ids_by_mcp_server_name.get(*server_name) == Some(&plugin.config_name)
}) {
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.entry(server_name.clone())
.or_default()
.push(plugin.display_name.clone());
}
}
for (server_name, attribution) in config
.mcp_server_catalog
.plugin_attributions_by_server_name()
{
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.insert(
server_name.clone(),
vec![attribution.display_name().to_string()],
);
tool_plugin_provenance
.plugin_ids_by_mcp_server_name
.insert(server_name, attribution.plugin_id().to_string());
}
tool_plugin_provenance
.selected_plugin_mcp_server_names
.extend(
config
.mcp_server_catalog
.selected_plugin_server_names()
.map(str::to_string),
);
for plugin_names in tool_plugin_provenance
.plugin_display_names_by_connector_id
.values_mut()
@@ -208,8 +227,6 @@ impl ToolPluginProvenance {
plugin_names.sort_unstable();
plugin_names.dedup();
}
tool_plugin_provenance.plugin_ids_by_mcp_server_name = plugin_ids_by_mcp_server_name;
tool_plugin_provenance
}
}
+45 -1
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpPluginAttribution;
use crate::McpServerRegistration;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
@@ -13,6 +14,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
@@ -127,7 +129,7 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_plugin(
"alpha".to_string(),
"alpha@test".to_string(),
McpPluginAttribution::new("alpha@test".to_string(), "alpha-plugin".to_string()),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
));
@@ -174,6 +176,7 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
"alpha".to_string(),
"alpha@test".to_string(),
)]),
selected_plugin_mcp_server_names: HashSet::new(),
}
);
assert_eq!(
@@ -183,6 +186,47 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None);
}
#[test]
fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() {
let mut config = test_mcp_config(PathBuf::new());
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_selected_plugin(
"github".to_string(),
McpPluginAttribution::new(
"shared-plugin-id".to_string(),
"Executor GitHub".to_string(),
),
/*selection_order*/ 0,
codex_apps_mcp_server_config("https://github.example", /*apps_mcp_product_sku*/ None),
));
config.mcp_server_catalog = catalog.build();
config.plugin_capability_summaries = vec![PluginCapabilitySummary {
config_name: "shared-plugin-id".to_string(),
display_name: "Local GitHub".to_string(),
mcp_server_names: vec!["github".to_string()],
..PluginCapabilitySummary::default()
}];
let provenance = tool_plugin_provenance(&config);
assert_eq!(
provenance,
ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap::new(),
plugin_display_names_by_mcp_server_name: HashMap::from([(
"github".to_string(),
vec!["Executor GitHub".to_string()],
)]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"github".to_string(),
"shared-plugin-id".to_string(),
)]),
selected_plugin_mcp_server_names: HashSet::from(["github".to_string()]),
}
);
assert!(provenance.is_selected_plugin_mcp_server("github"));
}
#[test]
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
assert_eq!(
+25
View File
@@ -1,3 +1,6 @@
use std::collections::HashMap;
use codex_config::AppToolApproval;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
@@ -75,6 +78,18 @@ 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 {
@@ -84,6 +99,16 @@ impl From<&EffectiveMcpServer> for McpServerMetadata {
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(),
},
}
}