mirror of
https://github.com/openai/codex.git
synced 2026-03-25 09:36:30 +03:00
Compare commits
3 Commits
starr/exec
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1414963114 | ||
|
|
7a04aa7fbc | ||
|
|
f505686117 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`"))?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user