Allow selective MCP tool preloading

This commit is contained in:
Jiayu Huang
2026-06-22 21:35:44 -07:00
parent e0ac5d3c15
commit 9c3cd7a627
8 changed files with 145 additions and 4 deletions

View File

@@ -23,6 +23,7 @@ use crate::types::PluginConfig;
use crate::types::SandboxWorkspaceWrite;
use crate::types::ShellEnvironmentPolicyToml;
use crate::types::SkillsConfig;
use crate::types::ToolSearchConfig;
use crate::types::ToolSuggestConfig;
use crate::types::Tui;
use crate::types::UriBasedFileOpener;
@@ -436,6 +437,9 @@ pub struct ConfigToml {
/// Additional discoverable tools that can be suggested for installation.
pub tool_suggest: Option<ToolSuggestConfig>,
/// MCP tools to expose directly instead of discovering through tool_search.
pub tool_search: Option<ToolSearchConfig>,
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,

View File

@@ -276,6 +276,14 @@ pub struct ToolSuggestConfig {
pub disabled_tools: Vec<ToolSuggestDisabledTool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ToolSearchConfig {
/// Raw MCP tool names to expose directly while leaving other MCP tools deferred.
#[serde(default)]
pub preloaded_tools: Vec<String>,
}
/// Memories settings loaded from config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]

View File

@@ -2884,6 +2884,20 @@
},
"type": "object"
},
"ToolSearchConfig": {
"additionalProperties": false,
"properties": {
"preloaded_tools": {
"default": [],
"description": "Raw MCP tool names to expose directly while leaving other MCP tools deferred.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"ToolSuggestConfig": {
"additionalProperties": false,
"properties": {
@@ -5436,6 +5450,14 @@
"minimum": 0.0,
"type": "integer"
},
"tool_search": {
"allOf": [
{
"$ref": "#/definitions/ToolSearchConfig"
}
],
"description": "MCP tools to expose directly instead of discovering through tool_search."
},
"tool_suggest": {
"allOf": [
{

View File

@@ -46,6 +46,7 @@ use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSearchConfig;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
@@ -1073,6 +1074,9 @@ pub struct Config {
/// Configured discoverable tools for tool suggestions.
pub tool_suggest: ToolSuggestConfig,
/// MCP tools to expose directly instead of discovering through tool_search.
pub tool_search: ToolSearchConfig,
/// OTEL configuration (exporter type, endpoint, headers, etc.).
pub otel: codex_config::types::OtelConfig,
}
@@ -3015,6 +3019,7 @@ impl Config {
}
let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack);
let tool_search = cfg.tool_search.clone().unwrap_or_default();
let feature_overrides = FeatureOverrides {
web_search_request: override_tools_web_search_request,
};
@@ -3926,6 +3931,7 @@ impl Config {
.and_then(|feedback| feedback.enabled)
.unwrap_or(true),
tool_suggest,
tool_search,
tui_notifications: cfg
.tui
.as_ref()

View File

@@ -22,9 +22,9 @@ pub(crate) fn build_mcp_tool_exposure(
config: &Config,
search_tool_enabled: bool,
) -> McpToolExposure {
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
let mut eligible_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
if let Some(connectors) = connectors {
deferred_tools.extend(filter_codex_apps_mcp_tools(
eligible_tools.extend(filter_codex_apps_mcp_tools(
all_mcp_tools,
connectors,
config,
@@ -33,13 +33,21 @@ pub(crate) fn build_mcp_tool_exposure(
if !search_tool_enabled {
return McpToolExposure {
direct_tools: deferred_tools,
direct_tools: eligible_tools,
deferred_tools: None,
};
}
let (direct_tools, deferred_tools) = eligible_tools.into_iter().partition(|tool| {
config
.tool_search
.preloaded_tools
.iter()
.any(|name| name == tool.tool.name.as_ref())
});
McpToolExposure {
direct_tools: Vec::new(),
direct_tools,
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
}
}

View File

@@ -294,3 +294,43 @@ async fn defers_apps_and_non_app_mcp_tools() {
"_create_event"
)));
}
#[tokio::test]
async fn preloaded_tools_stay_direct_when_other_tools_are_deferred() {
let mut config = test_config().await;
config.tool_search.preloaded_tools = vec!["calendar_create_event".to_string()];
let deferred_tool = make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
);
let preloaded_tool = make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
"mcp__codex_apps__calendar",
"_create_event",
Some("calendar"),
Some("Calendar"),
);
let connectors = vec![make_connector("calendar", "Calendar")];
let exposure = build_mcp_tool_exposure(
&[deferred_tool.clone(), preloaded_tool.clone()],
Some(connectors.as_slice()),
&config,
/*search_tool_enabled*/ true,
);
assert_eq!(
tool_names(&exposure.direct_tools),
tool_names(&[preloaded_tool])
);
let deferred_tools = exposure
.deferred_tools
.as_ref()
.expect("non-preloaded MCP tools should remain deferred");
assert_eq!(tool_names(deferred_tools), tool_names(&[deferred_tool]));
}

View File

@@ -217,6 +217,58 @@ async fn small_app_tool_sets_are_deferred_by_default() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn preloaded_app_tool_stays_direct_when_other_tools_are_deferred() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder =
configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| {
config.tool_search.preloaded_tools = vec!["calendar_create_event".to_string()];
});
let test = builder.build(&server).await?;
test.submit_turn_with_approval_and_permission_profile(
"list tools",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let body = mock.single_request().body_json();
let tools = tool_names(&body);
assert!(
tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
"tool_search should remain available for deferred tools: {tools:?}"
);
assert!(
namespace_child_tool(
&body,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
)
.is_some(),
"preloaded app tool should be directly exposed: {tools:?}"
);
assert!(
namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_none(),
"non-preloaded app tools should remain deferred: {tools:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -292,6 +292,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
analytics_enabled: Some(false),
feedback_enabled: false,
tool_suggest: ToolSuggestConfig::default(),
tool_search: Default::default(),
otel: OtelConfig::default(),
};
config