From 23e55d7668dabf86f8ae80b2ed1947a5192da11a Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 12 Mar 2026 00:35:21 -0700 Subject: [PATCH] [elicitation] User-friendly tool call messages. (#14403) - [x] Add a curated set of tool call messages and human-readable tool param names. --- .../consequential_tool_message_templates.json | 962 ++++++++++++++++++ codex-rs/core/src/lib.rs | 1 + .../core/src/mcp_tool_approval_templates.rs | 365 +++++++ codex-rs/core/src/mcp_tool_call.rs | 252 +++-- .../src/bottom_pane/mcp_server_elicitation.rs | 264 ++++- ...tion_approval_form_with_param_summary.snap | 19 + 6 files changed, 1782 insertions(+), 81 deletions(-) create mode 100644 codex-rs/core/src/consequential_tool_message_templates.json create mode 100644 codex-rs/core/src/mcp_tool_approval_templates.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap diff --git a/codex-rs/core/src/consequential_tool_message_templates.json b/codex-rs/core/src/consequential_tool_message_templates.json new file mode 100644 index 000000000..83e11c79a --- /dev/null +++ b/codex-rs/core/src/consequential_tool_message_templates.json @@ -0,0 +1,962 @@ +{ + "schema_version": 4, + "templates": [ + { + "source_tool_index": 0, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to add a comment to a pull request?" + }, + { + "source_tool_index": 1, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_issue_comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to an issue comment?" + }, + { + "source_tool_index": 2, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to a pull request?" + }, + { + "source_tool_index": 3, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr_review_comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to a pull request review comment?" + }, + { + "source_tool_index": 4, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_review_to_pr", + "template_params": [ + { + "name": "action", + "label": "Action" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "review", + "label": "Review" + } + ], + "template": "Allow {connector_name} to submit a pull request review?" + }, + { + "source_tool_index": 5, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_blob", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "content", + "label": "Content" + } + ], + "template": "Allow {connector_name} to create a Git blob?" + }, + { + "source_tool_index": 6, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_branch", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to create a branch?" + }, + { + "source_tool_index": 7, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_commit", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to create a commit?" + }, + { + "source_tool_index": 8, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_pull_request", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "head_branch", + "label": "Head branch" + }, + { + "name": "base_branch", + "label": "Base branch" + } + ], + "template": "Allow {connector_name} to create a pull request?" + }, + { + "source_tool_index": 9, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_tree", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "tree_elements", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to create a Git tree?" + }, + { + "source_tool_index": 10, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "enable_auto_merge", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to enable pull request auto-merge?" + }, + { + "source_tool_index": 11, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "label_pr", + "template_params": [ + { + "name": "label", + "label": "Label" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a label to a pull request?" + }, + { + "source_tool_index": 12, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_issue_comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from an issue comment?" + }, + { + "source_tool_index": 13, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from a pull request?" + }, + { + "source_tool_index": 14, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr_review_comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from a pull request review comment?" + }, + { + "source_tool_index": 15, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "reply_to_review_comment", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to reply to a pull request review comment?" + }, + { + "source_tool_index": 16, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_issue_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment ID" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to update an issue comment?" + }, + { + "source_tool_index": 17, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_ref", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "sha", + "label": "Commit" + } + ], + "template": "Allow {connector_name} to update a branch reference?" + }, + { + "source_tool_index": 18, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_review_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment ID" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to update a pull request review comment?" + }, + { + "source_tool_index": 19, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "create_event", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "start_time", + "label": "Start" + }, + { + "name": "attendees", + "label": "Attendees" + } + ], + "template": "Allow {connector_name} to create an event?" + }, + { + "source_tool_index": 20, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "delete_event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to delete an event?" + }, + { + "source_tool_index": 21, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "respond_event", + "template_params": [ + { + "name": "response_status", + "label": "Response Status" + }, + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to respond to an event?" + }, + { + "source_tool_index": 22, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "update_event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to update an event?" + }, + { + "source_tool_index": 23, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply spreadsheet updates?" + }, + { + "source_tool_index": 24, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "create_spreadsheet", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a spreadsheet?" + }, + { + "source_tool_index": 25, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "duplicate_sheet_in_new_file", + "template_params": [ + { + "name": "source_sheet_name", + "label": "Source Sheet Name" + }, + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "new_file_name", + "label": "New File Name" + } + ], + "template": "Allow {connector_name} to copy a sheet into a new spreadsheet?" + }, + { + "source_tool_index": 26, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "presentation_url", + "label": "Presentation" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply presentation updates?" + }, + { + "source_tool_index": 27, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "create_presentation", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a presentation?" + }, + { + "source_tool_index": 28, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "document_url", + "label": "Document" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply document updates?" + }, + { + "source_tool_index": 29, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "create_document", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a document?" + }, + { + "source_tool_index": 30, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "copy_document", + "template_params": [ + { + "name": "url", + "label": "URL" + } + ], + "template": "Allow {connector_name} to copy a file?" + }, + { + "source_tool_index": 31, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "share_document", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "permission", + "label": "Permission" + } + ], + "template": "Allow {connector_name} to change file sharing?" + }, + { + "source_tool_index": 32, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to send a message?" + }, + { + "source_tool_index": 33, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_schedule_message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "post_at", + "label": "Send at" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to schedule a message?" + }, + { + "source_tool_index": 34, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_create_canvas", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "content", + "label": "Content" + } + ], + "template": "Allow {connector_name} to create a canvas?" + }, + { + "source_tool_index": 35, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message_draft", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to create a message draft?" + }, + { + "source_tool_index": 36, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to add a comment to an issue?" + }, + { + "source_tool_index": 37, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_label_to_issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to add a label to an issue?" + }, + { + "source_tool_index": 38, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_url_attachment_to_issue", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to attach a link to an issue?" + }, + { + "source_tool_index": 39, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "assign_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "user_id", + "label": "User" + } + ], + "template": "Allow {connector_name} to assign an issue?" + }, + { + "source_tool_index": 40, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_issue", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "team_id", + "label": "Team" + } + ], + "template": "Allow {connector_name} to create an issue?" + }, + { + "source_tool_index": 41, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_label", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + } + ], + "template": "Allow {connector_name} to create a label?" + }, + { + "source_tool_index": 42, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_project", + "template_params": [ + { + "name": "name", + "label": "Name" + }, + { + "name": "team_id", + "label": "Team" + } + ], + "template": "Allow {connector_name} to create a project?" + }, + { + "source_tool_index": 43, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "remove_label_from_issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to remove a label from an issue?" + }, + { + "source_tool_index": 44, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "resolve_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to resolve a comment?" + }, + { + "source_tool_index": 45, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "set_issue_state", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "state_id", + "label": "State" + } + ], + "template": "Allow {connector_name} to change issue state?" + }, + { + "source_tool_index": 46, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "unassign_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to unassign an issue?" + }, + { + "source_tool_index": 47, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "issue_update", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to update an issue?" + }, + { + "source_tool_index": 48, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_project", + "template_params": [ + { + "name": "project_id", + "label": "Project" + }, + { + "name": "update_fields", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to update a project?" + }, + { + "source_tool_index": 49, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "apply_labels_to_emails", + "template_params": [], + "template": "Allow {connector_name} to apply label changes to messages?" + }, + { + "source_tool_index": 50, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "batch_modify_email", + "template_params": [], + "template": "Allow {connector_name} to update message labels?" + }, + { + "source_tool_index": 51, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "bulk_label_matching_emails", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + }, + { + "name": "query", + "label": "Query" + } + ], + "template": "Allow {connector_name} to label matching messages?" + }, + { + "source_tool_index": 52, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_draft", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to create an email draft?" + }, + { + "source_tool_index": 53, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_label", + "template_params": [ + { + "name": "name", + "label": "Name" + } + ], + "template": "Allow {connector_name} to create a label?" + }, + { + "source_tool_index": 54, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "send_email", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to send an email?" + } + ] +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1bfc43f31..51ea3f47e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -47,6 +47,7 @@ pub mod instructions; pub mod landlock; pub mod mcp; mod mcp_connection_manager; +mod mcp_tool_approval_templates; pub mod models_manager; mod network_policy_decision; pub mod network_proxy_loader; diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs new file mode 100644 index 000000000..f8fbad3ed --- /dev/null +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -0,0 +1,365 @@ +use std::collections::HashSet; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use tracing::warn; + +const CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION: u8 = 4; +const CONNECTOR_NAME_TEMPLATE_VAR: &str = "{connector_name}"; + +static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock< + Option>, +> = LazyLock::new(load_consequential_tool_message_templates); + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RenderedMcpToolApprovalTemplate { + pub(crate) question: String, + pub(crate) elicitation_message: String, + pub(crate) tool_params: Option, + pub(crate) tool_params_display: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct RenderedMcpToolApprovalParam { + pub(crate) name: String, + pub(crate) value: Value, +} + +#[derive(Debug, Deserialize)] +struct ConsequentialToolMessageTemplatesFile { + schema_version: u8, + templates: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct ConsequentialToolMessageTemplate { + connector_id: String, + server_name: String, + tool_title: String, + template: String, + template_params: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct ConsequentialToolTemplateParam { + name: String, + label: String, +} + +pub(crate) fn render_mcp_tool_approval_template( + server_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + tool_title: Option<&str>, + tool_params: Option<&Value>, +) -> Option { + let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.as_ref()?; + render_mcp_tool_approval_template_from_templates( + templates, + server_name, + connector_id, + connector_name, + tool_title, + tool_params, + ) +} + +fn load_consequential_tool_message_templates() -> Option> { + let templates = match serde_json::from_str::( + include_str!("consequential_tool_message_templates.json"), + ) { + Ok(templates) => templates, + Err(err) => { + warn!(error = %err, "failed to parse consequential tool approval templates"); + return None; + } + }; + + if templates.schema_version != CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION { + warn!( + found_schema_version = templates.schema_version, + expected_schema_version = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION, + "unexpected consequential tool approval templates schema version" + ); + return None; + } + + Some(templates.templates) +} + +fn render_mcp_tool_approval_template_from_templates( + templates: &[ConsequentialToolMessageTemplate], + server_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + tool_title: Option<&str>, + tool_params: Option<&Value>, +) -> Option { + let connector_id = connector_id?; + let tool_title = tool_title.map(str::trim).filter(|name| !name.is_empty())?; + let template = templates.iter().find(|template| { + template.server_name == server_name + && template.connector_id == connector_id + && template.tool_title == tool_title + })?; + let elicitation_message = render_question_template(&template.template, connector_name)?; + let (tool_params, tool_params_display) = match tool_params { + Some(Value::Object(tool_params)) => { + render_tool_params(tool_params, &template.template_params)? + } + Some(_) => return None, + None => (None, Vec::new()), + }; + + Some(RenderedMcpToolApprovalTemplate { + question: elicitation_message.clone(), + elicitation_message, + tool_params, + tool_params_display, + }) +} + +fn render_question_template(template: &str, connector_name: Option<&str>) -> Option { + let template = template.trim(); + if template.is_empty() { + return None; + } + + if template.contains(CONNECTOR_NAME_TEMPLATE_VAR) { + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty())?; + return Some(template.replace(CONNECTOR_NAME_TEMPLATE_VAR, connector_name)); + } + + Some(template.to_string()) +} + +fn render_tool_params( + tool_params: &Map, + template_params: &[ConsequentialToolTemplateParam], +) -> Option<(Option, Vec)> { + let mut relabeled = Map::new(); + let mut display_params = Vec::new(); + let mut handled_names = HashSet::new(); + + for template_param in template_params { + let label = template_param.label.trim(); + if label.is_empty() { + return None; + } + let Some(value) = tool_params.get(&template_param.name) else { + continue; + }; + if relabeled.insert(label.to_string(), value.clone()).is_some() { + return None; + } + display_params.push(RenderedMcpToolApprovalParam { + name: label.to_string(), + value: value.clone(), + }); + handled_names.insert(template_param.name.as_str()); + } + + let mut remaining_params = tool_params + .iter() + .filter(|(name, _)| !handled_names.contains(name.as_str())) + .collect::>(); + remaining_params.sort_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); + + for (name, value) in remaining_params { + if handled_names.contains(name.as_str()) { + continue; + } + if relabeled.insert(name.clone(), value.clone()).is_some() { + return None; + } + display_params.push(RenderedMcpToolApprovalParam { + name: name.clone(), + value: value.clone(), + }); + } + + Some((Some(Value::Object(relabeled)), display_params)) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn renders_exact_match_with_readable_param_labels() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: vec![ + ConsequentialToolTemplateParam { + name: "calendar_id".to_string(), + label: "Calendar".to_string(), + }, + ConsequentialToolTemplateParam { + name: "title".to_string(), + label: "Title".to_string(), + }, + ], + }]; + + let rendered = render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("create_event"), + Some(&json!({ + "title": "Roadmap review", + "calendar_id": "primary", + "timezone": "UTC", + })), + ); + + assert_eq!( + rendered, + Some(RenderedMcpToolApprovalTemplate { + question: "Allow Calendar to create an event?".to_string(), + elicitation_message: "Allow Calendar to create an event?".to_string(), + tool_params: Some(json!({ + "Calendar": "primary", + "Title": "Roadmap review", + "timezone": "UTC", + })), + tool_params_display: vec![ + RenderedMcpToolApprovalParam { + name: "Calendar".to_string(), + value: json!("primary"), + }, + RenderedMcpToolApprovalParam { + name: "Title".to_string(), + value: json!("Roadmap review"), + }, + RenderedMcpToolApprovalParam { + name: "timezone".to_string(), + value: json!("UTC"), + }, + ], + }) + ); + } + + #[test] + fn returns_none_when_no_exact_match_exists() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: Vec::new(), + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("delete_event"), + Some(&json!({})), + ), + None + ); + } + + #[test] + fn returns_none_when_relabeling_would_collide() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: vec![ConsequentialToolTemplateParam { + name: "calendar_id".to_string(), + label: "timezone".to_string(), + }], + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("create_event"), + Some(&json!({ + "calendar_id": "primary", + "timezone": "UTC", + })), + ), + None + ); + } + + #[test] + fn bundled_templates_load() { + assert_eq!(CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.is_some(), true); + } + + #[test] + fn renders_literal_template_without_connector_substitution() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "github".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "add_comment".to_string(), + template: "Allow GitHub to add a comment to a pull request?".to_string(), + template_params: Vec::new(), + }]; + + let rendered = render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("github"), + None, + Some("add_comment"), + Some(&json!({})), + ); + + assert_eq!( + rendered, + Some(RenderedMcpToolApprovalTemplate { + question: "Allow GitHub to add a comment to a pull request?".to_string(), + elicitation_message: "Allow GitHub to add a comment to a pull request?".to_string(), + tool_params: Some(json!({})), + tool_params_display: Vec::new(), + }) + ); + } + + #[test] + fn returns_none_when_connector_placeholder_has_no_value() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: Vec::new(), + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + None, + Some("create_event"), + Some(&json!({})), + ), + None + ); + } +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 811894e92..888d86c5f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -26,6 +26,8 @@ use crate::guardian::guardian_approval_request_to_json; 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::render_mcp_tool_approval_template; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; @@ -385,6 +387,16 @@ struct McpToolApprovalPromptOptions { allow_persistent_approval: bool, } +struct McpToolApprovalElicitationRequest<'a> { + server: &'a str, + metadata: Option<&'a McpToolApprovalMetadata>, + tool_params: Option<&'a serde_json::Value>, + tool_params_display: Option<&'a [RenderedMcpToolApprovalParam]>, + question: RequestUserInputQuestion, + message_override: Option<&'a str>, + prompt_options: McpToolApprovalPromptOptions, +} + 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 +415,7 @@ 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_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct McpToolApprovalKey { @@ -503,14 +516,26 @@ async fn maybe_request_mcp_tool_approval( tool_call_mcp_elicitation_enabled, ); let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); + let rendered_template = render_mcp_tool_approval_template( + &invocation.server, + metadata.and_then(|metadata| metadata.connector_id.as_deref()), + metadata.and_then(|metadata| metadata.connector_name.as_deref()), + metadata.and_then(|metadata| metadata.tool_title.as_deref()), + invocation.arguments.as_ref(), + ); + let tool_params_display = rendered_template + .as_ref() + .map(|rendered_template| rendered_template.tool_params_display.clone()) + .or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref())); let mut question = build_mcp_tool_approval_question( 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, prompt_options, + rendered_template + .as_ref() + .map(|rendered_template| rendered_template.question.as_str()), ); question.question = mcp_tool_approval_question_text(question.question, monitor_reason.as_deref()); @@ -521,11 +546,22 @@ async fn maybe_request_mcp_tool_approval( let params = build_mcp_tool_approval_elicitation_request( sess.as_ref(), turn_context.as_ref(), - &invocation.server, - metadata, - invocation.arguments.as_ref(), - question.clone(), - prompt_options, + McpToolApprovalElicitationRequest { + server: &invocation.server, + metadata, + tool_params: rendered_template + .as_ref() + .and_then(|rendered_template| rendered_template.tool_params.as_ref()) + .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()) + }), + prompt_options, + }, ); let decision = parse_mcp_tool_approval_elicitation_response( sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params) @@ -738,34 +774,16 @@ 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>, prompt_options: McpToolApprovalPromptOptions, + question_override: Option<&str>, ) -> 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")) + let question = question_override + .map(ToString::to_string) .unwrap_or_else(|| { - if server == CODEX_APPS_MCP_SERVER_NAME { - "This app".to_string() - } else { - format!("The {server} MCP server") - } + build_mcp_tool_approval_fallback_message(server, tool_name, connector_name) }); - let question = format!( - "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" - ); + let question = format!("{}?", question.trim_end_matches('?')); let mut options = vec![RequestUserInputQuestionOption { label: MCP_TOOL_APPROVAL_ACCEPT.to_string(), @@ -798,6 +816,25 @@ fn build_mcp_tool_approval_question( } } +fn build_mcp_tool_approval_fallback_message( + server: &str, + tool_name: &str, + connector_name: Option<&str>, +) -> String { + let actor = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| { + if server == CODEX_APPS_MCP_SERVER_NAME { + "this app".to_string() + } else { + format!("the {server} MCP server") + } + }); + format!("Allow {actor} to run tool \"{tool_name}\"?") +} + 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() => { @@ -819,30 +856,24 @@ fn arc_monitor_interrupt_message(reason: &str) -> String { fn build_mcp_tool_approval_elicitation_request( sess: &Session, turn_context: &TurnContext, - server: &str, - metadata: Option<&McpToolApprovalMetadata>, - tool_params: Option<&serde_json::Value>, - question: RequestUserInputQuestion, - prompt_options: McpToolApprovalPromptOptions, + request: McpToolApprovalElicitationRequest<'_>, ) -> McpServerElicitationRequestParams { - let message = if question.header.trim().is_empty() { - question.question - } else { - let header = question.header; - let prompt = question.question; - format!("{header}\n\n{prompt}") - }; + let message = request + .message_override + .map(ToString::to_string) + .unwrap_or_else(|| request.question.question.clone()); McpServerElicitationRequestParams { thread_id: sess.conversation_id.to_string(), turn_id: Some(turn_context.sub_id.clone()), - server_name: server.to_string(), + server_name: request.server.to_string(), request: McpServerElicitationRequest::Form { meta: build_mcp_tool_approval_elicitation_meta( - server, - metadata, - tool_params, - prompt_options, + request.server, + request.metadata, + request.tool_params, + request.tool_params_display, + request.prompt_options, ), message, requested_schema: McpElicitationSchema { @@ -859,6 +890,7 @@ fn build_mcp_tool_approval_elicitation_meta( server: &str, metadata: Option<&McpToolApprovalMetadata>, tool_params: Option<&serde_json::Value>, + tool_params_display: Option<&[RenderedMcpToolApprovalParam]>, prompt_options: McpToolApprovalPromptOptions, ) -> Option { let mut meta = serde_json::Map::new(); @@ -941,9 +973,34 @@ fn build_mcp_tool_approval_elicitation_meta( tool_params.clone(), ); } + if let Some(tool_params_display) = tool_params_display + && let Ok(tool_params_display) = serde_json::to_value(tool_params_display) + { + meta.insert( + MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), + tool_params_display, + ); + } (!meta.is_empty()).then_some(serde_json::Value::Object(meta)) } +fn build_mcp_tool_approval_display_params( + tool_params: Option<&serde_json::Value>, +) -> Option> { + let tool_params = tool_params?.as_object()?; + let mut display_params = tool_params + .iter() + .map( + |(name, value)| crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam { + name: name.clone(), + value: value.clone(), + }, + ) + .collect::>(); + display_params.sort_by(|left, right| left.name.cmp(&right.name)); + Some(display_params) +} + fn parse_mcp_tool_approval_elicitation_response( response: Option, question_id: &str, @@ -1280,22 +1337,92 @@ mod tests { ); } + #[tokio::test] + async fn approval_elicitation_request_uses_message_override_and_readable_tool_params() { + let (session, turn_context) = make_session_and_context().await; + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "create_event", + Some("Calendar"), + prompt_options(true, true), + Some("Allow Calendar to create an event?"), + ); + + let request = build_mcp_tool_approval_elicitation_request( + &session, + &turn_context, + McpToolApprovalElicitationRequest { + server: CODEX_APPS_MCP_SERVER_NAME, + metadata: Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Create Event"), + Some("Create a calendar event."), + )), + tool_params: Some(&serde_json::json!({ + "Calendar": "primary", + "Title": "Roadmap review", + })), + tool_params_display: None, + question, + message_override: Some("Allow Calendar to create an event?"), + prompt_options: prompt_options(true, true), + }, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + MCP_TOOL_APPROVAL_PERSIST_KEY: [ + MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + ], + MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, + MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", + MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", + MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Create Event", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Create a calendar event.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "Calendar": "primary", + "Title": "Roadmap review", + }, + })), + message: "Allow Calendar to create an event?".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } + ); + } + #[test] fn custom_mcp_tool_question_mentions_server_name() { 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)), prompt_options(false, false), + None, ); assert_eq!(question.header, "Approve app tool call?"); 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?" + "Allow the custom_server MCP server to run tool \"run_action\"?" ); assert!( !question @@ -1308,21 +1435,19 @@ mod tests { } #[test] - fn codex_apps_tool_question_keeps_legacy_app_label() { + fn codex_apps_tool_question_uses_fallback_app_label() { 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)), prompt_options(true, true), + None, ); - assert!( - question - .question - .starts_with("This app wants to run the tool \"Run Action\"") + assert_eq!( + question.question, + "Allow this app to run tool \"run_action\"?" ); } @@ -1332,10 +1457,9 @@ mod tests { "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - Some("Run Action"), Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), prompt_options(true, true), + None, ); let options = question.options.expect("options"); @@ -1374,10 +1498,9 @@ mod tests { "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - Some("Run Action"), Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false), + None, ); assert_eq!( @@ -1401,10 +1524,9 @@ mod tests { "q".to_string(), "custom_server", "run_action", - Some("Run Action"), None, - Some(&annotations(Some(false), Some(true), None)), prompt_options(true, false), + None, ); assert_eq!( @@ -1553,6 +1675,7 @@ mod tests { "custom_server", None, None, + None, prompt_options(false, false), ), Some(serde_json::json!({ @@ -1574,6 +1697,7 @@ mod tests { Some("Runs the selected action."), )), Some(&serde_json::json!({"id": 1})), + None, prompt_options(true, false), ), Some(serde_json::json!({ @@ -1732,6 +1856,7 @@ mod tests { Some(&serde_json::json!({ "calendar_id": "primary", })), + None, prompt_options(false, false), ), Some(serde_json::json!({ @@ -1764,6 +1889,7 @@ mod tests { Some(&serde_json::json!({ "calendar_id": "primary", })), + None, prompt_options(true, true), ), Some(serde_json::json!({ diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 4aecbea32..975b8701d 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -40,6 +40,8 @@ use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; use crate::render::renderable::Renderable; +use crate::text_formatting::format_json_compact; +use crate::text_formatting::truncate_text; const ANSWER_PLACEHOLDER: &str = "Type your answer"; const OPTIONAL_ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; @@ -58,6 +60,10 @@ const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; const APPROVAL_PERSIST_KEY: &str = "persist"; const APPROVAL_PERSIST_SESSION_VALUE: &str = "session"; const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always"; +const APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; +const APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; +const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3; +const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60; const TOOL_TYPE_KEY: &str = "tool_type"; const TOOL_ID_KEY: &str = "tool_id"; const TOOL_NAME_KEY: &str = "tool_name"; @@ -146,12 +152,19 @@ pub(crate) struct ToolSuggestionRequest { pub(crate) install_url: String, } +#[derive(Clone, Debug, PartialEq)] +struct McpToolApprovalDisplayParam { + name: String, + value: Value, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, server_name: String, request_id: McpRequestId, message: String, + approval_display_params: Vec, response_mode: McpServerElicitationResponseMode, fields: Vec, tool_suggestion: Option, @@ -216,6 +229,11 @@ impl McpServerElicitationFormRequest { }); let is_tool_approval_action = is_tool_approval && (requested_schema.is_null() || is_empty_object_schema); + let approval_display_params = if is_tool_approval_action { + parse_tool_approval_display_params(meta.as_ref()) + } else { + Vec::new() + }; let (response_mode, fields) = if tool_suggestion.is_some() && (requested_schema.is_null() || is_empty_object_schema) @@ -297,6 +315,7 @@ impl McpServerElicitationFormRequest { server_name: request.server_name, request_id: request.id, message, + approval_display_params, response_mode, fields, tool_suggestion, @@ -376,6 +395,99 @@ fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str } } +fn parse_tool_approval_display_params(meta: Option<&Value>) -> Vec { + let Some(meta) = meta.and_then(Value::as_object) else { + return Vec::new(); + }; + + let display_params = meta + .get(APPROVAL_TOOL_PARAMS_DISPLAY_KEY) + .and_then(Value::as_array) + .map(|display_params| { + display_params + .iter() + .filter_map(parse_tool_approval_display_param) + .collect::>() + }) + .unwrap_or_default(); + if !display_params.is_empty() { + return display_params; + } + + let mut fallback_params = meta + .get(APPROVAL_TOOL_PARAMS_KEY) + .and_then(Value::as_object) + .map(|tool_params| { + tool_params + .iter() + .map(|(name, value)| McpToolApprovalDisplayParam { + name: name.clone(), + value: value.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + fallback_params.sort_by(|left, right| left.name.cmp(&right.name)); + fallback_params +} + +fn parse_tool_approval_display_param(value: &Value) -> Option { + let value = value.as_object()?; + let name = value.get("name")?.as_str()?.trim(); + if name.is_empty() { + return None; + } + Some(McpToolApprovalDisplayParam { + name: name.to_string(), + value: value.get("value")?.clone(), + }) +} + +fn format_tool_approval_display_message( + message: &str, + approval_display_params: &[McpToolApprovalDisplayParam], +) -> String { + let message = message.trim(); + if approval_display_params.is_empty() { + return message.to_string(); + } + + let mut sections = Vec::new(); + if !message.is_empty() { + sections.push(message.to_string()); + } + let param_lines = approval_display_params + .iter() + .take(APPROVAL_TOOL_PARAM_DISPLAY_LIMIT) + .map(format_tool_approval_display_param_line) + .collect::>(); + if !param_lines.is_empty() { + sections.push(param_lines.join("\n")); + } + let mut message = sections.join("\n\n"); + message.push('\n'); + message +} + +fn format_tool_approval_display_param_line(param: &McpToolApprovalDisplayParam) -> String { + format!( + "{}: {}", + param.name, + format_tool_approval_display_param_value(¶m.value) + ) +} + +fn format_tool_approval_display_param_value(value: &Value) -> String { + let formatted = match value { + Value::String(text) => text.split_whitespace().collect::>().join(" "), + _ => { + let compact_json = value.to_string(); + format_json_compact(&compact_json).unwrap_or(compact_json) + } + }; + truncate_text(&formatted, APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES) +} + fn parse_fields_from_schema(requested_schema: &Value) -> Option> { let schema = requested_schema.as_object()?; if schema.get("type").and_then(Value::as_str) != Some("object") { @@ -779,12 +891,16 @@ impl McpServerElicitationOverlay { } fn current_prompt_text(&self) -> String { + let request_message = format_tool_approval_display_message( + &self.request.message, + &self.request.approval_display_params, + ); let Some(field) = self.current_field() else { - return self.request.message.clone(); + return request_message; }; let mut sections = Vec::new(); - if !self.request.message.trim().is_empty() { - sections.push(self.request.message.trim().to_string()); + if !request_message.trim().is_empty() { + sections.push(request_message); } let field_prompt = if field.label.trim().is_empty() || field.prompt.trim().is_empty() @@ -1549,7 +1665,11 @@ mod tests { }) } - fn tool_approval_meta(persist_modes: &[&str]) -> Option { + fn tool_approval_meta( + persist_modes: &[&str], + tool_params: Option, + tool_params_display: Option>, + ) -> Option { let mut meta = serde_json::Map::from_iter([( APPROVAL_META_KIND_KEY.to_string(), Value::String(APPROVAL_META_KIND_MCP_TOOL_CALL.to_string()), @@ -1565,6 +1685,25 @@ mod tests { ), ); } + if let Some(tool_params) = tool_params { + meta.insert(APPROVAL_TOOL_PARAMS_KEY.to_string(), tool_params); + } + if let Some(tool_params_display) = tool_params_display { + meta.insert( + APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), + Value::Array( + tool_params_display + .into_iter() + .map(|(name, value)| { + serde_json::json!({ + "name": name, + "value": value, + }) + }) + .collect(), + ), + ); + } Some(Value::Object(meta)) } @@ -1616,6 +1755,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::FormContent, fields: vec![McpServerElicitationField { id: "confirmed".to_string(), @@ -1681,6 +1821,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), @@ -1723,7 +1864,7 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[]), + tool_approval_meta(&[], None, None), ), ) .expect("expected approval fallback"); @@ -1735,6 +1876,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), @@ -1805,6 +1947,43 @@ mod tests { assert_eq!(request, None); } + #[test] + fn tool_approval_display_params_prefer_explicit_display_order() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "zeta": 3, + "alpha": 1, + })), + Some(vec![ + ("Calendar", Value::String("primary".to_string())), + ("Title", Value::String("Roadmap review".to_string())), + ]), + ), + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request.approval_display_params, + vec![ + McpToolApprovalDisplayParam { + name: "Calendar".to_string(), + value: Value::String("primary".to_string()), + }, + McpToolApprovalDisplayParam { + name: "Title".to_string(), + value: Value::String("Roadmap review".to_string()), + }, + ] + ); + } + #[test] fn submit_sends_accept_with_typed_content() { let (tx, mut rx) = test_sender(); @@ -1865,10 +2044,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -1912,10 +2095,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -2105,7 +2292,7 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[]), + tool_approval_meta(&[], None, None), ), ) .expect("expected approval fallback"); @@ -2125,10 +2312,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -2139,4 +2330,41 @@ mod tests { render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } + + #[test] + fn approval_form_tool_approval_with_param_summary_snapshot() { + let (tx, _rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "calendar_id": "primary", + "title": "Roadmap review", + "notes": "This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.", + "ignored_after_limit": "fourth param", + })), + Some(vec![ + ("Calendar", Value::String("primary".to_string())), + ("Title", Value::String("Roadmap review".to_string())), + ( + "Notes", + Value::String("This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.".to_string()), + ), + ("Ignored", Value::String("fourth param".to_string())), + ]), + ), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_with_param_summary", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap new file mode 100644 index 000000000..0ac8f529a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Allow Calendar to create an event + + Calendar: primary + Title: Roadmap review + Notes: This is a deliberately long note that should truncate bef... + + › 1. Allow Run the tool and continue. + 2. Cancel Cancel this tool call + + + + + enter to submit | esc to cancel