mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +00:00
register all mcp tools with namespace (#17404)
stacked on #17402. MCP tools returned by `tool_search` (deferred tools) get registered in our `ToolRegistry` with a different format than directly available tools. this leads to two different ways of accessing MCP tools from our tool catalog, only one of which works for each. fix this by registering all MCP tools with the namespace format, since this info is already available. also, direct MCP tools are registered to responsesapi without a namespace, while deferred MCP tools have a namespace. this means we can receive MCP `FunctionCall`s in both formats from namespaces. fix this by always registering MCP tools with namespace, regardless of deferral status. make code mode track `ToolName` provenance of tools so it can map the literal JS function name string to the correct `ToolName` for invocation, rather than supporting both in core. this lets us unify to a single canonical `ToolName` representation for each MCP tool and force everywhere to use that one, without supporting fallbacks.
This commit is contained in:
@@ -177,12 +177,25 @@ async fn run_code_mode_turn_with_rmcp(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
code: &str,
|
||||
) -> Result<(TestCodex, ResponseMock)> {
|
||||
run_code_mode_turn_with_rmcp_mode(server, prompt, code, /*code_mode_only*/ false).await
|
||||
}
|
||||
|
||||
async fn run_code_mode_turn_with_rmcp_mode(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
code: &str,
|
||||
code_mode_only: bool,
|
||||
) -> Result<(TestCodex, ResponseMock)> {
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let mut builder = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
let _ = if code_mode_only {
|
||||
config.features.enable(Feature::CodeModeOnly)
|
||||
} else {
|
||||
config.features.enable(Feature::CodeMode)
|
||||
};
|
||||
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
@@ -1989,6 +2002,36 @@ contentLength=0"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_only_can_call_mcp_tool() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let code = r#"
|
||||
const result = await tools.mcp__rmcp__echo({ message: "ping" });
|
||||
text(`echo=${result.structuredContent?.echo ?? "missing"}`);
|
||||
"#;
|
||||
|
||||
let (_test, second_mock) = run_code_mode_turn_with_rmcp_mode(
|
||||
&server,
|
||||
"use exec to run the rmcp echo tool in code mode only",
|
||||
code,
|
||||
/*code_mode_only*/ true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"code_mode_only rmcp tool call failed unexpectedly: {output}"
|
||||
);
|
||||
assert_eq!(output, "echo=ECHOING: ping");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exposes_mcp_tools_on_global_tools_object() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::responses;
|
||||
@@ -12,12 +14,15 @@ use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
@@ -594,6 +599,82 @@ async fn js_repl_can_invoke_builtin_tools() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_can_invoke_mcp_tools_by_display_name() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::JsRepl)
|
||||
.expect("test config should allow feature update");
|
||||
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
"rmcp".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: rmcp_test_server_bin,
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"js_repl",
|
||||
r#"
|
||||
const result = await codex.tool("mcp__rmcp__echo", { message: "ping" });
|
||||
console.log(result.output);
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let final_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("use js_repl to call an MCP tool").await?;
|
||||
|
||||
let req = final_mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "ECHOING: ping");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -10,7 +10,7 @@ use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_function_call_with_namespace;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
@@ -26,7 +26,8 @@ use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DOCUMENT_EXTRACT_TOOL: &str = "mcp__codex_apps__calendar_extract_text";
|
||||
const DOCUMENT_EXTRACT_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const DOCUMENT_EXTRACT_TOOL: &str = "_extract_text";
|
||||
|
||||
fn configure_apps(config: &mut Config, chatgpt_base_url: &str) {
|
||||
if let Err(err) = config.features.enable(Feature::Apps) {
|
||||
@@ -35,18 +36,6 @@ fn configure_apps(config: &mut Config, chatgpt_base_url: &str) {
|
||||
config.chatgpt_base_url = chatgpt_base_url.to_string();
|
||||
}
|
||||
|
||||
fn tool_by_name<'a>(body: &'a Value, name: &str) -> &'a Value {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find(|tool| {
|
||||
tool.get("name").and_then(Value::as_str) == Some(name)
|
||||
|| tool.get("type").and_then(Value::as_str) == Some(name)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing tool {name} in /v1/responses request: {body:?}"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
@@ -93,8 +82,9 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
ev_function_call_with_namespace(
|
||||
call_id,
|
||||
DOCUMENT_EXTRACT_NAMESPACE,
|
||||
DOCUMENT_EXTRACT_TOOL,
|
||||
&json!({"file": "report.txt"}).to_string(),
|
||||
),
|
||||
@@ -123,8 +113,14 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
let body = requests[0].body_json();
|
||||
let extract_tool = tool_by_name(&body, DOCUMENT_EXTRACT_TOOL);
|
||||
let Some(extract_tool) =
|
||||
requests[0].tool_by_name(DOCUMENT_EXTRACT_NAMESPACE, DOCUMENT_EXTRACT_TOOL)
|
||||
else {
|
||||
let body = requests[0].body_json();
|
||||
panic!(
|
||||
"missing tool {DOCUMENT_EXTRACT_NAMESPACE}{DOCUMENT_EXTRACT_TOOL} in /v1/responses request: {body:?}"
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
extract_tool.pointer("/parameters/properties/file"),
|
||||
Some(&json!({
|
||||
|
||||
@@ -168,22 +168,6 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find_map(|tool| {
|
||||
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
|
||||
tool.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn capability_sections_render_in_developer_message_in_order() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -319,20 +303,27 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
assert!(
|
||||
request_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__google_calendar_create_event"),
|
||||
.any(|name| name == "mcp__codex_apps__google_calendar"),
|
||||
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||
);
|
||||
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||
let echo_tool = request
|
||||
.tool_by_name("mcp__sample__", "echo")
|
||||
.expect("plugin MCP tool should be present");
|
||||
let echo_description = echo_tool
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("plugin MCP tool description should be present");
|
||||
assert!(
|
||||
echo_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||
);
|
||||
let calendar_description = tool_description(
|
||||
&request_body,
|
||||
"mcp__codex_apps__google_calendar_create_event",
|
||||
)
|
||||
.expect("plugin app tool description should be present");
|
||||
let calendar_tool = request
|
||||
.tool_by_name("mcp__codex_apps__google_calendar", "_create_event")
|
||||
.expect("plugin app tool should be present");
|
||||
let calendar_description = calendar_tool
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("plugin app tool description should be present");
|
||||
assert!(
|
||||
calendar_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin app provenance in tool description: {calendar_description:?}"
|
||||
|
||||
@@ -91,13 +91,18 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-123";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
let call_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -223,6 +228,13 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let request = call_mock.single_request();
|
||||
assert!(
|
||||
request.tool_by_name(&namespace, "echo").is_some(),
|
||||
"direct MCP tool should be sent as a namespace child tool: {:?}",
|
||||
request.body_json()
|
||||
);
|
||||
|
||||
let output_text = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
@@ -246,13 +258,14 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
|
||||
let call_id = "sandbox-meta-call";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__sandbox_meta");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let tool_name = format!("{namespace}sandbox_meta");
|
||||
|
||||
mount_sse_once(
|
||||
let call_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "sandbox_meta", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -301,11 +314,43 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
fixture.codex.submit(Op::ListMcpTools).await?;
|
||||
let list_event = wait_for_event_with_timeout(
|
||||
&fixture.codex,
|
||||
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
|
||||
unreachable!("event guard guarantees McpListToolsResponse");
|
||||
};
|
||||
if tool_list.tools.contains_key(&tool_name) {
|
||||
break;
|
||||
}
|
||||
|
||||
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
|
||||
if Instant::now() >= tools_ready_deadline {
|
||||
panic!(
|
||||
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
fixture
|
||||
.submit_turn_with_policy("call the rmcp sandbox_meta tool", sandbox_policy.clone())
|
||||
.await?;
|
||||
|
||||
let request = call_mock.single_request();
|
||||
assert!(
|
||||
request.tool_by_name(&namespace, "sandbox_meta").is_some(),
|
||||
"direct MCP tool should be sent as a namespace child tool: {:?}",
|
||||
request.body_json()
|
||||
);
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let output_text = output_item
|
||||
.get("output")
|
||||
@@ -346,15 +391,15 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::
|
||||
let first_call_id = "sync-serial-1";
|
||||
let second_call_id = "sync-serial-2";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__sync");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let args = json!({ "sleep_after_ms": 100 }).to_string();
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(first_call_id, &tool_name, &args),
|
||||
responses::ev_function_call(second_call_id, &tool_name, &args),
|
||||
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
|
||||
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -488,7 +533,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
let first_call_id = "sync-1";
|
||||
let second_call_id = "sync-2";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__sync");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let args = json!({
|
||||
"sleep_after_ms": 100,
|
||||
"barrier": {
|
||||
@@ -503,8 +548,8 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(first_call_id, &tool_name, &args),
|
||||
responses::ev_function_call(second_call_id, &tool_name, &args),
|
||||
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
|
||||
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -604,13 +649,14 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
let call_id = "img-1";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
// First stream: model decides to call the image tool.
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -793,7 +839,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
|
||||
let call_id = "img-text-only-1";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let text_only_model_slug = "rmcp-text-only-model";
|
||||
|
||||
let models_mock = mount_models_once(
|
||||
@@ -843,7 +889,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -964,13 +1010,18 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-1234";
|
||||
let server_name = "rmcp_whitelist";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -1106,13 +1157,18 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-456";
|
||||
let server_name = "rmcp_http";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -1311,12 +1367,18 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
let call_id = "call-789";
|
||||
let server_name = "rmcp_http_oauth";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::ev_tool_search_call;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -45,6 +46,7 @@ const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event";
|
||||
const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events";
|
||||
const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event";
|
||||
const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events";
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
@@ -215,8 +217,17 @@ async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result
|
||||
let body = mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
|
||||
assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));
|
||||
assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL));
|
||||
assert!(
|
||||
namespace_child_tool(
|
||||
&body,
|
||||
SEARCH_CALENDAR_NAMESPACE,
|
||||
SEARCH_CALENDAR_CREATE_TOOL
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -332,6 +343,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> {
|
||||
assert!(tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
|
||||
assert!(!tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));
|
||||
assert!(!tools.iter().any(|name| name == CALENDAR_LIST_TOOL));
|
||||
assert!(!tools.iter().any(|name| name == SEARCH_CALENDAR_NAMESPACE));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,11 +377,16 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
||||
let body = mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
tools.iter().any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
namespace_child_tool(
|
||||
&body,
|
||||
SEARCH_CALENDAR_NAMESPACE,
|
||||
SEARCH_CALENDAR_CREATE_TOOL
|
||||
)
|
||||
.is_some(),
|
||||
"expected explicit app mention to expose create tool, got tools: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
tools.iter().any(|name| name == CALENDAR_LIST_TOOL),
|
||||
namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some(),
|
||||
"expected explicit app mention to expose list tool, got tools: {tools:?}"
|
||||
);
|
||||
|
||||
@@ -523,6 +540,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"app tools should still be hidden before search: {first_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!first_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"app namespace should still be hidden before search: {first_request_tools:?}"
|
||||
);
|
||||
|
||||
let output_item = tool_search_output_item(&requests[1], call_id);
|
||||
assert_eq!(
|
||||
@@ -570,6 +593,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!second_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"follow-up request should rely on tool_search_output history, not namespace injection: {second_request_tools:?}"
|
||||
);
|
||||
|
||||
let output_item = requests[2].function_call_output("calendar-call-1");
|
||||
assert_eq!(
|
||||
@@ -584,6 +613,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"post-tool follow-up should still rely on tool_search_output history, not tool injection: {third_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!third_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"post-tool follow-up should still rely on tool_search_output history, not namespace injection: {third_request_tools:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -683,16 +718,19 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
|
||||
.any(|name| name == "mcp__rmcp__echo"),
|
||||
"non-app MCP tools should be hidden before search in large-search mode: {first_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!first_request_tools.iter().any(|name| name == "mcp__rmcp__"),
|
||||
"non-app MCP namespace should be hidden before search in large-search mode: {first_request_tools:?}"
|
||||
);
|
||||
|
||||
let echo_tools = tool_search_output_tools(&requests[1], echo_call_id);
|
||||
let rmcp_echo_tools = echo_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp__"))
|
||||
.flat_map(|namespace| namespace.get("tools").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|tool| tool.get("name").and_then(Value::as_str).map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rmcp_echo_tools, vec!["echo".to_string()]);
|
||||
let echo_output = json!({ "tools": echo_tools });
|
||||
let rmcp_echo_tool = namespace_child_tool(&echo_output, "mcp__rmcp__", "echo")
|
||||
.expect("tool_search should return rmcp echo as a namespace child tool");
|
||||
assert_eq!(
|
||||
rmcp_echo_tool.get("type").and_then(Value::as_str),
|
||||
Some("function")
|
||||
);
|
||||
|
||||
let image_tools = tool_search_output_tools(&requests[1], image_call_id);
|
||||
let found_rmcp_image_tool = image_tools
|
||||
|
||||
@@ -325,12 +325,17 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "call-123";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -331,7 +331,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
|
||||
let call_id = "rmcp-truncated";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
// Build a very large message to exceed 10KiB once serialized.
|
||||
let large_msg = "long-message-with-newlines-".repeat(6000);
|
||||
@@ -341,7 +341,12 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
&server,
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, &args_json.to_string()),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
&args_json.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -426,13 +431,13 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
|
||||
let call_id = "rmcp-image-no-trunc";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -705,7 +710,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
|
||||
let call_id = "rmcp-untruncated";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let large_msg = "a".repeat(80_000);
|
||||
let args_json = serde_json::json!({ "message": large_msg });
|
||||
|
||||
@@ -713,7 +718,12 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
&server,
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, &args_json.to_string()),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
&args_json.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user