mirror of
https://github.com/openai/codex.git
synced 2026-03-23 16:46:32 +03:00
Compare commits
2 Commits
starr/exec
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b98f286c55 | ||
|
|
ce1ce00edf |
@@ -1,10 +1,31 @@
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_CODEX_KEY;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_META_KEY;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
pub(crate) fn render_apps_section() -> String {
|
||||
let body = format!(
|
||||
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
|
||||
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nFor consequential `{CODEX_APPS_MCP_SERVER_NAME}` tool calls that require approval, you need to include user-facing approval copy in the tool arguments under:\n```json\n{{\n \"{MCP_TOOL_ARGS_META_KEY}\": {{\n \"{MCP_TOOL_ARGS_CODEX_KEY}\": {{\n \"{MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY}\": \"Allow Calendar to create this event?\"\n }}\n }}\n}}\n```\nUse `{MCP_TOOL_ARGS_META_KEY}.{MCP_TOOL_ARGS_CODEX_KEY}.{MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY}` only for consequential app tools that may require approval. Make it a short user-facing approval sentence, not execution data, and do not rely on it being forwarded to the app.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
|
||||
);
|
||||
format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::render_apps_section;
|
||||
|
||||
#[test]
|
||||
fn render_apps_section_mentions_elicitation_description_contract() {
|
||||
let rendered = render_apps_section();
|
||||
|
||||
assert!(rendered.contains("`_meta._codex.elicitation_description`"));
|
||||
assert!(
|
||||
rendered
|
||||
.contains("\"elicitation_description\": \"Allow Calendar to create this event?\"")
|
||||
);
|
||||
assert!(rendered.contains("not execution data"));
|
||||
assert!(rendered.contains("do not rely on it being forwarded to the app"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalTemplate;
|
||||
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::McpInvocation;
|
||||
@@ -51,6 +52,10 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(crate) const MCP_TOOL_ARGS_META_KEY: &str = "_meta";
|
||||
pub(crate) const MCP_TOOL_ARGS_CODEX_KEY: &str = "_codex";
|
||||
pub(crate) const MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY: &str = "elicitation_description";
|
||||
|
||||
/// Handles the specified tool call dispatches the appropriate
|
||||
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
|
||||
pub(crate) async fn handle_mcp_tool_call(
|
||||
@@ -75,6 +80,12 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
}
|
||||
};
|
||||
|
||||
let (arguments_value, model_elicitation_description) = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
extract_codex_app_elicitation_description(arguments_value)
|
||||
} else {
|
||||
(arguments_value, None)
|
||||
};
|
||||
|
||||
let invocation = McpInvocation {
|
||||
server: server.clone(),
|
||||
tool: tool_name.clone(),
|
||||
@@ -130,6 +141,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context,
|
||||
&call_id,
|
||||
&invocation,
|
||||
model_elicitation_description.as_deref(),
|
||||
metadata.as_ref(),
|
||||
app_tool_policy.approval,
|
||||
)
|
||||
@@ -406,6 +418,12 @@ struct McpToolApprovalPromptOptions {
|
||||
allow_persistent_approval: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct McpToolApprovalPromptCopy<'a> {
|
||||
question_override: Option<&'a str>,
|
||||
message_override: Option<&'a str>,
|
||||
}
|
||||
|
||||
struct McpToolApprovalElicitationRequest<'a> {
|
||||
server: &'a str,
|
||||
metadata: Option<&'a McpToolApprovalMetadata>,
|
||||
@@ -471,6 +489,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
turn_context: &Arc<TurnContext>,
|
||||
call_id: &str,
|
||||
invocation: &McpInvocation,
|
||||
model_elicitation_description: Option<&str>,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
@@ -553,6 +572,11 @@ async fn maybe_request_mcp_tool_approval(
|
||||
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
|
||||
invocation.arguments.as_ref(),
|
||||
);
|
||||
let prompt_copy = build_mcp_tool_approval_prompt_copy(
|
||||
model_elicitation_description,
|
||||
rendered_template.as_ref(),
|
||||
monitor_reason.as_deref(),
|
||||
);
|
||||
let tool_params_display = rendered_template
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.tool_params_display.clone())
|
||||
@@ -563,9 +587,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
&invocation.tool,
|
||||
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
|
||||
prompt_options,
|
||||
rendered_template
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.question.as_str()),
|
||||
prompt_copy.question_override,
|
||||
);
|
||||
question.question =
|
||||
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
|
||||
@@ -585,11 +607,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.or(invocation.arguments.as_ref()),
|
||||
tool_params_display: tool_params_display.as_deref(),
|
||||
question,
|
||||
message_override: rendered_template.as_ref().and_then(|rendered_template| {
|
||||
monitor_reason
|
||||
.is_none()
|
||||
.then_some(rendered_template.elicitation_message.as_str())
|
||||
}),
|
||||
message_override: prompt_copy.message_override,
|
||||
prompt_options,
|
||||
},
|
||||
);
|
||||
@@ -631,6 +649,66 @@ async fn maybe_request_mcp_tool_approval(
|
||||
Some(decision)
|
||||
}
|
||||
|
||||
fn extract_codex_app_elicitation_description(
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> (Option<serde_json::Value>, Option<String>) {
|
||||
let Some(serde_json::Value::Object(mut arguments)) = arguments else {
|
||||
return (arguments, None);
|
||||
};
|
||||
|
||||
let mut remove_meta = false;
|
||||
let mut elicitation_description = None;
|
||||
|
||||
if let Some(meta_value) = arguments.get_mut(MCP_TOOL_ARGS_META_KEY)
|
||||
&& let Some(meta) = meta_value.as_object_mut()
|
||||
{
|
||||
elicitation_description = meta
|
||||
.remove(MCP_TOOL_ARGS_CODEX_KEY)
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|codex_meta| codex_meta.get(MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|description| !description.is_empty())
|
||||
.map(ToString::to_string);
|
||||
remove_meta = meta.is_empty();
|
||||
}
|
||||
|
||||
if remove_meta {
|
||||
arguments.remove(MCP_TOOL_ARGS_META_KEY);
|
||||
}
|
||||
|
||||
(
|
||||
(!arguments.is_empty()).then_some(serde_json::Value::Object(arguments)),
|
||||
elicitation_description,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_prompt_copy<'a>(
|
||||
model_elicitation_description: Option<&'a str>,
|
||||
rendered_template: Option<&'a RenderedMcpToolApprovalTemplate>,
|
||||
monitor_reason: Option<&str>,
|
||||
) -> McpToolApprovalPromptCopy<'a> {
|
||||
let model_elicitation_description = model_elicitation_description
|
||||
.map(str::trim)
|
||||
.filter(|description| !description.is_empty());
|
||||
let question_override = model_elicitation_description
|
||||
.or_else(|| rendered_template.map(|rendered_template| rendered_template.question.as_str()));
|
||||
let message_override = if monitor_reason.is_some() {
|
||||
None
|
||||
} else {
|
||||
model_elicitation_description.or_else(|| {
|
||||
rendered_template
|
||||
.map(|rendered_template| rendered_template.elicitation_message.as_str())
|
||||
})
|
||||
};
|
||||
|
||||
McpToolApprovalPromptCopy {
|
||||
question_override,
|
||||
message_override,
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_monitor_auto_approved_mcp_tool_call(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -1246,7 +1324,7 @@ async fn persist_codex_app_tool_approval(
|
||||
.await
|
||||
}
|
||||
|
||||
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
|
||||
pub(crate) fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
|
||||
if annotations.destructive_hint == Some(true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalTemplate;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -108,6 +109,141 @@ fn approval_question_text_prepends_safety_reason() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_app_elicitation_description_strips_control_meta_and_prunes_empty_meta() {
|
||||
let (arguments, elicitation_description) =
|
||||
extract_codex_app_elicitation_description(Some(serde_json::json!({
|
||||
"title": "Roadmap review",
|
||||
"_meta": {
|
||||
"_codex": {
|
||||
"elicitation_description": " Allow Calendar to create this event? "
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
assert_eq!(
|
||||
arguments,
|
||||
Some(serde_json::json!({
|
||||
"title": "Roadmap review",
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
elicitation_description,
|
||||
Some("Allow Calendar to create this event?".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_app_elicitation_description_preserves_other_meta_fields() {
|
||||
let (arguments, elicitation_description) =
|
||||
extract_codex_app_elicitation_description(Some(serde_json::json!({
|
||||
"title": "Roadmap review",
|
||||
"_meta": {
|
||||
"request_id": "req-1",
|
||||
"_codex": {
|
||||
"elicitation_description": "Allow Calendar to create this event?"
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
assert_eq!(
|
||||
arguments,
|
||||
Some(serde_json::json!({
|
||||
"title": "Roadmap review",
|
||||
"_meta": {
|
||||
"request_id": "req-1",
|
||||
}
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
elicitation_description,
|
||||
Some("Allow Calendar to create this event?".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_app_elicitation_description_ignores_blank_strings() {
|
||||
let (arguments, elicitation_description) =
|
||||
extract_codex_app_elicitation_description(Some(serde_json::json!({
|
||||
"_meta": {
|
||||
"_codex": {
|
||||
"elicitation_description": " "
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
assert_eq!(arguments, None);
|
||||
assert_eq!(elicitation_description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripped_arguments_omit_codex_meta_from_approval_display_params() {
|
||||
let (arguments, _) = extract_codex_app_elicitation_description(Some(serde_json::json!({
|
||||
"title": "Roadmap review",
|
||||
"_meta": {
|
||||
"_codex": {
|
||||
"elicitation_description": "Allow Calendar to create this event?"
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_tool_approval_display_params(arguments.as_ref()),
|
||||
Some(vec![RenderedMcpToolApprovalParam {
|
||||
name: "title".to_string(),
|
||||
value: serde_json::json!("Roadmap review"),
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_prompt_copy_prefers_model_text_over_template() {
|
||||
let rendered_template = RenderedMcpToolApprovalTemplate {
|
||||
question: "Allow Calendar to create an event?".to_string(),
|
||||
elicitation_message: "Allow Calendar to create an event?".to_string(),
|
||||
tool_params: None,
|
||||
tool_params_display: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_tool_approval_prompt_copy(
|
||||
Some("Allow Calendar to create this event?"),
|
||||
Some(&rendered_template),
|
||||
None,
|
||||
),
|
||||
McpToolApprovalPromptCopy {
|
||||
question_override: Some("Allow Calendar to create this event?"),
|
||||
message_override: Some("Allow Calendar to create this event?"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_prompt_copy_defers_to_safety_reason_over_model_text() {
|
||||
let prompt_copy = build_mcp_tool_approval_prompt_copy(
|
||||
Some("Allow Calendar to create this event?"),
|
||||
None,
|
||||
Some("This tool may contact an external system."),
|
||||
);
|
||||
let question = build_mcp_tool_approval_question(
|
||||
"q".to_string(),
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"create_event",
|
||||
Some("Calendar"),
|
||||
prompt_options(true, true),
|
||||
prompt_copy.question_override,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_question_text(
|
||||
question.question,
|
||||
Some("This tool may contact an external system."),
|
||||
),
|
||||
"Tool call needs your approval. Reason: This tool may contact an external system."
|
||||
);
|
||||
assert_eq!(prompt_copy.message_override, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_elicitation_request_uses_message_override_and_readable_tool_params() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -899,6 +1035,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
&turn_context,
|
||||
"call-1",
|
||||
&invocation,
|
||||
None,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
@@ -963,6 +1100,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
None,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
@@ -1066,6 +1204,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
|
||||
&turn_context,
|
||||
"call-3",
|
||||
&invocation,
|
||||
None,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,12 @@ use crate::config::AgentRoleConfig;
|
||||
use crate::features::Feature;
|
||||
use crate::features::Features;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::split_qualified_tool_name;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_CODEX_KEY;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY;
|
||||
use crate::mcp_tool_call::MCP_TOOL_ARGS_META_KEY;
|
||||
use crate::mcp_tool_call::requires_mcp_tool_approval;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::shell::Shell;
|
||||
@@ -50,6 +55,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -63,6 +69,9 @@ const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
|
||||
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_suggest_description.md");
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
const CODEX_APP_ELICITATION_DESCRIPTION_FIELD_DESCRIPTION: &str =
|
||||
"Short user-facing approval sentence. This field is not forwarded to the app.";
|
||||
const CODEX_APP_ELICITATION_DESCRIPTION_NOTE: &str = "For consequential codex_apps tools that may require approval, you may optionally include `_meta._codex.elicitation_description` as a short user-facing approval sentence. This field is used for approval UI and is not forwarded to the app.";
|
||||
|
||||
fn unified_exec_output_schema() -> JsonValue {
|
||||
json!({
|
||||
@@ -2268,7 +2277,10 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
||||
fully_qualified_name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
let add_elicitation_metadata =
|
||||
should_add_codex_app_elicitation_metadata(&fully_qualified_name, tool.annotations.as_ref());
|
||||
let (description, input_schema, output_schema) =
|
||||
mcp_tool_to_openai_tool_parts(tool, add_elicitation_metadata)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: fully_qualified_name,
|
||||
@@ -2284,7 +2296,10 @@ pub(crate) fn mcp_tool_to_deferred_openai_tool(
|
||||
name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
let add_elicitation_metadata =
|
||||
should_add_codex_app_elicitation_metadata(&name, tool.annotations.as_ref());
|
||||
let (description, input_schema, _) =
|
||||
mcp_tool_to_openai_tool_parts(tool, add_elicitation_metadata)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name,
|
||||
@@ -2320,6 +2335,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, s
|
||||
|
||||
fn mcp_tool_to_openai_tool_parts(
|
||||
tool: rmcp::model::Tool,
|
||||
add_elicitation_metadata: bool,
|
||||
) -> Result<(String, JsonSchema, Option<JsonValue>), serde_json::Error> {
|
||||
let rmcp::model::Tool {
|
||||
description,
|
||||
@@ -2348,18 +2364,94 @@ fn mcp_tool_to_openai_tool_parts(
|
||||
// `integer`. Our internal JsonSchema is a small subset and requires
|
||||
// `type`, so we coerce/sanitize here for compatibility.
|
||||
sanitize_json_schema(&mut serialized_input_schema);
|
||||
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
|
||||
let mut input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
|
||||
let structured_content_schema = output_schema
|
||||
.map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone()))
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
|
||||
let output_schema = Some(mcp_call_tool_result_output_schema(
|
||||
structured_content_schema,
|
||||
));
|
||||
let description = description.map(Into::into).unwrap_or_default();
|
||||
let mut description = description
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.unwrap_or_default();
|
||||
|
||||
if add_elicitation_metadata
|
||||
&& add_codex_app_elicitation_metadata_to_input_schema(&mut input_schema)
|
||||
{
|
||||
description = append_codex_app_elicitation_description_note(&description);
|
||||
}
|
||||
|
||||
Ok((description, input_schema, output_schema))
|
||||
}
|
||||
|
||||
fn should_add_codex_app_elicitation_metadata(
|
||||
fully_qualified_name: &str,
|
||||
annotations: Option<&ToolAnnotations>,
|
||||
) -> bool {
|
||||
split_qualified_tool_name(fully_qualified_name)
|
||||
.is_some_and(|(server_name, _)| server_name == CODEX_APPS_MCP_SERVER_NAME)
|
||||
&& annotations.is_some_and(requires_mcp_tool_approval)
|
||||
}
|
||||
|
||||
fn append_codex_app_elicitation_description_note(description: &str) -> String {
|
||||
if description.is_empty() {
|
||||
return CODEX_APP_ELICITATION_DESCRIPTION_NOTE.to_string();
|
||||
}
|
||||
|
||||
format!("{description}\n\n{CODEX_APP_ELICITATION_DESCRIPTION_NOTE}")
|
||||
}
|
||||
|
||||
fn add_codex_app_elicitation_metadata_to_input_schema(input_schema: &mut JsonSchema) -> bool {
|
||||
let JsonSchema::Object { properties, .. } = input_schema else {
|
||||
return false;
|
||||
};
|
||||
let meta_schema = properties
|
||||
.entry(MCP_TOOL_ARGS_META_KEY.to_string())
|
||||
.or_insert_with(new_codex_app_meta_schema);
|
||||
|
||||
add_codex_app_elicitation_description_to_meta_schema(meta_schema)
|
||||
}
|
||||
|
||||
fn add_codex_app_elicitation_description_to_meta_schema(meta_schema: &mut JsonSchema) -> bool {
|
||||
let JsonSchema::Object { properties, .. } = meta_schema else {
|
||||
return false;
|
||||
};
|
||||
let codex_schema = properties
|
||||
.entry(MCP_TOOL_ARGS_CODEX_KEY.to_string())
|
||||
.or_insert_with(new_codex_app_control_meta_schema);
|
||||
|
||||
add_codex_app_elicitation_description_to_codex_schema(codex_schema)
|
||||
}
|
||||
|
||||
fn add_codex_app_elicitation_description_to_codex_schema(codex_schema: &mut JsonSchema) -> bool {
|
||||
let JsonSchema::Object { properties, .. } = codex_schema else {
|
||||
return false;
|
||||
};
|
||||
properties.insert(
|
||||
MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY.to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(CODEX_APP_ELICITATION_DESCRIPTION_FIELD_DESCRIPTION.to_string()),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn new_codex_app_meta_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_codex_app_control_meta_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
@@ -31,6 +32,32 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_tool_with_annotations(
|
||||
name: &str,
|
||||
description: &str,
|
||||
input_schema: serde_json::Value,
|
||||
annotations: ToolAnnotations,
|
||||
) -> rmcp::model::Tool {
|
||||
rmcp::model::Tool {
|
||||
annotations: Some(annotations),
|
||||
..mcp_tool(name, description, input_schema)
|
||||
}
|
||||
}
|
||||
|
||||
fn annotations(
|
||||
read_only: Option<bool>,
|
||||
destructive: Option<bool>,
|
||||
open_world: Option<bool>,
|
||||
) -> ToolAnnotations {
|
||||
ToolAnnotations {
|
||||
destructive_hint: destructive,
|
||||
idempotent_hint: None,
|
||||
open_world_hint: open_world,
|
||||
read_only_hint: read_only,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
||||
let slug = name.replace(' ', "-").to_lowercase();
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
@@ -254,6 +281,121 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consequential_codex_apps_tools_include_elicitation_description_meta() {
|
||||
let openai_tool = mcp_tool_to_openai_tool(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
mcp_tool_with_annotations(
|
||||
"calendar_create_event",
|
||||
"Create an event",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
annotations(Some(false), None, Some(true)),
|
||||
),
|
||||
)
|
||||
.expect("convert tool");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(openai_tool.parameters).expect("serialize parameters"),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_codex": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"elicitation_description": {
|
||||
"type": "string",
|
||||
"description": "Short user-facing approval sentence. This field is not forwarded to the app."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
openai_tool
|
||||
.description
|
||||
.contains("`_meta._codex.elicitation_description`")
|
||||
);
|
||||
assert!(openai_tool.description.contains("not forwarded to the app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_consequential_codex_apps_tools_do_not_include_elicitation_description_meta() {
|
||||
let openai_tool = mcp_tool_to_openai_tool(
|
||||
"mcp__codex_apps__calendar_list_events".to_string(),
|
||||
mcp_tool_with_annotations(
|
||||
"calendar_list_events",
|
||||
"List events",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calendar_id": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
annotations(Some(true), None, None),
|
||||
),
|
||||
)
|
||||
.expect("convert tool");
|
||||
let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize parameters");
|
||||
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|properties| properties.get("_meta")),
|
||||
None
|
||||
);
|
||||
assert_eq!(openai_tool.description, "List events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_codex_apps_tools_do_not_include_elicitation_description_meta() {
|
||||
let openai_tool = mcp_tool_to_openai_tool(
|
||||
"mcp__custom_server__dangerous_tool".to_string(),
|
||||
mcp_tool_with_annotations(
|
||||
"dangerous_tool",
|
||||
"Dangerous action",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
annotations(Some(false), Some(true), Some(true)),
|
||||
),
|
||||
)
|
||||
.expect("convert tool");
|
||||
let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize parameters");
|
||||
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|properties| properties.get("_meta")),
|
||||
None
|
||||
);
|
||||
assert_eq!(openai_tool.description, "Dangerous action");
|
||||
}
|
||||
|
||||
fn tool_name(tool: &ToolSpec) -> &str {
|
||||
match tool {
|
||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||
@@ -2682,6 +2824,59 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_augments_consequential_codex_apps_tool_descriptions_with_elicitation_meta() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::UnifiedExec);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
mcp_tool_with_annotations(
|
||||
"calendar_create_event",
|
||||
"Create an event",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
annotations(Some(false), None, Some(true)),
|
||||
),
|
||||
)])),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
|
||||
&find_tool(&tools, "mcp__codex_apps__calendar_create_event").spec
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
|
||||
assert!(description.contains("`_meta._codex.elicitation_description`"));
|
||||
assert!(description.contains(
|
||||
"mcp__codex_apps__calendar_create_event(args: { _meta?: { _codex?: { elicitation_description?: string; }; }; title: string; })"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_restricts_model_tools_to_exec_tools() {
|
||||
let mut features = Features::with_defaults();
|
||||
|
||||
Reference in New Issue
Block a user