[mcp] Add dummy tools for previously called but currently missing tools. (#17853)

- [x] Add dummy tools for previously called but currently missing tools.
Currently supporting MCP tools only.
This commit is contained in:
Matthew Zeng
2026-04-15 14:48:05 -07:00
committed by GitHub
parent 9d1bf002c6
commit 28b76d13fe
16 changed files with 473 additions and 3 deletions

View File

@@ -287,6 +287,7 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(listed_mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names,
discoverable_tools: None,
dynamic_tools: exec.turn.dynamic_tools.as_slice(),

View File

@@ -15,6 +15,7 @@ mod shell;
mod test_sync;
mod tool_search;
mod tool_suggest;
mod unavailable_tool;
pub(crate) mod unified_exec;
mod view_image;
@@ -49,6 +50,8 @@ pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use tool_search::ToolSearchHandler;
pub use tool_suggest::ToolSuggestHandler;
pub use unavailable_tool::UnavailableToolHandler;
pub(crate) use unavailable_tool::unavailable_tool_message;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;

View File

@@ -0,0 +1,44 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct UnavailableToolHandler;
pub(crate) fn unavailable_tool_message(
tool_name: impl std::fmt::Display,
next_step: &str,
) -> String {
format!(
"Tool `{tool_name}` is not currently available. It appeared in earlier tool calls in this conversation, but its implementation is not available in the current request. {next_step}"
)
}
impl ToolHandler for UnavailableToolHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
tool_name, payload, ..
} = invocation;
match payload {
ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text(
unavailable_tool_message(
tool_name.display(),
"Retry after the tool becomes available or ask the user to re-enable it.",
),
Some(false),
)),
_ => Err(FunctionCallError::RespondToModel(
"unavailable tool handler received unsupported payload".to_string(),
)),
}
}
}

View File

@@ -1568,6 +1568,7 @@ impl JsReplManager {
crate::tools::router::ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
// JS REPL dispatches nested tool calls directly, not through
// `ToolCallRuntime`'s parallel scheduling lock.
parallel_mcp_server_names: std::collections::HashSet::new(),

View File

@@ -44,6 +44,7 @@ pub struct ToolRouter {
pub(crate) struct ToolRouterParams<'a> {
pub(crate) mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) unavailable_called_tools: Vec<ToolName>,
pub(crate) parallel_mcp_server_names: HashSet<String>,
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
@@ -54,6 +55,7 @@ impl ToolRouter {
let ToolRouterParams {
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
parallel_mcp_server_names,
discoverable_tools,
dynamic_tools,
@@ -62,6 +64,7 @@ impl ToolRouter {
config,
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
discoverable_tools,
dynamic_tools,
);

View File

@@ -33,6 +33,7 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
ToolRouterParams {
deferred_mcp_tools,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -86,6 +87,7 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
ToolRouterParams {
deferred_mcp_tools,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -132,6 +134,7 @@ async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<(
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -182,6 +185,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -254,6 +258,7 @@ async fn mcp_parallel_support_uses_exact_payload_server() -> anyhow::Result<()>
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::from(["echo".to_string()]),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),

View File

@@ -7,8 +7,12 @@ use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::AdditionalProperties;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolHandlerKind;
use codex_tools::ToolName;
use codex_tools::ToolNamespace;
use codex_tools::ToolRegistryPlanDeferredTool;
use codex_tools::ToolRegistryPlanMcpTool;
@@ -16,8 +20,10 @@ use codex_tools::ToolRegistryPlanParams;
use codex_tools::ToolUserShellType;
use codex_tools::ToolsConfig;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::augment_tool_spec_for_code_mode;
use codex_tools::build_tool_registry_plan;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
@@ -66,6 +72,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
@@ -86,6 +93,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnavailableToolHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
@@ -99,6 +107,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::tools::handlers::unavailable_tool_message;
let mut builder = ToolRegistryBuilder::new();
let mcp_tool_plan_inputs = mcp_tools.as_ref().map(map_mcp_tools_for_plan);
@@ -154,6 +163,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
let mut existing_spec_names = plan
.specs
.iter()
.map(|configured_tool| configured_tool.name().to_string())
.collect::<HashSet<_>>();
for spec in plan.specs {
if spec.supports_parallel_tool_calls {
@@ -278,6 +293,34 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler(name.clone(), mcp_handler.clone());
}
}
for unavailable_tool in unavailable_called_tools {
let tool_name = unavailable_tool.display();
if existing_spec_names.insert(tool_name.clone()) {
let spec = codex_tools::ToolSpec::Function(ResponsesApiTool {
name: tool_name.clone(),
description: unavailable_tool_message(
&tool_name,
"Calling this placeholder returns an error explaining that the tool is unavailable.",
),
strict: false,
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
Some(AdditionalProperties::Boolean(false)),
),
output_schema: None,
defer_loading: None,
});
let spec = if config.code_mode_enabled {
augment_tool_spec_for_code_mode(spec)
} else {
spec
};
builder.push_spec(spec);
}
builder.register_handler(unavailable_tool, unavailable_tool_handler.clone());
}
builder
}

View File

@@ -16,6 +16,7 @@ use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_tools::AdditionalProperties;
use codex_tools::ConfiguredToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
@@ -267,11 +268,28 @@ fn build_specs(
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_unavailable_tools(
config,
mcp_tools,
deferred_mcp_tools,
Vec::new(),
dynamic_tools,
)
}
fn build_specs_with_unavailable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_discoverable_tools(
config,
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
/*discoverable_tools*/ None,
dynamic_tools,
)
@@ -356,6 +374,7 @@ fn assert_model_tools(
ToolRouterParams {
mcp_tools: None,
deferred_mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: std::collections::HashSet::new(),
discoverable_tools: None,
dynamic_tools: &[],
@@ -770,6 +789,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
Vec::new(),
discoverable_tools.clone(),
&[],
)
@@ -991,6 +1011,55 @@ fn direct_mcp_tools_register_namespaced_handlers() {
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
}
#[test]
fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
let config = test_config();
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let unavailable_tool = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event");
let (tools, registry) = build_specs_with_unavailable_tools(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
vec![unavailable_tool],
&[],
)
.build();
let tool = find_tool(&tools, "mcp__codex_apps__calendar_create_event");
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
..
}) = &tool.spec
else {
panic!("unavailable MCP tool should be exposed as a function tool");
};
assert!(description.contains("not currently available"));
assert_eq!(
parameters.additional_properties,
Some(AdditionalProperties::Boolean(false))
);
assert!(registry.has_handler(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)));
assert!(!registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();