Add MCP tool wall time to model output (#17406)

Include MCP wall time in the output so the model is aware of how long
it's calls are taking.
This commit is contained in:
pakrym-oai
2026-04-12 18:26:15 -07:00
committed by GitHub
parent 68a1d82a41
commit 7c1e41c8b6
6 changed files with 286 additions and 32 deletions

View File

@@ -118,6 +118,63 @@ impl ToolOutput for CallToolResult {
}
}
#[derive(Clone, Debug)]
pub struct McpToolOutput {
pub result: CallToolResult,
pub wall_time: Duration,
}
impl ToolOutput for McpToolOutput {
fn log_preview(&self) -> String {
let payload = self.response_payload();
let preview = payload.body.to_text().unwrap_or_else(|| {
serde_json::to_string(&self.result.content)
.unwrap_or_else(|err| format!("failed to serialize mcp result: {err}"))
});
telemetry_preview(&preview)
}
fn success_for_logging(&self) -> bool {
self.result.success()
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: self.response_payload(),
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
serde_json::to_value(&self.result).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
})
}
}
impl McpToolOutput {
fn response_payload(&self) -> FunctionCallOutputPayload {
let mut payload = self.result.as_function_call_output_payload();
let wall_time_seconds = self.wall_time.as_secs_f64();
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
match &mut payload.body {
FunctionCallOutputBody::Text(text) => {
if text.is_empty() {
*text = header;
} else {
*text = format!("{header}\n{text}");
}
}
FunctionCallOutputBody::ContentItems(items) => {
items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
}
}
payload
}
}
#[derive(Clone)]
pub struct ToolSearchOutput {
pub tools: Vec<ToolSearchOutputTool>,

View File

@@ -85,6 +85,145 @@ fn mcp_code_mode_result_serializes_full_call_tool_result() {
);
}
#[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,
},
wall_time: std::time::Duration::from_millis(1250),
};
let response = output.to_response_item(
"mcp-call-1",
&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_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_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,
},
wall_time: std::time::Duration::from_millis(500),
};
let response = output.to_response_item(
"mcp-call-2",
&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_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: None,
},
]
.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_stays_raw_call_tool_result() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"content": "done",
})),
is_error: Some(false),
meta: None,
},
wall_time: std::time::Duration::from_millis(1250),
};
let result = output.code_mode_result(&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
});
assert_eq!(
result,
serde_json::json!({
"content": [{
"type": "text",
"text": "ignored",
}],
"structuredContent": {
"content": "done",
},
"isError": false,
})
);
}
#[test]
fn custom_tool_calls_can_derive_text_from_content_items() {
let payload = ToolPayload::Custom {

View File

@@ -1,16 +1,17 @@
use std::sync::Arc;
use std::time::Instant;
use crate::function_tool::FunctionCallError;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::tools::context::McpToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::mcp::CallToolResult;
pub struct McpHandler;
impl ToolHandler for McpHandler {
type Output = CallToolResult;
type Output = McpToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Mcp
@@ -41,7 +42,8 @@ impl ToolHandler for McpHandler {
let (server, tool, raw_arguments) = payload;
let arguments_str = raw_arguments;
let output = handle_mcp_tool_call(
let started = Instant::now();
let result = handle_mcp_tool_call(
Arc::clone(&session),
&turn,
call_id.clone(),
@@ -51,6 +53,9 @@ impl ToolHandler for McpHandler {
)
.await;
Ok(output)
Ok(McpToolOutput {
result,
wall_time: started.elapsed(),
})
}
}