Files
codex/codex-rs/core/src/tools/context_tests.rs
tongzhou wang 51c9ed6d4f Add per-surface MCP tool exposure controls (#36781)
## Why

MCP tools can be exposed directly, discovered through tool search, or called
from Code Mode. Servers need to be able to opt out of any of these surfaces
without disabling their tools everywhere.

## What changed

- Add `omit_tools_from` to MCP server configuration, accepting any combination
  of `direct`, `deferred`, and `code_mode`.
- Apply the exclusions independently when building direct, deferred, and Code
  Mode tool surfaces while keeping omitted tools registered for permitted uses.
- Remove client-private `_meta` fields from MCP results returned to Code Mode.
- Include the new setting in the configuration schema and MCP config
  serialization.

## Testing

- Cover every exposure combination across Code Mode, Code-Mode-only sessions,
  tool search, direct-only namespaces, and prefixed and unprefixed MCP names.
- Verify direct and nested execution paths, parallel-call support, config
  round-tripping, and `_meta` filtering.

GitOrigin-RevId: 12dfcb78bb5c5ecf4d70f38a8b5022792463a27f
2026-08-03 18:51:46 +00:00

508 lines
16 KiB
Rust

use super::*;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::SearchToolCallParams;
use core_test_support::assert_regex_match;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
let payload = ToolPayload::Custom {
input: "patch".to_string(),
};
let response = FunctionToolOutput::from_text("patched".to_string(), Some(true))
.to_response_item("call-42", &payload);
match response {
ResponseInputItem::CustomToolCallOutput {
call_id, output, ..
} => {
assert_eq!(call_id, "call-42");
assert_eq!(output.content_items(), None);
assert_eq!(output.body.to_text().as_deref(), Some("patched"));
assert_eq!(output.success, Some(true));
}
other => panic!("expected CustomToolCallOutput, got {other:?}"),
}
}
#[test]
fn function_payloads_remain_function_outputs() {
let payload = ToolPayload::Function {
arguments: "{}".to_string(),
};
let response = FunctionToolOutput::from_text("ok".to_string(), Some(true))
.to_response_item("fn-1", &payload);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "fn-1");
assert_eq!(output.content_items(), None);
assert_eq!(output.body.to_text().as_deref(), Some("ok"));
assert_eq!(output.success, Some(true));
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_code_mode_result_omits_private_metadata() {
let output = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"threadId": "thread_123",
"content": "done",
})),
is_error: Some(false),
meta: Some(serde_json::json!({
"source": "mcp",
})),
};
let result = output.code_mode_result(&ToolPayload::Function {
arguments: "{}".to_string(),
});
assert_eq!(
result,
serde_json::json!({
"content": [{
"type": "text",
"text": "ignored",
}],
"structuredContent": {
"threadId": "thread_123",
"content": "done",
},
"isError": false,
})
);
assert_eq!(output.meta, Some(serde_json::json!({ "source": "mcp" })));
}
#[test]
fn mcp_tool_output_response_item_includes_wall_time() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "done",
})],
structured_content: None,
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(1024),
};
let response = output.to_response_item(
"mcp-call-1",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "mcp-call-1");
assert_eq!(output.success, Some(true));
let Some(text) = output.body.to_text() else {
panic!("MCP output should serialize as text");
};
let Some(payload) = text.strip_prefix("Wall time: 1.2500 seconds\nOutput:\n") else {
panic!("MCP output should include wall-time header: {text}");
};
let parsed: serde_json::Value = serde_json::from_str(payload).unwrap_or_else(|err| {
panic!("MCP output should serialize JSON content: {err}");
});
assert_eq!(
parsed,
json!([{
"type": "text",
"text": "done",
}])
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_response_item_truncates_large_structured_content() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored when structured content is present",
})],
structured_content: Some(serde_json::json!({
"items": "large structured value ".repeat(1_000),
})),
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(128),
};
let response = output.to_response_item(
"mcp-call-large",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "mcp-call-large");
assert_eq!(output.success, Some(true));
let text = output
.body
.to_text()
.expect("MCP output should serialize as text");
assert!(text.starts_with("Wall time: 1.2500 seconds\nOutput:\n"));
assert!(text.contains("chars truncated"));
assert!(!text.contains("ignored when structured content is present"));
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_response_item_preserves_content_items() {
let image_url = "data:image/png;base64,AAA";
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"mimeType": "image/png",
"data": "AAA",
})],
structured_content: None,
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(500),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(1024),
};
let response = output.to_response_item(
"mcp-call-2",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
assert_eq!(
output.content_items(),
Some(
vec![
FunctionCallOutputContentItem::InputText {
text: "Wall time: 0.5000 seconds\nOutput:".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: image_url.to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
]
.as_slice()
)
);
assert_eq!(
output.body.to_text().as_deref(),
Some("Wall time: 0.5000 seconds\nOutput:")
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_code_mode_result_preserves_content_without_private_metadata() {
let large_content = "large structured value ".repeat(1_000);
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"content": large_content,
})),
is_error: Some(false),
meta: Some(serde_json::json!({
"hive_dispatch_id": "private-dispatch-id",
})),
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(64),
};
let result = output.code_mode_result(&ToolPayload::Function {
arguments: "{}".to_string(),
});
assert_eq!(
result,
serde_json::json!({
"content": [{
"type": "text",
"text": "ignored",
}],
"structuredContent": {
"content": "large structured value ".repeat(1_000),
},
"isError": false,
})
);
assert_eq!(
output.result.meta,
Some(serde_json::json!({ "hive_dispatch_id": "private-dispatch-id" }))
);
}
#[test]
fn custom_tool_calls_can_derive_text_from_content_items() {
let payload = ToolPayload::Custom {
input: "patch".to_string(),
};
let response = FunctionToolOutput::from_content(
vec![
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: "line 2".to_string(),
},
],
Some(true),
)
.to_response_item("call-99", &payload);
match response {
ResponseInputItem::CustomToolCallOutput {
call_id, output, ..
} => {
let expected = vec![
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: "line 2".to_string(),
},
];
assert_eq!(call_id, "call-99");
assert_eq!(output.content_items(), Some(expected.as_slice()));
assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2"));
assert_eq!(output.success, Some(true));
}
other => panic!("expected CustomToolCallOutput, got {other:?}"),
}
}
#[test]
fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
let payload = ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: "calendar".to_string(),
limit: None,
},
};
let response = ToolSearchOutput {
tools: vec![LoadableToolSpec::Function(codex_tools::ResponsesApiTool {
name: "create_event".to_string(),
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
/*properties*/ Default::default(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
})],
}
.to_response_item("search-1", &payload);
match response {
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => {
assert_eq!(call_id, "search-1");
assert_eq!(status, "completed");
assert_eq!(execution, "client");
assert_eq!(
tools,
vec![json!({
"type": "function",
"name": "create_event",
"description": "",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
})]
);
}
other => panic!("expected ToolSearchOutput, got {other:?}"),
}
}
#[test]
fn log_preview_uses_content_items_when_plain_text_is_missing() {
let output = FunctionToolOutput::from_content(
vec![FunctionCallOutputContentItem::InputText {
text: "preview".to_string(),
}],
Some(true),
);
assert_eq!(output.log_preview(), "preview");
assert_eq!(
function_call_output_content_items_to_text(&output.body),
Some("preview".to_string())
);
}
#[test]
fn telemetry_preview_returns_original_within_limits() {
let content = "short output";
assert_eq!(telemetry_preview(content), content);
}
#[test]
fn telemetry_preview_truncates_by_bytes() {
let content = "x".repeat(TELEMETRY_PREVIEW_MAX_BYTES + 8);
let preview = telemetry_preview(&content);
assert!(preview.contains(TELEMETRY_PREVIEW_TRUNCATION_NOTICE));
assert!(
preview.len()
<= TELEMETRY_PREVIEW_MAX_BYTES + TELEMETRY_PREVIEW_TRUNCATION_NOTICE.len() + 1
);
}
#[test]
fn telemetry_preview_truncates_by_lines() {
let content = (0..(TELEMETRY_PREVIEW_MAX_LINES + 5))
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>()
.join("\n");
let preview = telemetry_preview(&content);
let lines: Vec<&str> = preview.lines().collect();
assert!(lines.len() <= TELEMETRY_PREVIEW_MAX_LINES + 1);
assert_eq!(lines.last(), Some(&TELEMETRY_PREVIEW_TRUNCATION_NOTICE));
}
#[test]
fn exec_command_tool_output_formats_truncated_response() {
let payload = ToolPayload::Function {
arguments: "{}".to_string(),
};
let response = ExecCommandToolOutput {
event_call_id: "call-42".to_string(),
chunk_id: "abc123".to_string(),
wall_time: std::time::Duration::from_millis(1250),
raw_output: b"token one token two token three token four token five".to_vec(),
truncation_policy: TruncationPolicy::Tokens(10_000),
max_output_tokens: Some(4),
process_id: None,
exit_code: Some(0),
original_token_count: Some(10),
output_omitted_bytes: None,
hook_command: None,
}
.to_response_item("call-42", &payload);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "call-42");
assert_eq!(output.success, Some(true));
let text = output
.body
.to_text()
.expect("exec output should serialize as text");
assert_regex_match(
r#"(?sx)
^Chunk\ ID:\ abc123
\nWall\ time:\ \d+\.\d{4}\ seconds
\nProcess\ exited\ with\ code\ 0
\nOriginal\ token\ count:\ 10
\nOutput:
\n.*tokens\ truncated.*
$"#,
&text,
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn exec_command_tool_output_preserves_omission_metadata_when_truncated() {
let payload = ToolPayload::Function {
arguments: "{}".to_string(),
};
let marker = format_output_omission_marker(/*omitted_bytes*/ 123_456);
let raw_output = format!(
"HEAD-{}\n{marker}\nTAIL-{}",
"a".repeat(/*n*/ 100),
"z".repeat(/*n*/ 100)
)
.into_bytes();
let response = ExecCommandToolOutput {
event_call_id: "call-omitted".to_string(),
chunk_id: "abc123".to_string(),
wall_time: std::time::Duration::from_millis(/*millis*/ 1250),
raw_output,
truncation_policy: TruncationPolicy::Tokens(10_000),
max_output_tokens: Some(4),
process_id: None,
exit_code: Some(0),
original_token_count: Some(42_000),
output_omitted_bytes: NonZeroUsize::new(/*n*/ 123_456),
hook_command: None,
}
.to_response_item("call-omitted", &payload);
let ResponseInputItem::FunctionCallOutput { output, .. } = response else {
panic!("expected FunctionCallOutput");
};
let text = output
.body
.to_text()
.expect("exec output should serialize as text");
assert!(text.contains("Original token count: 42000"));
assert!(text.contains("Warning: truncated output (original token count: 42000)"));
assert_eq!(text.matches(&marker).count(), 1);
}