Preserve MCP tool output as content items (#40737)

## What changed

- Convert unstructured MCP results into typed function-call output items instead of serializing the entire content array as a text string.
- Keep structured MCP results as serialized text and preserve media, encrypted content, and unknown content through their existing item conversions.
- Drop empty text items during output truncation so they do not consume API array slots.

## Testing

- Cover text-only, mixed unstructured, structured, and image-sanitized MCP results.
- Verify empty text items are discarded under byte- and token-based truncation policies.

GitOrigin-RevId: fa1c5b7dee6f003a094265379dcabdd060386a48
This commit is contained in:
pakrym-oai authored and copyberry committed 2026-08-25 23:42:50 +00:00
1 parent 62aacbb2c9
commit 75cb7c903d
12 files changed
+288 -104

No files matched your search

+82 -34
View File
@@ -2230,13 +2230,12 @@ impl CallToolResult {
pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
let content_items = convert_mcp_content_to_items(&self.content);
if content_items.as_ref().is_some_and(|items| {
items
.iter()
.any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
}) {
if content_items
.iter()
.any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
{
return FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(content_items.unwrap_or_default()),
body: FunctionCallOutputBody::ContentItems(content_items),
success: Some(self.success()),
};
}
@@ -2260,23 +2259,8 @@ impl CallToolResult {
}
}
let serialized_content = match serde_json::to_string(&self.content) {
Ok(serialized_content) => serialized_content,
Err(err) => {
return FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(err.to_string()),
success: Some(false),
};
}
};
let body = match content_items {
Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
None => FunctionCallOutputBody::Text(serialized_content),
};
FunctionCallOutputPayload {
body,
body: FunctionCallOutputBody::ContentItems(content_items),
success: Some(self.success()),
}
}
@@ -2288,7 +2272,7 @@ impl CallToolResult {
fn convert_mcp_content_to_items(
contents: &[serde_json::Value],
) -> Option<Vec<FunctionCallOutputContentItem>> {
) -> Vec<FunctionCallOutputContentItem> {
const CODEX_ENCRYPTED_CONTENT_META_KEY: &str = "codex/encryptedContent";
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
@@ -2321,7 +2305,6 @@ fn convert_mcp_content_to_items(
Unknown,
}
let mut saw_content_item = false;
let mut items = Vec::with_capacity(contents.len());
for content in contents {
@@ -2333,7 +2316,6 @@ fn convert_mcp_content_to_items(
.and_then(serde_json::Value::as_bool)
== Some(true)
{
saw_content_item = true;
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: text,
}
@@ -2346,7 +2328,6 @@ fn convert_mcp_content_to_items(
mime_type,
meta,
}) => {
saw_content_item = true;
let image_url = if data.starts_with("data:") {
data
} else {
@@ -2373,7 +2354,6 @@ fn convert_mcp_content_to_items(
Ok(McpContent::Audio {
data, mime_type, ..
}) => {
saw_content_item = true;
let audio_url = if data.starts_with("data:") {
data
} else {
@@ -2389,7 +2369,7 @@ fn convert_mcp_content_to_items(
items.push(item);
}
if saw_content_item { Some(items) } else { None }
items
}
// Implement Display so callers can treat the payload like a plain string when logging or doing
@@ -2679,7 +2659,7 @@ mod tests {
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
let items = convert_mcp_content_to_items(&contents);
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
@@ -3011,7 +2991,7 @@ mod tests {
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
let items = convert_mcp_content_to_items(&contents);
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
@@ -3039,25 +3019,30 @@ mod tests {
assert_eq!(
convert_mcp_content_to_items(&contents),
Some(vec![
vec![
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/wav;base64,Zm9v".to_string(),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/ogg;base64,YmFy".to_string(),
},
])
]
);
}
#[test]
fn convert_mcp_content_to_items_returns_none_without_media() {
fn convert_mcp_content_to_items_converts_text_without_media() {
let contents = vec![serde_json::json!({
"type": "text",
"text": "hello",
})];
assert_eq!(convert_mcp_content_to_items(&contents), None);
assert_eq!(
convert_mcp_content_to_items(&contents),
vec![FunctionCallOutputContentItem::InputText {
text: "hello".to_string(),
}]
);
}
#[test]
@@ -3302,6 +3287,69 @@ mod tests {
Ok(())
}
#[test]
fn converts_unstructured_mcp_content_to_items() {
let content = vec![
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({
"type": "resource_link",
"uri": "file:///notes.txt",
"name": "notes",
}),
serde_json::json!({
"type": "audio",
"mimeType": "audio/wav",
}),
];
let call_tool_result = CallToolResult {
content: content.clone(),
structured_content: Some(serde_json::Value::Null),
is_error: Some(false),
meta: None,
};
let resource_link = serde_json::to_string(&content[1]).expect("serialize resource link");
let malformed_audio =
serde_json::to_string(&content[2]).expect("serialize malformed audio");
assert_eq!(
call_tool_result.as_function_call_output_payload(),
FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "caption".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: resource_link,
},
FunctionCallOutputContentItem::InputText {
text: malformed_audio,
},
]),
success: Some(true),
}
);
}
#[test]
fn preserves_structured_mcp_content() {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({"type":"text","text":"ignored"})],
structured_content: Some(serde_json::json!({"result":"structured"})),
is_error: Some(false),
meta: None,
};
assert_eq!(
call_tool_result.as_function_call_output_payload(),
FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(
serde_json::json!({"result":"structured"}).to_string(),
),
success: Some(true),
}
);
}
#[test]
fn serializes_image_outputs_as_array() -> Result<()> {
let call_tool_result = CallToolResult {