mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Preserve encrypted content in MCP tool outputs (#33509)
## What changed - Convert MCP text content marked with `_meta["codex/encryptedContent"] = true` into `encrypted_content` function call output items. - Prefer the content-item payload whenever encrypted content is present so that `structured_content` does not replace the encrypted output. ## Testing - Add a stdio MCP integration test that round-trips mixed plaintext and encrypted tool output. GitOrigin-RevId: c3997a16b06ad38ff22b21016ede35b97974d9ec
This commit is contained in:
@@ -1272,6 +1272,96 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial(mcp_test_value)]
|
||||
async fn stdio_encrypted_content_responses_round_trip() -> anyhow::Result<()> {
|
||||
skip_if_wine_exec!(
|
||||
Ok(()),
|
||||
"requires a Windows test_stdio_server in the Wine-exec environment"
|
||||
);
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let call_id = "encrypted-1";
|
||||
let server_name = "rmcp";
|
||||
let namespace = format!("mcp__{server_name}");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"encrypted_output",
|
||||
"{}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let final_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
|
||||
let fixture = test_codex()
|
||||
.with_config(move |config| {
|
||||
insert_mcp_server(
|
||||
config,
|
||||
server_name,
|
||||
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
|
||||
TestMcpServerOptions {
|
||||
environment_id: remote_aware_environment_id(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.submit(read_only_user_turn(
|
||||
&fixture,
|
||||
"call the rmcp encrypted output tool",
|
||||
))
|
||||
.await?;
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let output = output_item["output"]
|
||||
.as_array()
|
||||
.expect("encrypted MCP output should be content items");
|
||||
assert_eq!(output.len(), 3);
|
||||
assert_wall_time_header(
|
||||
output[0]["text"]
|
||||
.as_str()
|
||||
.expect("first encrypted MCP output item should be wall-time text"),
|
||||
);
|
||||
assert_eq!(
|
||||
&output[1..],
|
||||
&[
|
||||
json!({
|
||||
"type": "input_text",
|
||||
"text": "Lookup completed",
|
||||
}),
|
||||
json!({
|
||||
"type": "encrypted_content",
|
||||
"encrypted_content": "gAAAA-test",
|
||||
}),
|
||||
]
|
||||
);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial(mcp_test_value)]
|
||||
async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
@@ -1901,6 +1901,18 @@ 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 { .. }))
|
||||
}) {
|
||||
return FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(content_items.unwrap_or_default()),
|
||||
success: Some(self.success()),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(structured_content) = &self.structured_content
|
||||
&& !structured_content.is_null()
|
||||
{
|
||||
@@ -1930,8 +1942,6 @@ impl CallToolResult {
|
||||
}
|
||||
};
|
||||
|
||||
let content_items = convert_mcp_content_to_items(&self.content);
|
||||
|
||||
let body = match content_items {
|
||||
Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
|
||||
None => FunctionCallOutputBody::Text(serialized_content),
|
||||
@@ -1951,13 +1961,18 @@ impl CallToolResult {
|
||||
fn convert_mcp_content_to_items(
|
||||
contents: &[serde_json::Value],
|
||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||
const CODEX_ENCRYPTED_CONTENT_META_KEY: &str = "codex/encryptedContent";
|
||||
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum McpContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "image")]
|
||||
Image {
|
||||
data: String,
|
||||
@@ -1970,18 +1985,32 @@ fn convert_mcp_content_to_items(
|
||||
Unknown,
|
||||
}
|
||||
|
||||
let mut saw_image = false;
|
||||
let mut saw_content_item = false;
|
||||
let mut items = Vec::with_capacity(contents.len());
|
||||
|
||||
for content in contents {
|
||||
let item = match serde_json::from_value::<McpContent>(content.clone()) {
|
||||
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
|
||||
Ok(McpContent::Text { text, meta }) => {
|
||||
if meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(CODEX_ENCRYPTED_CONTENT_META_KEY))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
saw_content_item = true;
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: text,
|
||||
}
|
||||
} else {
|
||||
FunctionCallOutputContentItem::InputText { text }
|
||||
}
|
||||
}
|
||||
Ok(McpContent::Image {
|
||||
data,
|
||||
mime_type,
|
||||
meta,
|
||||
}) => {
|
||||
saw_image = true;
|
||||
saw_content_item = true;
|
||||
let image_url = if data.starts_with("data:") {
|
||||
data
|
||||
} else {
|
||||
@@ -2012,7 +2041,7 @@ fn convert_mcp_content_to_items(
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if saw_image { Some(items) } else { None }
|
||||
if saw_content_item { Some(items) } else { None }
|
||||
}
|
||||
|
||||
// Implement Display so callers can treat the payload like a plain string when logging or doing
|
||||
|
||||
@@ -98,9 +98,24 @@ impl TestToolServer {
|
||||
thread_hint_meta.insert("ui".to_string(), json!({ "visibility": [] }));
|
||||
thread_hint_tool.meta = Some(thread_hint_meta);
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let encrypted_output_schema: JsonObject = serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}))
|
||||
.expect("encrypted_output tool schema should deserialize");
|
||||
let mut encrypted_output_tool = Tool::new(
|
||||
Cow::Borrowed("encrypted_output"),
|
||||
Cow::Borrowed("Return mixed plaintext and encrypted content for integration tests."),
|
||||
Arc::new(encrypted_output_schema),
|
||||
);
|
||||
encrypted_output_tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
|
||||
let mut tools = vec![
|
||||
Self::echo_tool(),
|
||||
Self::echo_dash_tool(),
|
||||
encrypted_output_tool,
|
||||
thread_hint_tool,
|
||||
Self::client_capabilities_tool(),
|
||||
Self::cwd_tool(),
|
||||
@@ -636,6 +651,22 @@ impl ServerHandler for TestToolServer {
|
||||
|
||||
Ok(Self::structured_result(structured_content))
|
||||
}
|
||||
"encrypted_output" => {
|
||||
let mut meta = Meta::new();
|
||||
meta.insert("codex/encryptedContent".to_string(), json!(true));
|
||||
let mut result = CallToolResult::success(vec![
|
||||
rmcp::model::Content::text("Lookup completed"),
|
||||
rmcp::model::Annotated::new(
|
||||
rmcp::model::RawContent::Text(rmcp::model::RawTextContent {
|
||||
text: "gAAAA-test".to_string(),
|
||||
meta: Some(meta),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
]);
|
||||
result.structured_content = Some(json!({"encrypted_output": "ignored"}));
|
||||
Ok(result)
|
||||
}
|
||||
"image" => {
|
||||
// Read a data URL (e.g. data:image/png;base64,AAA...) from env and convert to
|
||||
// an MCP image content block. Tests set MCP_TEST_IMAGE_DATA_URL.
|
||||
|
||||
Reference in New Issue
Block a user