This commit is contained in:
Matthew Zeng
2026-03-11 10:32:34 -07:00
parent 78280f872a
commit fba13b1a8b
4 changed files with 1457 additions and 48 deletions

View File

@@ -0,0 +1,963 @@
{
"schema_version": 3,
"generated_from_path": "codex-rs/core/src/consequential_tools.json",
"templates": [
{
"source_tool_index": 0,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "add_comment_to_issue",
"action": "add a comment to a pull request",
"template_params": [
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repo_full_name",
"label": "Repository"
},
{
"name": "comment",
"label": "Comment"
}
]
},
{
"source_tool_index": 1,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "add_reaction_to_issue_comment",
"action": "add a reaction to an issue comment",
"template_params": [
{
"name": "reaction",
"label": "Reaction"
},
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 2,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "add_reaction_to_pr",
"action": "add a reaction to a pull request",
"template_params": [
{
"name": "reaction",
"label": "Reaction"
},
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 3,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "add_reaction_to_pr_review_comment",
"action": "add a reaction to a pull request review comment",
"template_params": [
{
"name": "reaction",
"label": "Reaction"
},
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 4,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "add_review_to_pr",
"action": "submit a pull request review",
"template_params": [
{
"name": "action",
"label": "Action"
},
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "review",
"label": "Review"
}
]
},
{
"source_tool_index": 5,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "create_blob",
"action": "create a Git blob",
"template_params": [
{
"name": "repository_full_name",
"label": "Repository"
},
{
"name": "content",
"label": "Content"
}
]
},
{
"source_tool_index": 6,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "create_branch",
"action": "create a branch",
"template_params": [
{
"name": "branch_name",
"label": "Branch"
},
{
"name": "repository_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 7,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "create_commit",
"action": "create a commit",
"template_params": [
{
"name": "repository_full_name",
"label": "Repository"
},
{
"name": "message",
"label": "Message"
}
]
},
{
"source_tool_index": 8,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "create_pull_request",
"action": "create a pull request",
"template_params": [
{
"name": "title",
"label": "Title"
},
{
"name": "head_branch",
"label": "Head branch"
},
{
"name": "base_branch",
"label": "Base branch"
}
]
},
{
"source_tool_index": 9,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "create_tree",
"action": "create a Git tree",
"template_params": [
{
"name": "repository_full_name",
"label": "Repository"
},
{
"name": "tree_elements",
"label": "Changes"
}
]
},
{
"source_tool_index": 10,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "enable_auto_merge",
"action": "enable pull request auto-merge",
"template_params": [
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repository_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 11,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "label_pr",
"action": "add a label to a pull request",
"template_params": [
{
"name": "label",
"label": "Label"
},
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repository_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 12,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "remove_reaction_from_issue_comment",
"action": "remove a reaction from an issue comment",
"template_params": [
{
"name": "reaction_id",
"label": "Reaction"
},
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 13,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "remove_reaction_from_pr",
"action": "remove a reaction from a pull request",
"template_params": [
{
"name": "reaction_id",
"label": "Reaction"
},
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 14,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "remove_reaction_from_pr_review_comment",
"action": "remove a reaction from a pull request review comment",
"template_params": [
{
"name": "reaction_id",
"label": "Reaction"
},
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
}
]
},
{
"source_tool_index": 15,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "reply_to_review_comment",
"action": "reply to a pull request review comment",
"template_params": [
{
"name": "pr_number",
"label": "Pull request"
},
{
"name": "repo_full_name",
"label": "Repository"
},
{
"name": "comment",
"label": "Comment"
}
]
},
{
"source_tool_index": 16,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "update_issue_comment",
"action": "update an issue comment",
"template_params": [
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
},
{
"name": "comment",
"label": "Comment"
}
]
},
{
"source_tool_index": 17,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "update_ref",
"action": "update a branch reference",
"template_params": [
{
"name": "branch_name",
"label": "Branch"
},
{
"name": "repository_full_name",
"label": "Repository"
},
{
"name": "sha",
"label": "Commit"
}
]
},
{
"source_tool_index": 18,
"connector_id": "connector_76869538009648d5b282a4bb21c3d157",
"server_name": "codex_apps",
"tool_title": "update_review_comment",
"action": "update a pull request review comment",
"template_params": [
{
"name": "comment_id",
"label": "Comment"
},
{
"name": "repo_full_name",
"label": "Repository"
},
{
"name": "comment",
"label": "Comment"
}
]
},
{
"source_tool_index": 19,
"connector_id": "connector_947e0d954944416db111db556030eea6",
"server_name": "codex_apps",
"tool_title": "create_event",
"action": "create a calendar event",
"template_params": [
{
"name": "title",
"label": "Title"
},
{
"name": "start_time",
"label": "Start"
},
{
"name": "attendees",
"label": "Attendees"
}
]
},
{
"source_tool_index": 20,
"connector_id": "connector_947e0d954944416db111db556030eea6",
"server_name": "codex_apps",
"tool_title": "delete_event",
"action": "delete a calendar event",
"template_params": [
{
"name": "event_id",
"label": "Event"
}
]
},
{
"source_tool_index": 21,
"connector_id": "connector_947e0d954944416db111db556030eea6",
"server_name": "codex_apps",
"tool_title": "respond_event",
"action": "respond to a calendar event",
"template_params": [
{
"name": "response_status",
"label": "Response Status"
},
{
"name": "event_id",
"label": "Event"
}
]
},
{
"source_tool_index": 22,
"connector_id": "connector_947e0d954944416db111db556030eea6",
"server_name": "codex_apps",
"tool_title": "update_event",
"action": "update a calendar event",
"template_params": [
{
"name": "event_id",
"label": "Event"
}
]
},
{
"source_tool_index": 23,
"connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"server_name": "codex_apps",
"tool_title": "batch_update",
"action": "apply spreadsheet updates",
"template_params": [
{
"name": "spreadsheet_url",
"label": "Spreadsheet"
},
{
"name": "requests",
"label": "Changes"
}
]
},
{
"source_tool_index": 24,
"connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"server_name": "codex_apps",
"tool_title": "create_spreadsheet",
"action": "create a spreadsheet",
"template_params": [
{
"name": "title",
"label": "Title"
}
]
},
{
"source_tool_index": 25,
"connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"server_name": "codex_apps",
"tool_title": "duplicate_sheet_in_new_file",
"action": "copy a sheet into a new spreadsheet",
"template_params": [
{
"name": "source_sheet_name",
"label": "Source Sheet Name"
},
{
"name": "spreadsheet_url",
"label": "Spreadsheet"
},
{
"name": "new_file_name",
"label": "New File Name"
}
]
},
{
"source_tool_index": 26,
"connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b",
"server_name": "codex_apps",
"tool_title": "batch_update",
"action": "apply presentation updates",
"template_params": [
{
"name": "presentation_url",
"label": "Presentation"
},
{
"name": "requests",
"label": "Changes"
}
]
},
{
"source_tool_index": 27,
"connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b",
"server_name": "codex_apps",
"tool_title": "create_presentation",
"action": "create a presentation",
"template_params": [
{
"name": "title",
"label": "Title"
}
]
},
{
"source_tool_index": 28,
"connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"server_name": "codex_apps",
"tool_title": "batch_update",
"action": "apply document updates",
"template_params": [
{
"name": "document_url",
"label": "Document"
},
{
"name": "requests",
"label": "Changes"
}
]
},
{
"source_tool_index": 29,
"connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"server_name": "codex_apps",
"tool_title": "create_document",
"action": "create a document",
"template_params": [
{
"name": "title",
"label": "Title"
}
]
},
{
"source_tool_index": 30,
"connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"server_name": "codex_apps",
"tool_title": "copy_document",
"action": "copy a Google Drive file",
"template_params": [
{
"name": "url",
"label": "URL"
}
]
},
{
"source_tool_index": 31,
"connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"server_name": "codex_apps",
"tool_title": "share_document",
"action": "change file sharing",
"template_params": [
{
"name": "url",
"label": "URL"
},
{
"name": "permission",
"label": "Permission"
}
]
},
{
"source_tool_index": 32,
"connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d",
"server_name": "codex_apps",
"tool_title": "slack_send_message",
"action": "send a message",
"template_params": [
{
"name": "channel_id",
"label": "Conversation"
},
{
"name": "message",
"label": "Message"
}
]
},
{
"source_tool_index": 33,
"connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d",
"server_name": "codex_apps",
"tool_title": "slack_schedule_message",
"action": "schedule a message",
"template_params": [
{
"name": "channel_id",
"label": "Conversation"
},
{
"name": "post_at",
"label": "Send at"
},
{
"name": "message",
"label": "Message"
}
]
},
{
"source_tool_index": 34,
"connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d",
"server_name": "codex_apps",
"tool_title": "slack_create_canvas",
"action": "create a canvas",
"template_params": [
{
"name": "title",
"label": "Title"
},
{
"name": "content",
"label": "Content"
}
]
},
{
"source_tool_index": 35,
"connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d",
"server_name": "codex_apps",
"tool_title": "slack_send_message_draft",
"action": "create a message draft",
"template_params": [
{
"name": "channel_id",
"label": "Conversation"
},
{
"name": "message",
"label": "Message"
}
]
},
{
"source_tool_index": 36,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "add_comment_to_issue",
"action": "add a comment to a Linear issue",
"template_params": [
{
"name": "issue_id",
"label": "Issue"
},
{
"name": "body",
"label": "Body"
}
]
},
{
"source_tool_index": 37,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "add_label_to_issue",
"action": "add a label to a Linear issue",
"template_params": [
{
"name": "label_id",
"label": "Label"
},
{
"name": "issue_id",
"label": "Issue"
}
]
},
{
"source_tool_index": 38,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "add_url_attachment_to_issue",
"action": "attach a link to a Linear issue",
"template_params": [
{
"name": "url",
"label": "URL"
},
{
"name": "issue_id",
"label": "Issue"
},
{
"name": "title",
"label": "Title"
}
]
},
{
"source_tool_index": 39,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "assign_issue",
"action": "assign a Linear issue",
"template_params": [
{
"name": "issue_id",
"label": "Issue"
},
{
"name": "user_id",
"label": "User"
}
]
},
{
"source_tool_index": 40,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "create_issue",
"action": "create a Linear issue",
"template_params": [
{
"name": "title",
"label": "Title"
},
{
"name": "team_id",
"label": "Team"
}
]
},
{
"source_tool_index": 41,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "create_label",
"action": "create a Linear label",
"template_params": [
{
"name": "label_name",
"label": "Label Name"
}
]
},
{
"source_tool_index": 42,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "create_project",
"action": "create a Linear project",
"template_params": [
{
"name": "name",
"label": "Name"
},
{
"name": "team_id",
"label": "Team"
}
]
},
{
"source_tool_index": 43,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "remove_label_from_issue",
"action": "remove a label from a Linear issue",
"template_params": [
{
"name": "label_id",
"label": "Label"
},
{
"name": "issue_id",
"label": "Issue"
}
]
},
{
"source_tool_index": 44,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "resolve_comment",
"action": "resolve a Linear comment",
"template_params": [
{
"name": "comment_id",
"label": "Comment"
}
]
},
{
"source_tool_index": 45,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "set_issue_state",
"action": "change a Linear issue state",
"template_params": [
{
"name": "issue_id",
"label": "Issue"
},
{
"name": "state_id",
"label": "State"
}
]
},
{
"source_tool_index": 46,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "unassign_issue",
"action": "unassign a Linear issue",
"template_params": [
{
"name": "issue_id",
"label": "Issue"
}
]
},
{
"source_tool_index": 47,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "update_issue",
"action": "update a Linear issue",
"template_params": [
{
"name": "issue_id",
"label": "Issue"
},
{
"name": "issue_update",
"label": "Changes"
}
]
},
{
"source_tool_index": 48,
"connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31",
"server_name": "codex_apps",
"tool_title": "update_project",
"action": "update a Linear project",
"template_params": [
{
"name": "project_id",
"label": "Project"
},
{
"name": "update_fields",
"label": "Changes"
}
]
},
{
"source_tool_index": 49,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "apply_labels_to_emails",
"action": "apply label changes to Gmail messages",
"template_params": []
},
{
"source_tool_index": 50,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "batch_modify_email",
"action": "update Gmail message labels",
"template_params": []
},
{
"source_tool_index": 51,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "bulk_label_matching_emails",
"action": "label matching Gmail messages",
"template_params": [
{
"name": "label_name",
"label": "Label Name"
},
{
"name": "query",
"label": "Query"
}
]
},
{
"source_tool_index": 52,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "create_draft",
"action": "create an email draft",
"template_params": [
{
"name": "to",
"label": "To"
},
{
"name": "subject",
"label": "Subject"
},
{
"name": "body",
"label": "Body"
}
]
},
{
"source_tool_index": 53,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "create_label",
"action": "create a Gmail label",
"template_params": [
{
"name": "name",
"label": "Name"
}
]
},
{
"source_tool_index": 54,
"connector_id": "connector_2128aebfecb84f64a069897515042a44",
"server_name": "codex_apps",
"tool_title": "send_email",
"action": "send an email",
"template_params": [
{
"name": "to",
"label": "To"
},
{
"name": "subject",
"label": "Subject"
},
{
"name": "body",
"label": "Body"
}
]
}
]
}

View File

@@ -44,9 +44,11 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
use std::sync::LazyLock;
use toml_edit::value;
/// Handles the specified tool call dispatches the appropriate
@@ -385,6 +387,40 @@ struct McpToolApprovalPromptOptions {
allow_persistent_approval: bool,
}
#[derive(Debug, Deserialize)]
struct ConsequentialToolMessageTemplateCatalog {
templates: Vec<ConsequentialToolMessageTemplate>,
}
#[derive(Debug, Clone, Deserialize)]
struct ConsequentialToolMessageTemplate {
server_name: String,
connector_id: Option<String>,
tool_title: String,
action: String,
template_params: Vec<ConsequentialToolMessageTemplateParam>,
}
#[derive(Debug, Clone, Deserialize)]
struct ConsequentialToolMessageTemplateParam {
name: String,
label: String,
}
static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock<
Option<Vec<ConsequentialToolMessageTemplate>>,
> = LazyLock::new(|| {
serde_json::from_str::<ConsequentialToolMessageTemplateCatalog>(include_str!(
"consequential_tool_message_templates.json",
))
.map(|catalog| catalog.templates)
.map_err(|err| {
error!("failed to parse bundled consequential tool message templates: {err}");
err
})
.ok()
});
const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow";
const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session";
@@ -403,6 +439,11 @@ const MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: &str = "connector_description
const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title";
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
const MCP_TOOL_APPROVAL_ACTOR_MAX_CHARS: usize = 48;
const MCP_TOOL_APPROVAL_ACTION_MAX_CHARS: usize = 72;
const MCP_TOOL_APPROVAL_DETAIL_VALUE_MAX_CHARS: usize = 80;
const MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS: usize = 220;
const MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX: &str = "...";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct McpToolApprovalKey {
@@ -507,9 +548,8 @@ async fn maybe_request_mcp_tool_approval(
question_id.clone(),
&invocation.server,
&invocation.tool,
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
annotations,
invocation.arguments.as_ref(),
metadata,
prompt_options,
);
question.question =
@@ -738,34 +778,25 @@ fn build_mcp_tool_approval_question(
question_id: String,
server: &str,
tool_name: &str,
tool_title: Option<&str>,
connector_name: Option<&str>,
annotations: Option<&ToolAnnotations>,
tool_params: Option<&serde_json::Value>,
metadata: Option<&McpToolApprovalMetadata>,
prompt_options: McpToolApprovalPromptOptions,
) -> RequestUserInputQuestion {
let destructive =
annotations.and_then(|annotations| annotations.destructive_hint) == Some(true);
let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true);
let reason = match (destructive, open_world) {
(true, true) => "may modify data and access external systems",
(true, false) => "may modify or delete data",
(false, true) => "may access external systems",
(false, false) => "may have side effects",
};
let tool_label = tool_title.unwrap_or(tool_name);
let app_label = connector_name
.map(|name| format!("The {name} app"))
.unwrap_or_else(|| {
if server == CODEX_APPS_MCP_SERVER_NAME {
"This app".to_string()
} else {
format!("The {server} MCP server")
}
});
let question = format!(
"{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?"
);
let tool_title = metadata.and_then(|metadata| metadata.tool_title.as_deref());
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let app_label = mcp_tool_approval_app_label(server, connector_name);
let question = build_templated_mcp_tool_approval_question_text(
server,
tool_name,
connector_id,
connector_name,
tool_params,
)
.unwrap_or_else(|| {
build_legacy_mcp_tool_approval_question_text(&app_label, tool_name, tool_title, annotations)
});
let mut options = vec![RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
@@ -790,7 +821,7 @@ fn build_mcp_tool_approval_question(
RequestUserInputQuestion {
id: question_id,
header: "Approve app tool call?".to_string(),
header: String::new(),
question,
is_other: false,
is_secret: false,
@@ -798,6 +829,187 @@ fn build_mcp_tool_approval_question(
}
}
fn build_templated_mcp_tool_approval_question_text(
server: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
tool_params: Option<&serde_json::Value>,
) -> Option<String> {
let actor = truncate_for_mcp_tool_approval_message(
&mcp_tool_approval_actor_label(server, connector_name),
MCP_TOOL_APPROVAL_ACTOR_MAX_CHARS,
);
let tool_title = derive_consequential_tool_title(tool_name, connector_name);
let template = find_consequential_tool_message_template(server, connector_id, &tool_title)?;
let action = truncate_for_mcp_tool_approval_message(
&template.action,
MCP_TOOL_APPROVAL_ACTION_MAX_CHARS,
);
let details = render_consequential_tool_details(template, tool_params)?;
if details.is_empty() {
Some(format!("Allow {actor} to {action}?"))
} else {
Some(format!("Allow {actor} to {action}?\n\n{details}"))
}
}
fn find_consequential_tool_message_template(
server: &str,
connector_id: Option<&str>,
tool_title: &str,
) -> Option<&'static ConsequentialToolMessageTemplate> {
CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES
.as_ref()?
.iter()
.find(|template| {
template.server_name == server
&& template.connector_id.as_deref() == connector_id
&& template.tool_title == tool_title
})
}
fn derive_consequential_tool_title(tool_name: &str, connector_name: Option<&str>) -> String {
if let Some(connector_name) = connector_name {
let connector_name = connector_name.to_lowercase();
if let Some(suffix) = tool_name.strip_prefix(&connector_name) {
let tool_title = suffix.trim_start_matches('_');
if !tool_title.is_empty() {
return tool_title.to_string();
}
}
}
tool_name.to_string()
}
fn render_consequential_tool_details(
template: &ConsequentialToolMessageTemplate,
tool_params: Option<&serde_json::Value>,
) -> Option<String> {
let tool_params = tool_params?.as_object()?;
let mut details = String::new();
let mut remaining_chars = MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS;
for param in &template.template_params {
let value = format_consequential_tool_message_value(tool_params.get(&param.name)?)?;
let line = format!("{}: {value}", param.label);
let separator_chars = usize::from(!details.is_empty());
if remaining_chars <= separator_chars {
break;
}
if !details.is_empty() {
details.push('\n');
remaining_chars -= 1;
}
let line_chars = line.chars().count();
if line_chars <= remaining_chars {
details.push_str(&line);
remaining_chars -= line_chars;
} else {
details.push_str(&truncate_for_mcp_tool_approval_message(
&line,
remaining_chars,
));
break;
}
}
Some(details)
}
fn format_consequential_tool_message_value(value: &serde_json::Value) -> Option<String> {
let formatted = match value {
serde_json::Value::Null => None,
serde_json::Value::Bool(value) => Some(value.to_string()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::String(value) => {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
if value.is_empty() { None } else { Some(value) }
}
serde_json::Value::Array(values) => {
let values = values
.iter()
.map(format_consequential_tool_message_value)
.collect::<Option<Vec<_>>>()?;
if values.is_empty() {
None
} else {
Some(values.join(", "))
}
}
serde_json::Value::Object(_) => serde_json::to_string(value).ok(),
}?;
Some(truncate_for_mcp_tool_approval_message(
&formatted,
MCP_TOOL_APPROVAL_DETAIL_VALUE_MAX_CHARS,
))
}
fn truncate_for_mcp_tool_approval_message(text: &str, max_chars: usize) -> String {
let char_count = text.chars().count();
if char_count <= max_chars {
return text.to_string();
}
if max_chars == 0 {
return String::new();
}
let suffix_chars = MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX.chars().count();
if max_chars <= suffix_chars {
return ".".repeat(max_chars);
}
let prefix: String = text.chars().take(max_chars - suffix_chars).collect();
format!("{prefix}{MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX}")
}
fn mcp_tool_approval_app_label(server: &str, connector_name: Option<&str>) -> String {
connector_name
.map(|name| format!("The {name} app"))
.unwrap_or_else(|| {
if server == CODEX_APPS_MCP_SERVER_NAME {
"This app".to_string()
} else {
format!("The {server} MCP server")
}
})
}
fn mcp_tool_approval_actor_label(server: &str, connector_name: Option<&str>) -> String {
connector_name.map(str::to_string).unwrap_or_else(|| {
if server == CODEX_APPS_MCP_SERVER_NAME {
"this app".to_string()
} else {
format!("the {server} MCP server")
}
})
}
fn build_legacy_mcp_tool_approval_question_text(
app_label: &str,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> String {
let destructive =
annotations.and_then(|annotations| annotations.destructive_hint) == Some(true);
let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true);
let reason = match (destructive, open_world) {
(true, true) => "may modify data and access external systems",
(true, false) => "may modify or delete data",
(false, true) => "may access external systems",
(false, false) => "may have side effects",
};
let tool_label = tool_title.unwrap_or(tool_name);
format!(
"{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?"
)
}
fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String {
match monitor_reason.map(str::trim) {
Some(reason) if !reason.is_empty() => {
@@ -1282,17 +1494,18 @@ mod tests {
#[test]
fn custom_mcp_tool_question_mentions_server_name() {
let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None);
metadata.annotations = Some(annotations(Some(false), Some(true), None));
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
Some(&metadata),
prompt_options(false, false),
);
assert_eq!(question.header, "Approve app tool call?");
assert!(question.header.is_empty());
assert_eq!(
question.question,
"The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data. Allow this action?"
@@ -1309,13 +1522,14 @@ mod tests {
#[test]
fn codex_apps_tool_question_keeps_legacy_app_label() {
let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None);
metadata.annotations = Some(annotations(Some(false), Some(true), None));
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
Some(&metadata),
prompt_options(true, true),
);
@@ -1328,13 +1542,15 @@ mod tests {
#[test]
fn trusted_codex_apps_tool_question_offers_always_allow() {
let mut metadata =
approval_metadata(None, Some("Calendar"), None, Some("Run Action"), None);
metadata.annotations = Some(annotations(Some(false), Some(true), None));
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
Some("Calendar"),
Some(&annotations(Some(false), Some(true), None)),
None,
Some(&metadata),
prompt_options(true, true),
);
let options = question.options.expect("options");
@@ -1370,13 +1586,15 @@ mod tests {
tool_name: "run_action".to_string(),
};
let persistent_key = session_key.clone();
let mut metadata =
approval_metadata(None, Some("Calendar"), None, Some("Run Action"), None);
metadata.annotations = Some(annotations(Some(false), Some(true), None));
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
Some("Calendar"),
Some(&annotations(Some(false), Some(true), None)),
None,
Some(&metadata),
mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false),
);
@@ -1397,13 +1615,14 @@ mod tests {
#[test]
fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None);
metadata.annotations = Some(annotations(Some(false), Some(true), None));
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
Some(&metadata),
prompt_options(true, false),
);
@@ -1422,6 +1641,173 @@ mod tests {
);
}
#[test]
fn derives_consequential_tool_title_from_connector_prefix() {
assert_eq!(
derive_consequential_tool_title("gmail_send_email", Some("Gmail")),
"send_email".to_string()
);
assert_eq!(
derive_consequential_tool_title(
"slack (oai internal)_slack_send_message",
Some("Slack (OAI Internal)")
),
"slack_send_message".to_string()
);
assert_eq!(
derive_consequential_tool_title("custom_tool", None),
"custom_tool".to_string()
);
}
#[test]
fn truncate_for_mcp_tool_approval_message_preserves_utf8_boundaries() {
assert_eq!(
truncate_for_mcp_tool_approval_message("aé🙂zbcdef", 8),
"aé..."
);
assert_eq!(truncate_for_mcp_tool_approval_message("aé🙂z", 4), "aé🙂z");
}
#[test]
fn consequential_tool_message_templates_use_at_most_three_params() {
let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES
.as_ref()
.expect("templates loaded");
let oversized = templates
.iter()
.filter(|template| template.template_params.len() > 3)
.map(|template| template.tool_title.clone())
.collect::<Vec<_>>();
assert_eq!(oversized, Vec::<String>::new());
}
#[test]
fn codex_apps_tool_question_uses_template_when_params_match() {
let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES
.as_ref()
.expect("templates loaded")
.iter()
.find(|template| template.tool_title == "send_email")
.expect("send_email template");
let mut metadata = approval_metadata(
template.connector_id.as_deref(),
Some("Gmail"),
None,
Some("Send Email"),
None,
);
metadata.annotations = Some(annotations(Some(false), Some(true), Some(true)));
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"gmail_send_email",
Some(&serde_json::json!({
"to": "alice@example.com",
"subject": "Quarterly update",
"body": "Status update body",
})),
Some(&metadata),
prompt_options(false, false),
);
assert_eq!(
question.question,
"Allow Gmail to send an email?\n\nTo: alice@example.com\nSubject: Quarterly update\nBody: Status update body"
);
}
#[test]
fn codex_apps_tool_question_falls_back_when_template_rendering_fails() {
let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES
.as_ref()
.expect("templates loaded")
.iter()
.find(|template| template.tool_title == "send_email")
.expect("send_email template");
let mut metadata = approval_metadata(
template.connector_id.as_deref(),
Some("Gmail"),
None,
Some("Send Email"),
None,
);
metadata.annotations = Some(annotations(Some(false), Some(true), Some(true)));
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"gmail_send_email",
Some(&serde_json::json!({
"subject": "Quarterly update",
"body": "Status update body",
})),
Some(&metadata),
prompt_options(false, false),
);
assert_eq!(
question.question,
"The Gmail app wants to run the tool \"Send Email\", which may modify data and access external systems. Allow this action?"
);
}
#[test]
fn codex_apps_tool_question_truncates_long_template_values() {
let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES
.as_ref()
.expect("templates loaded")
.iter()
.find(|template| template.tool_title == "create_pull_request")
.expect("create_pull_request template");
let mut metadata = approval_metadata(
template.connector_id.as_deref(),
Some("GitHub"),
None,
Some("Create Pull Request"),
None,
);
metadata.annotations = Some(annotations(Some(false), Some(true), Some(true)));
let title = "Launch readiness review ".repeat(10);
let head_branch = "feature/synchronize-release-notes-and-migration-guides-".repeat(4);
let base_branch = "release/2026-03-11-super-long-branch-name".repeat(3);
let repository_full_name =
"openai/very-long-repository-name-for-approval-prompt-tests".repeat(3);
let body =
"This pull request includes the release notes, migration guide, and rollout plan. "
.repeat(4);
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"github_create_pull_request",
Some(&serde_json::json!({
"title": title,
"head_branch": head_branch,
"base_branch": base_branch,
"repository_full_name": repository_full_name,
"body": body,
})),
Some(&metadata),
prompt_options(false, false),
);
assert_eq!(
question.question,
"Allow GitHub to create a pull request?\n\nTitle: Launch readiness review Launch readiness review Launch readiness review La...\nHead branch: feature/synchronize-release-notes-and-migration-guides-feature/synchronize...\nBase branch: release/2026-03-11-..."
);
assert!(
question.question.chars().count()
<= format!("Allow GitHub to create a pull request?\n\n")
.chars()
.count()
+ MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS
);
}
#[test]
fn custom_servers_keep_session_remember_without_persistent_approval() {
let invocation = McpInvocation {

View File

@@ -144,6 +144,11 @@ impl RequestUserInputOverlay {
}
if !notes_visible {
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let mut spacer_after_options = 0;
if remaining > footer_pref {
spacer_after_options = 1;
@@ -151,11 +156,6 @@ impl RequestUserInputOverlay {
}
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let grow_by = remaining.min(options.full.saturating_sub(options_height));
options_height = options_height.saturating_add(grow_by);
return LayoutPlan {

View File

@@ -1439,6 +1439,26 @@ mod tests {
}
}
fn question_with_long_approval_body(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Allow GitHub to create a pull request?\n\nTitle: Launch readiness review for the release branch.\nHead branch: feature/synchronize-release-notes-and-migration-guides\nBase branch: release/2026-03-11".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Allow".to_string(),
description: "Run the tool and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: "Cancel".to_string(),
description: "Cancel this tool call.".to_string(),
},
]),
}
}
fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
@@ -2505,6 +2525,46 @@ mod tests {
);
}
#[test]
fn request_user_input_long_approval_body_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_long_approval_body("q1", "Approval")],
),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 70, 12);
insta::assert_snapshot!(
"request_user_input_long_approval_body",
render_snapshot(&overlay, area)
);
}
#[test]
fn long_approval_body_keeps_spacer_after_question() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_long_approval_body("q1", "Approval")],
),
tx,
true,
false,
false,
);
let content_area = menu_surface_inset(Rect::new(0, 0, 70, 12));
let sections = overlay.layout_sections(content_area);
let question_bottom = sections.question_area.y + sections.question_area.height;
assert_eq!(sections.options_area.y.saturating_sub(question_bottom), 1);
}
#[test]
fn layout_allocates_all_wrapped_options_when_space_allows() {
let (tx, _rx) = test_sender();