diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9b207f7bb0..63c4d38e75 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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<()> { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 39094331a4..882f5e5d4d 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -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> { + 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(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::(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 diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 3cc8e5d564..38944ab44b 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -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.