Compare commits

...

3 Commits

Author SHA1 Message Date
Matthew Zeng
1414963114 update 2026-02-23 15:32:01 -08:00
Matthew Zeng
7a04aa7fbc Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/cam_client_1 2026-02-23 15:22:23 -08:00
Matthew Zeng
f505686117 update 2026-02-21 21:53:15 -08:00
5 changed files with 822 additions and 14 deletions

View File

@@ -91,6 +91,7 @@ use futures::prelude::*;
use futures::stream::FuturesOrdered;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
@@ -3200,17 +3201,18 @@ impl Session {
.await
}
pub async fn call_tool(
pub(crate) async fn call_tool_with_meta(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<Meta>,
) -> anyhow::Result<CallToolResult> {
self.services
.mcp_connection_manager
.read()
.await
.call_tool(server, tool, arguments)
.call_tool_with_meta(server, tool, arguments, meta)
.await
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::sync::LazyLock;
@@ -10,14 +11,21 @@ use async_channel::unbounded;
pub use codex_app_server_protocol::AppBranding;
pub use codex_app_server_protocol::AppInfo;
pub use codex_app_server_protocol::AppMetadata;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SandboxPolicy;
use rmcp::model::Meta;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;
use uuid::Uuid;
use crate::AuthManager;
use crate::CodexAuth;
use crate::SandboxState;
use crate::codex::Session;
use crate::config::Config;
use crate::config::types::AppToolApproval;
use crate::config::types::AppsConfigToml;
@@ -406,6 +414,199 @@ pub(crate) fn filter_codex_apps_tools_by_policy(
mcp_tools
}
const CODEX_APPS_META_KEY: &str = "_codex_apps";
const CODEX_APPS_CALL_TOOL_CLASSIFY_CONTEXT_META_KEY: &str = "classify_context";
const CODEX_APPS_CALL_TOOL_CONVO_META_KEY: &str = "convo";
const CODEX_APPS_THREAD_TOOL_RECIPIENT_PREFIX: &str = "functions.apps.";
const CODEX_APPS_THREAD_TOOL_OUTPUT_NAME: &str = "api_tool.call_tool";
const CODEX_APPS_THREAD_TOOL_CALL_CHANNEL: &str = "commentary";
#[derive(Debug, Serialize, PartialEq, Eq)]
struct CodexAppsThread {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
messages: Vec<CodexAppsThreadMessage>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct CodexAppsThreadMessage {
author: CodexAppsThreadAuthor,
#[serde(skip_serializing_if = "Option::is_none")]
recipient: Option<String>,
content: CodexAppsTextContent,
#[serde(skip_serializing_if = "Option::is_none")]
channel: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct CodexAppsThreadAuthor {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct CodexAppsTextContent {
content_type: &'static str,
parts: Vec<String>,
}
pub(crate) async fn codex_apps_tool_call_meta(sess: &Session) -> Option<Meta> {
let history = sess.clone_history().await;
codex_apps_tool_call_meta_from_history_items(
Some(sess.conversation_id.to_string()),
history.raw_items(),
)
}
fn codex_apps_tool_call_meta_from_history_items(
thread_id: Option<String>,
history_items: &[ResponseItem],
) -> Option<Meta> {
let thread = codex_apps_thread_from_history_items(thread_id, history_items);
let thread = serde_json::to_value(thread).ok()?;
let serde_json::Value::Object(thread) = thread else {
return None;
};
let mut meta = Meta::new();
meta.insert(
CODEX_APPS_META_KEY.to_string(),
serde_json::json!({
CODEX_APPS_CALL_TOOL_CLASSIFY_CONTEXT_META_KEY: {
CODEX_APPS_CALL_TOOL_CONVO_META_KEY: serde_json::Value::Object(thread),
},
}),
);
Some(meta)
}
fn codex_apps_thread_from_history_items(
thread_id: Option<String>,
history_items: &[ResponseItem],
) -> CodexAppsThread {
let mut messages = Vec::new();
let mut included_tool_call_ids: HashSet<&str> = HashSet::new();
let text_content = |text: String| CodexAppsTextContent {
content_type: "text",
parts: vec![text],
};
let flatten_message_content = |content: &[ContentItem]| {
let text = content
.iter()
.filter_map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
Some(text.as_str())
}
ContentItem::InputImage { .. } => None,
})
.collect::<String>();
(!text.is_empty()).then_some(text)
};
let serialize_tool_output = |output: &FunctionCallOutputPayload| {
if let Some(text) = output.text_content() {
return text.to_string();
}
serde_json::to_string(output).unwrap_or_else(|err| {
serde_json::json!({
"error": format!("failed to serialize tool output: {err}"),
})
.to_string()
})
};
for item in history_items {
match item {
ResponseItem::Message { role, content, .. } => {
if (role == "user" || role == "assistant")
&& let Some(text) = flatten_message_content(content)
{
messages.push(CodexAppsThreadMessage {
author: CodexAppsThreadAuthor {
role: role.clone(),
name: None,
},
recipient: None,
content: text_content(text),
channel: None,
});
}
}
ResponseItem::FunctionCall {
name,
arguments,
call_id,
..
} => {
if let Some((_server, tool_name)) = crate::mcp::split_qualified_tool_name(name) {
included_tool_call_ids.insert(call_id.as_str());
messages.push(CodexAppsThreadMessage {
author: CodexAppsThreadAuthor {
role: "assistant".to_string(),
name: None,
},
recipient: Some(format!(
"{CODEX_APPS_THREAD_TOOL_RECIPIENT_PREFIX}{tool_name}"
)),
content: text_content(arguments.clone()),
channel: Some(CODEX_APPS_THREAD_TOOL_CALL_CHANNEL.to_string()),
});
}
}
ResponseItem::CustomToolCall {
name,
input,
call_id,
..
} if name.starts_with(CODEX_APPS_THREAD_TOOL_RECIPIENT_PREFIX) => {
included_tool_call_ids.insert(call_id.as_str());
messages.push(CodexAppsThreadMessage {
author: CodexAppsThreadAuthor {
role: "assistant".to_string(),
name: None,
},
recipient: Some(name.clone()),
content: text_content(input.clone()),
channel: Some(CODEX_APPS_THREAD_TOOL_CALL_CHANNEL.to_string()),
});
}
ResponseItem::FunctionCallOutput { call_id, output }
if included_tool_call_ids.contains(call_id.as_str()) =>
{
messages.push(CodexAppsThreadMessage {
author: CodexAppsThreadAuthor {
role: "tool".to_string(),
name: Some(CODEX_APPS_THREAD_TOOL_OUTPUT_NAME.to_string()),
},
recipient: None,
content: text_content(serialize_tool_output(output)),
channel: None,
});
}
ResponseItem::CustomToolCallOutput { call_id, output }
if included_tool_call_ids.contains(call_id.as_str()) =>
{
messages.push(CodexAppsThreadMessage {
author: CodexAppsThreadAuthor {
role: "tool".to_string(),
name: Some(CODEX_APPS_THREAD_TOOL_OUTPUT_NAME.to_string()),
},
recipient: None,
content: text_content(output.clone()),
channel: None,
});
}
_ => {}
}
}
CodexAppsThread {
id: thread_id.filter(|id| Uuid::parse_str(id).is_ok()),
messages,
}
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
@@ -615,6 +816,9 @@ mod tests {
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsDefaultConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
fn annotations(
@@ -1009,4 +1213,192 @@ mod tests {
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")]
);
}
#[test]
fn codex_apps_tool_call_meta_serializes_minimal_thread_shape() {
let conversation_id = "0194f5a6-89ab-7cde-8123-456789abcdef".to_string();
let history_items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Find my docs".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "Checking the app.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
id: None,
name: "mcp__codex_apps__drive_search".to_string(),
arguments: r#"{"query":"docs"}"#.to_string(),
call_id: "call-app-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-app-1".to_string(),
output: FunctionCallOutputPayload::from_text("done".to_string()),
},
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
arguments: r#"{"cmd":"pwd"}"#.to_string(),
call_id: "call-shell-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-shell-1".to_string(),
output: FunctionCallOutputPayload::from_text("/tmp".to_string()),
},
];
let meta = codex_apps_tool_call_meta_from_history_items(
Some(conversation_id.clone()),
&history_items,
)
.expect("meta should serialize");
assert_eq!(
serde_json::to_value(meta).expect("serialize meta"),
serde_json::json!({
"_codex_apps": {
"classify_context": {
"convo": {
"id": conversation_id,
"messages": [
{
"author": { "role": "user" },
"content": {
"content_type": "text",
"parts": ["Find my docs"],
},
},
{
"author": { "role": "assistant" },
"content": {
"content_type": "text",
"parts": ["Checking the app."],
},
},
{
"author": { "role": "assistant" },
"recipient": "functions.apps.drive_search",
"content": {
"content_type": "text",
"parts": ["{\"query\":\"docs\"}"],
},
"channel": "commentary",
},
{
"author": { "role": "tool", "name": "api_tool.call_tool" },
"content": {
"content_type": "text",
"parts": ["done"],
},
},
],
}
}
}
})
);
}
#[test]
fn codex_apps_tool_call_meta_includes_non_codex_apps_mcp_tool_calls() {
let conversation_id = "0194f5a6-89ab-7cde-8123-456789abcdef".to_string();
let history_items = vec![
ResponseItem::FunctionCall {
id: None,
name: "mcp__alpha__do_thing".to_string(),
arguments: r#"{"x":1}"#.to_string(),
call_id: "call-alpha-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-alpha-1".to_string(),
output: FunctionCallOutputPayload::from_text("ok".to_string()),
},
];
let meta = codex_apps_tool_call_meta_from_history_items(
Some(conversation_id.clone()),
&history_items,
)
.expect("meta should serialize");
assert_eq!(
serde_json::to_value(meta).expect("serialize meta"),
serde_json::json!({
"_codex_apps": {
"classify_context": {
"convo": {
"id": conversation_id,
"messages": [
{
"author": { "role": "assistant" },
"recipient": "functions.apps.do_thing",
"content": {
"content_type": "text",
"parts": ["{\"x\":1}"],
},
"channel": "commentary",
},
{
"author": { "role": "tool", "name": "api_tool.call_tool" },
"content": {
"content_type": "text",
"parts": ["ok"],
},
},
],
}
}
}
})
);
}
#[test]
fn codex_apps_tool_call_meta_omits_invalid_conversation_id() {
let meta = codex_apps_tool_call_meta_from_history_items(
Some("not-a-uuid".to_string()),
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hello".to_string(),
}],
end_turn: None,
phase: None,
}],
)
.expect("meta should serialize");
assert_eq!(
serde_json::to_value(meta).expect("serialize meta"),
serde_json::json!({
"_codex_apps": {
"classify_context": {
"convo": {
"messages": [
{
"author": { "role": "user" },
"content": {
"content_type": "text",
"parts": ["hello"],
},
}
],
}
}
}
})
);
}
}

View File

@@ -54,6 +54,7 @@ use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParams;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ProtocolVersion;
use rmcp::model::ReadResourceRequestParams;
@@ -912,12 +913,12 @@ impl McpConnectionManager {
aggregated
}
/// Invoke the tool indicated by the (server, tool) pair.
pub async fn call_tool(
pub async fn call_tool_with_meta(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<Meta>,
) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
if !client.tool_filter.allows(tool) {
@@ -928,7 +929,7 @@ impl McpConnectionManager {
let result: rmcp::model::CallToolResult = client
.client
.call_tool(tool.to_string(), arguments, client.tool_timeout)
.call_tool_with_meta(tool.to_string(), arguments, meta, client.tool_timeout)
.await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))?;

View File

@@ -26,6 +26,10 @@ use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use codex_protocol::request_user_input::RequestUserInputResponse;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::CreateElicitationResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use rmcp::model::ToolAnnotations;
use serde::Serialize;
use std::sync::Arc;
@@ -101,6 +105,12 @@ pub(crate) async fn handle_mcp_tool_call(
return ResponseInputItem::McpToolCallOutput { call_id, result };
}
let tool_call_meta = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::codex_apps_tool_call_meta(sess.as_ref()).await
} else {
None
};
if let Some(decision) = maybe_request_mcp_tool_approval(
sess.as_ref(),
turn_context,
@@ -122,10 +132,16 @@ pub(crate) async fn handle_mcp_tool_call(
.await;
let start = Instant::now();
let result = sess
.call_tool(&server, &tool_name, arguments_value.clone())
.await
.map_err(|e| format!("tool call error: {e:?}"));
let result = call_mcp_tool_with_elicitation(
sess.as_ref(),
turn_context,
&call_id,
&server,
&tool_name,
arguments_value.clone(),
tool_call_meta.clone(),
)
.await;
let result = sanitize_mcp_tool_result_for_model(
turn_context
.model_info
@@ -191,10 +207,16 @@ pub(crate) async fn handle_mcp_tool_call(
let start = Instant::now();
// Perform the tool call.
let result = sess
.call_tool(&server, &tool_name, arguments_value.clone())
.await
.map_err(|e| format!("tool call error: {e:?}"));
let result = call_mcp_tool_with_elicitation(
sess.as_ref(),
turn_context,
&call_id,
&server,
&tool_name,
arguments_value.clone(),
tool_call_meta,
)
.await;
let result = sanitize_mcp_tool_result_for_model(
turn_context
.model_info
@@ -223,6 +245,220 @@ pub(crate) async fn handle_mcp_tool_call(
ResponseInputItem::McpToolCallOutput { call_id, result }
}
const MCP_TOOL_ELICITATION_QUESTION_ID_PREFIX: &str = "mcp_tool_call_elicitation";
const MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY: &str = "_codex_apps";
const MCP_TOOL_ELICITATION_REQUEST_META_KEY: &str = "elicit_request_params";
const MCP_TOOL_ELICITATION_RESPONSE_META_KEY: &str = "elicit_result";
async fn call_mcp_tool_with_elicitation(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
server: &str,
tool_name: &str,
arguments_value: Option<serde_json::Value>,
tool_call_meta: Option<Meta>,
) -> Result<CallToolResult, String> {
let mut request_meta = tool_call_meta.clone();
let mut elicitation_attempt = 0_u32;
loop {
let result = sess
.call_tool_with_meta(
server,
tool_name,
arguments_value.clone(),
request_meta.clone(),
)
.await
.map_err(|e| format!("tool call error: {e:?}"))?;
match classify_mcp_tool_result_meta(&result) {
McpToolResultMetaStatus::ElicitationRequired { request_params } => {
let Some(user_response) = request_mcp_tool_elicitation(
sess,
turn_context,
call_id,
&request_params,
elicitation_attempt,
)
.await
else {
return Err("user cancelled MCP tool elicitation".to_string());
};
request_meta = Some(build_mcp_tool_elicitation_retry_meta(
tool_call_meta.as_ref(),
result.meta.as_ref(),
user_response,
));
elicitation_attempt = elicitation_attempt.saturating_add(1);
}
McpToolResultMetaStatus::BlockedBySafetyMonitor | McpToolResultMetaStatus::Normal => {
return Ok(result);
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum McpToolResultMetaStatus {
ElicitationRequired {
request_params: CreateElicitationRequestParams,
},
BlockedBySafetyMonitor,
Normal,
}
fn classify_mcp_tool_result_meta(result: &CallToolResult) -> McpToolResultMetaStatus {
let Some(meta) = result.meta.as_ref().and_then(serde_json::Value::as_object) else {
return McpToolResultMetaStatus::Normal;
};
if let Some(elicitation_request_params) = meta
.get(MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY)
.and_then(serde_json::Value::as_object)
.and_then(|codex_apps_meta| codex_apps_meta.get(MCP_TOOL_ELICITATION_REQUEST_META_KEY))
.and_then(parse_mcp_tool_elicitation_request_params)
{
return McpToolResultMetaStatus::ElicitationRequired {
request_params: elicitation_request_params,
};
}
let Some(status) = meta.get("status").and_then(serde_json::Value::as_str) else {
return McpToolResultMetaStatus::Normal;
};
match status {
"blocked_by_safety_monitor" => McpToolResultMetaStatus::BlockedBySafetyMonitor,
_ => McpToolResultMetaStatus::Normal,
}
}
fn parse_mcp_tool_elicitation_request_params(
request_params: &serde_json::Value,
) -> Option<CreateElicitationRequestParams> {
serde_json::from_value::<CreateElicitationRequestParams>(request_params.clone()).ok()
}
async fn request_mcp_tool_elicitation(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
elicitation_request_params: &CreateElicitationRequestParams,
elicitation_attempt: u32,
) -> Option<McpToolApprovalDecision> {
let question_id =
format!("{MCP_TOOL_ELICITATION_QUESTION_ID_PREFIX}_{call_id}_{elicitation_attempt}");
let args = RequestUserInputArgs {
questions: vec![build_mcp_tool_elicitation_question(
question_id.clone(),
mcp_tool_elicitation_message(elicitation_request_params),
)],
};
let response = sess
.request_user_input(turn_context, call_id.to_string(), args)
.await;
Some(parse_mcp_tool_approval_response(response, &question_id))
}
fn mcp_tool_elicitation_message(
elicitation_request_params: &CreateElicitationRequestParams,
) -> String {
match elicitation_request_params {
CreateElicitationRequestParams::FormElicitationParams { message, .. } => message.clone(),
CreateElicitationRequestParams::UrlElicitationParams { message, url, .. } => {
format!("{message}\n\nURL: {url}")
}
}
}
fn build_mcp_tool_elicitation_question(
question_id: String,
message: String,
) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: question_id,
header: "Approve app tool request?".to_string(),
question: message,
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
description: "Approve and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_DECLINE.to_string(),
description: "Decline and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
description: "Cancel".to_string(),
},
]),
}
}
fn build_mcp_tool_elicitation_retry_meta(
base_meta: Option<&Meta>,
result_meta: Option<&serde_json::Value>,
user_response: McpToolApprovalDecision,
) -> Meta {
let mut meta = base_meta.cloned().unwrap_or_default();
let mut codex_apps_meta = match meta.remove(MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY) {
Some(serde_json::Value::Object(object)) => object,
_ => serde_json::Map::new(),
};
if let Some(result_codex_apps_meta) = result_meta
.and_then(serde_json::Value::as_object)
.and_then(|meta| meta.get(MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY))
.and_then(serde_json::Value::as_object)
{
for (key, value) in result_codex_apps_meta {
codex_apps_meta.insert(key.clone(), value.clone());
}
}
match serde_json::to_value(mcp_tool_approval_decision_to_elicitation_result(
user_response,
)) {
Ok(elicitation_response) => {
codex_apps_meta.insert(
MCP_TOOL_ELICITATION_RESPONSE_META_KEY.to_string(),
elicitation_response,
);
}
Err(e) => {
error!("failed to serialize elicitation response metadata: {e}");
}
}
meta.insert(
MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY.to_string(),
serde_json::Value::Object(codex_apps_meta),
);
meta
}
fn mcp_tool_approval_decision_to_elicitation_result(
user_response: McpToolApprovalDecision,
) -> CreateElicitationResult {
let action = match user_response {
McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => {
ElicitationAction::Accept
}
McpToolApprovalDecision::Decline => ElicitationAction::Decline,
McpToolApprovalDecision::Cancel => ElicitationAction::Cancel,
};
CreateElicitationResult {
action,
content: None,
}
}
fn sanitize_mcp_tool_result_for_model(
supports_image_input: bool,
result: Result<CallToolResult, String>,
@@ -339,6 +575,12 @@ async fn maybe_request_mcp_tool_approval(
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
// codex_apps tools run their own product-grade elicitation/approval flow inside
// `call_mcp_tool_with_elicitation`.
if server == CODEX_APPS_MCP_SERVER_NAME {
return None;
}
if approval_mode == AppToolApproval::Approve {
return None;
}
@@ -616,6 +858,7 @@ async fn notify_mcp_tool_call_skip(
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use rmcp::model::ElicitationSchema;
fn annotations(
read_only: Option<bool>,
@@ -706,6 +949,164 @@ mod tests {
);
}
#[test]
fn classify_mcp_tool_result_meta_detects_elicitation_required() {
let elicitation_request_params = CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Need confirmation from the user".to_string(),
requested_schema: ElicitationSchema::builder()
.required_string("approval")
.build()
.expect("valid schema"),
};
let result = CallToolResult {
content: vec![],
structured_content: None,
is_error: Some(false),
meta: Some(serde_json::json!({
"_codex_apps": {
"elicit_request_params": serde_json::to_value(&elicitation_request_params)
.expect("elicitation request serializes"),
},
})),
};
assert_eq!(
classify_mcp_tool_result_meta(&result),
McpToolResultMetaStatus::ElicitationRequired {
request_params: elicitation_request_params,
}
);
}
#[test]
fn classify_mcp_tool_result_meta_ignores_wrapped_elicitation_request() {
let elicitation_request_params = CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Need confirmation from the user".to_string(),
requested_schema: ElicitationSchema::builder()
.required_string("approval")
.build()
.expect("valid schema"),
};
let result = CallToolResult {
content: vec![],
structured_content: None,
is_error: Some(false),
meta: Some(serde_json::json!({
"_codex_apps": {
"elicit_request_params": {
"method": "elicitation/create",
"params": serde_json::to_value(&elicitation_request_params)
.expect("elicitation request params serialize"),
},
},
})),
};
assert_eq!(
classify_mcp_tool_result_meta(&result),
McpToolResultMetaStatus::Normal
);
}
#[test]
fn classify_mcp_tool_result_meta_detects_blocked_status_without_prompt() {
let result = CallToolResult {
content: vec![],
structured_content: None,
is_error: Some(true),
meta: Some(serde_json::json!({
"status": "blocked_by_safety_monitor",
"elicitation_message": "ignored",
})),
};
assert_eq!(
classify_mcp_tool_result_meta(&result),
McpToolResultMetaStatus::BlockedBySafetyMonitor
);
}
#[test]
fn build_mcp_tool_elicitation_question_uses_approval_options() {
let question = build_mcp_tool_elicitation_question(
"q".to_string(),
"Need approval from user".to_string(),
);
assert_eq!(question.header, "Approve app tool request?");
assert_eq!(question.question, "Need approval from user");
assert_eq!(
question.options.expect("options"),
vec![
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
description: "Approve and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_DECLINE.to_string(),
description: "Decline and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
description: "Cancel".to_string(),
},
]
);
}
#[test]
fn build_mcp_tool_elicitation_retry_meta_merges_base_and_result_elicitation_meta() {
let mut base_meta = Meta::new();
base_meta.insert("base".to_string(), serde_json::json!({"kept": true}));
base_meta.insert(
MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY.to_string(),
serde_json::json!({
"base_only": 1,
"override_me": "base",
}),
);
let result_meta = serde_json::json!({
"_codex_apps": {
"elicit_request_params": {
"mode": "url",
"message": "Need more details",
"url": "https://example.com",
"elicitationId": "abc123",
},
"request_id": "abc123",
"override_me": "result",
}
});
let got = build_mcp_tool_elicitation_retry_meta(
Some(&base_meta),
Some(&result_meta),
McpToolApprovalDecision::Accept,
);
assert_eq!(got.get("base"), Some(&serde_json::json!({"kept": true})));
assert_eq!(
got.get(MCP_TOOL_ELICITATION_CODEX_APPS_META_KEY),
Some(&serde_json::json!({
"base_only": 1,
"override_me": "result",
"elicit_request_params": {
"mode": "url",
"message": "Need more details",
"url": "https://example.com",
"elicitationId": "abc123",
},
"request_id": "abc123",
"elicit_result": {
"action": "accept",
},
}))
);
}
#[test]
fn sanitize_mcp_tool_result_for_model_rewrites_image_content() {
let result = Ok(CallToolResult {

View File

@@ -27,6 +27,7 @@ use rmcp::model::InitializeResult;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
@@ -491,6 +492,17 @@ impl RmcpClient {
name: String,
arguments: Option<serde_json::Value>,
timeout: Option<Duration>,
) -> Result<CallToolResult> {
self.call_tool_with_meta(name, arguments, None, timeout)
.await
}
pub async fn call_tool_with_meta(
&self,
name: String,
arguments: Option<serde_json::Value>,
meta: Option<Meta>,
timeout: Option<Duration>,
) -> Result<CallToolResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
@@ -504,7 +516,7 @@ impl RmcpClient {
None => None,
};
let rmcp_params = CallToolRequestParams {
meta: None,
meta,
name: name.into(),
arguments,
task: None,