Files
codex/codex-rs/tools/src/tool_output.rs
T
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

298 lines
9.9 KiB
Rust

use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_utils_string::take_bytes_at_char_boundary;
use serde_json::Value as JsonValue;
use crate::ToolPayload;
const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024;
const TELEMETRY_PREVIEW_MAX_LINES: usize = 64;
const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]";
/// Model-facing output contract returned by executable tool runtimes.
pub trait ToolOutput: Send {
fn log_preview(&self) -> String;
fn success_for_logging(&self) -> bool;
/// Whether this output contains external context that should disable memory generation when
/// `memories.disable_on_external_context` is enabled.
fn contains_external_context(&self) -> bool {
false
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
/// Returns the tool call id exposed to `PostToolUse` hooks for this output.
fn post_tool_use_id(&self, call_id: &str) -> String {
call_id.to_string()
}
/// Returns the tool input exposed to `PostToolUse` hooks for this output.
fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
None
}
/// Returns the stable value exposed to `PostToolUse` hooks for this tool output.
///
/// Tool handlers decide whether a tool participates in `PostToolUse`, but
/// this method lets the output type own any conversion from model-facing
/// response content to hook-facing data. Returning `None` means the output
/// should not produce a post-use hook payload, not merely that the tool had
/// empty output.
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
None
}
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
response_input_to_code_mode_result(self.to_response_item("", payload))
}
}
impl<T> ToolOutput for Box<T>
where
T: ToolOutput + ?Sized,
{
fn log_preview(&self) -> String {
(**self).log_preview()
}
fn success_for_logging(&self) -> bool {
(**self).success_for_logging()
}
fn contains_external_context(&self) -> bool {
(**self).contains_external_context()
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
(**self).to_response_item(call_id, payload)
}
fn post_tool_use_id(&self, call_id: &str) -> String {
(**self).post_tool_use_id(call_id)
}
fn post_tool_use_input(&self, payload: &ToolPayload) -> Option<JsonValue> {
(**self).post_tool_use_input(payload)
}
fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option<JsonValue> {
(**self).post_tool_use_response(call_id, payload)
}
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
(**self).code_mode_result(payload)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct JsonToolOutput {
value: JsonValue,
success: Option<bool>,
contains_external_context: bool,
}
impl JsonToolOutput {
pub fn new(value: JsonValue) -> Self {
Self {
value,
success: Some(true),
contains_external_context: false,
}
}
pub fn with_success(value: JsonValue, success: Option<bool>) -> Self {
Self {
value,
success,
contains_external_context: false,
}
}
pub fn with_external_context(mut self) -> Self {
self.contains_external_context = true;
self
}
}
impl ToolOutput for JsonToolOutput {
fn log_preview(&self) -> String {
telemetry_preview(&self.value.to_string())
}
fn success_for_logging(&self) -> bool {
self.success.unwrap_or(true)
}
fn contains_external_context(&self) -> bool {
self.contains_external_context
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
let output = FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(self.value.to_string()),
success: self.success,
};
if matches!(payload, ToolPayload::Custom { .. }) {
return ResponseInputItem::CustomToolCallOutput {
call_id: call_id.to_string(),
name: None,
output,
};
}
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output,
}
}
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
Some(self.value.clone())
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
self.value.clone()
}
}
impl ToolOutput for codex_protocol::mcp::CallToolResult {
fn log_preview(&self) -> String {
let output = self.as_function_call_output_payload();
let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
telemetry_preview(&preview)
}
fn success_for_logging(&self) -> bool {
self.success()
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::McpToolCallOutput {
call_id: call_id.to_string(),
output: self.clone(),
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
let mut result = serde_json::to_value(self).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
});
// MCP result metadata is private to clients and must not reach Code Mode.
if let JsonValue::Object(fields) = &mut result {
fields.remove("_meta");
}
result
}
}
fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue {
match response {
ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result(
&content
.into_iter()
.map(|item| match item {
codex_protocol::models::ContentItem::InputText { text }
| codex_protocol::models::ContentItem::OutputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
codex_protocol::models::ContentItem::InputImage { image_url, detail } => {
FunctionCallOutputContentItem::InputImage {
image_url,
detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)),
}
}
codex_protocol::models::ContentItem::InputAudio { audio_url } => {
FunctionCallOutputContentItem::InputAudio { audio_url }
}
})
.collect::<Vec<_>>(),
),
ResponseInputItem::FunctionCallOutput { output, .. }
| ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body {
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
FunctionCallOutputBody::ContentItems(items) => {
content_items_to_code_mode_result(&items)
}
},
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output)
.unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
}),
}
}
fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue {
JsonValue::String(
items
.iter()
.filter_map(|item| match item {
FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
Some(text.clone())
}
FunctionCallOutputContentItem::InputImage { image_url, .. }
if !image_url.trim().is_empty() =>
{
Some(image_url.clone())
}
FunctionCallOutputContentItem::InputAudio { audio_url }
if !audio_url.trim().is_empty() =>
{
Some(audio_url.clone())
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>()
.join("\n"),
)
}
fn telemetry_preview(content: &str) -> String {
let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES);
let truncated_by_bytes = truncated_slice.len() < content.len();
let mut preview = String::new();
let mut lines_iter = truncated_slice.lines();
for idx in 0..TELEMETRY_PREVIEW_MAX_LINES {
match lines_iter.next() {
Some(line) => {
if idx > 0 {
preview.push('\n');
}
preview.push_str(line);
}
None => break,
}
}
let truncated_by_lines = lines_iter.next().is_some();
if !truncated_by_bytes && !truncated_by_lines {
return content.to_string();
}
if preview.len() < truncated_slice.len()
&& truncated_slice
.as_bytes()
.get(preview.len())
.is_some_and(|byte| *byte == b'\n')
{
preview.push('\n');
}
if !preview.is_empty() && !preview.ends_with('\n') {
preview.push('\n');
}
preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE);
preview
}