Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions codex-rs/config/src/merge_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use super::*;
use crate::config_toml::ConfigToml;
use crate::types::MemoriesToml;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::BTreeSet;

fn parse_toml(value: &str) -> TomlValue {
toml::from_str(value).expect("TOML should parse")
Expand Down Expand Up @@ -124,3 +126,40 @@ fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() {
);
assert_eq!(base, expected);
}

#[test]
fn server_registered_mcp_tool_policy_replaces_lower_layer_policy() {
let mut base = parse_toml(
r#"
[features.server_registered_tools_only]
enabled = true
mcp_tools = [
{ server_name = "user_server", tool_name = "write" },
]
"#,
);
let overlay = parse_toml(
r#"
[features.server_registered_tools_only]
enabled = true
mcp_tools = [
{ server_name = "hoopa", tool_name = "list_addresses" },
{ server_name = "hoopa", tool_name = "read_message" },
]
"#,
);

merge_toml_values(&mut base, &overlay);

let config: ConfigToml = base.try_into().expect("merged config should deserialize");
assert_eq!(
config
.features
.expect("features should be present")
.server_registered_mcp_tools(),
BTreeMap::from([(
"hoopa".to_string(),
BTreeSet::from(["list_addresses".to_string(), "read_message".to_string()]),
)])
);
}
9 changes: 9 additions & 0 deletions codex-rs/config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
);
continue;
}
if feature.id == codex_features::Feature::ServerRegisteredToolsOnly {
validation.properties.insert(
feature.key.to_string(),
schema_gen.subschema_for::<codex_features::FeatureToml<
codex_features::ServerRegisteredToolsOnlyConfigToml,
>>(),
);
continue;
}
if feature.id == codex_features::Feature::TokenBudget {
validation.properties.insert(
feature.key.to_string(),
Expand Down
48 changes: 48 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,9 @@
"secret_auth_storage": {
"type": "boolean"
},
"server_registered_tools_only": {
"$ref": "#/definitions/FeatureToml_for_ServerRegisteredToolsOnlyConfigToml"
},
"shell_snapshot": {
"type": "boolean"
},
Expand Down Expand Up @@ -993,6 +996,16 @@
}
]
},
"FeatureToml_for_ServerRegisteredToolsOnlyConfigToml": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/ServerRegisteredToolsOnlyConfigToml"
}
]
},
"FeatureToml_for_TokenBudgetConfigToml": {
"anyOf": [
{
Expand Down Expand Up @@ -2783,6 +2796,38 @@
},
"type": "object"
},
"ServerRegisteredMcpToolToml": {
"additionalProperties": false,
"properties": {
"server_name": {
"type": "string"
},
"tool_name": {
"type": "string"
}
},
"required": [
"server_name",
"tool_name"
],
"type": "object"
},
"ServerRegisteredToolsOnlyConfigToml": {
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"mcp_tools": {
"description": "Raw MCP server and action identities authorized by the hosting server. An absent or empty list permits no MCP tools.",
"items": {
"$ref": "#/definitions/ServerRegisteredMcpToolToml"
},
"type": "array"
}
},
"type": "object"
},
"SessionPickerViewMode": {
"description": "Preferred layout for the resume/fork session picker.",
"enum": [
Expand Down Expand Up @@ -5007,6 +5052,9 @@
"secret_auth_storage": {
"type": "boolean"
},
"server_registered_tools_only": {
"$ref": "#/definitions/FeatureToml_for_ServerRegisteredToolsOnlyConfigToml"
},
"shell_snapshot": {
"type": "boolean"
},
Expand Down
11 changes: 11 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ use rmcp::model::UrlElicitationCapability;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::ErrorKind;
Expand Down Expand Up @@ -822,6 +823,10 @@ pub struct Config {
/// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: Constrained<HashMap<String, McpServerConfig>>,

/// Raw MCP identities authorized when only server-registered tools are enabled.
/// An absent server or action is denied at tool-registry construction time.
pub server_registered_mcp_tools: BTreeMap<String, BTreeSet<String>>,

/// Preferred store for MCP OAuth credentials.
/// keyring: Use an OS-specific keyring service.
/// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.
Expand Down Expand Up @@ -3065,6 +3070,11 @@ impl Config {
},
feature_overrides,
);
let server_registered_mcp_tools = cfg
.features
.as_ref()
.map(FeaturesToml::server_registered_mcp_tools)
.unwrap_or_default();
let features = ManagedFeatures::from_configured_with_warnings(
configured_features,
feature_requirements,
Expand Down Expand Up @@ -3829,6 +3839,7 @@ impl Config {
env!("CARGO_PKG_VERSION"),
),
mcp_servers,
server_registered_mcp_tools,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/core/src/config/schema_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,24 @@ fn config_schema_hides_unsupported_inline_mcp_bearer_token() {
(false, true),
);
}

#[test]
fn config_schema_exposes_server_registered_tool_policy() {
let schema_json = config_schema_json().expect("serialize config schema");
let schema_value: serde_json::Value =
serde_json::from_slice(&schema_json).expect("decode schema json");

assert_eq!(
schema_value.pointer("/properties/features/properties/server_registered_tools_only/$ref"),
Some(&serde_json::json!(
"#/definitions/FeatureToml_for_ServerRegisteredToolsOnlyConfigToml"
))
);
assert!(
schema_value
.pointer(
"/definitions/ServerRegisteredToolsOnlyConfigToml/properties/mcp_tools/items/$ref"
)
.is_some()
);
}
77 changes: 60 additions & 17 deletions codex-rs/core/src/tools/spec_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ use codex_tools::request_user_input_available_modes;
use codex_tools::shell_command_backend_for_features;
use codex_tools::shell_type_for_model_and_features;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::sync::Arc;
use tracing::instrument;
Expand Down Expand Up @@ -195,13 +196,30 @@ fn build_tool_specs_and_registry(
wait_agent_timeouts: wait_agent_timeout_options(turn_context),
};
let mut planned_tools = PlannedTools::default();
add_tool_sources(&context, &mut planned_tools);
apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools);
append_tool_search_executor(&context, &mut planned_tools);
prepend_code_mode_executors(&context, &mut planned_tools);
if server_registered_tools_only(turn_context) {
add_mcp_runtime_tools(
&context,
&mut planned_tools,
Some(&turn_context.config.server_registered_mcp_tools),
);
add_dynamic_tools(&context, &mut planned_tools);
} else {
add_tool_sources(&context, &mut planned_tools);
apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools);
append_tool_search_executor(&context, &mut planned_tools);
prepend_code_mode_executors(&context, &mut planned_tools);
}
build_model_visible_specs_and_registry(turn_context, planned_tools)
}

fn server_registered_tools_only(turn_context: &TurnContext) -> bool {
turn_context
.config
.features
.get()
.enabled(Feature::ServerRegisteredToolsOnly)
}

fn apply_direct_model_only_namespace_overrides(
turn_context: &TurnContext,
planned_tools: &mut PlannedTools,
Expand Down Expand Up @@ -240,6 +258,7 @@ fn build_model_visible_specs_and_registry(
runtimes,
hosted_specs,
} = planned_tools;
let server_only = server_registered_tools_only(turn_context);
let mut specs = Vec::new();
let mut seen_tool_names = HashSet::new();
for runtime in &runtimes {
Expand All @@ -248,15 +267,15 @@ fn build_model_visible_specs_and_registry(
continue;
}
let exposure = runtime.exposure();
if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
if exposure.is_direct()
&& (server_only || !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure))
{
let spec = runtime.spec();
specs.push(spec_for_model_request(
turn_context,
exposure,
&tool_name,
spec,
));
specs.push(if server_only {
spec
} else {
spec_for_model_request(turn_context, exposure, &tool_name, spec)
});
}
}
specs.extend(hosted_specs);
Expand Down Expand Up @@ -329,12 +348,15 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
}

pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool {
turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context)
!server_registered_tools_only(turn_context)
&& turn_context.model_info.supports_search_tool
&& namespace_tools_enabled(turn_context)
}

pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool {
let features = turn_context.config.features.get();
features.enabled(Feature::ToolSuggest)
!server_registered_tools_only(turn_context)
&& features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins)
}
Expand Down Expand Up @@ -615,7 +637,7 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
add_mcp_resource_tools(context, planned_tools);
add_core_utility_tools(context, planned_tools);
add_collaboration_tools(context, planned_tools);
add_mcp_runtime_tools(context, planned_tools);
add_mcp_runtime_tools(context, planned_tools, /*allowed_raw_tools*/ None);
add_extension_tools(context, planned_tools);
add_dynamic_tools(context, planned_tools);
for spec in hosted_model_tool_specs(context) {
Expand Down Expand Up @@ -882,9 +904,16 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
deferred_mcp_tool_count = context.deferred_mcp_tools.map_or(0, <[ToolInfo]>::len)
)
)]
fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
fn add_mcp_runtime_tools(
context: &CoreToolPlanContext<'_>,
planned_tools: &mut PlannedTools,
allowed_raw_tools: Option<&BTreeMap<String, BTreeSet<String>>>,
) {
if let Some(mcp_tools) = context.mcp_tools {
for tool in mcp_tools {
for tool in mcp_tools
.iter()
.filter(|tool| raw_mcp_tool_is_allowed(tool, allowed_raw_tools))
{
match McpHandler::new(tool.clone()) {
Ok(handler) => planned_tools.add(handler),
Err(err) => warn!(
Expand All @@ -896,7 +925,10 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}

if let Some(deferred_mcp_tools) = context.deferred_mcp_tools {
for tool in deferred_mcp_tools {
for tool in deferred_mcp_tools
.iter()
.filter(|tool| raw_mcp_tool_is_allowed(tool, allowed_raw_tools))
{
match McpHandler::new(tool.clone()) {
Ok(handler) => planned_tools.add_with_exposure(handler, ToolExposure::Deferred),
Err(err) => warn!(
Expand All @@ -908,6 +940,17 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}
}

fn raw_mcp_tool_is_allowed(
tool: &ToolInfo,
allowed_raw_tools: Option<&BTreeMap<String, BTreeSet<String>>>,
) -> bool {
allowed_raw_tools.is_none_or(|allowed_raw_tools| {
allowed_raw_tools
.get(&tool.server_name)
.is_some_and(|allowed_tools| allowed_tools.contains(tool.tool.name.as_ref()))
})
}

#[instrument(
level = "trace",
skip_all,
Expand Down
Loading
Loading