From 98dca99db7f72353f0c89eb9f9d573ae8e7ce9fd Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 6 Mar 2026 01:50:26 -0800 Subject: [PATCH] [elicitations] Switch to use MCP style elicitation payload for mcp tool approvals. (#13621) - [x] Switch to use MCP style elicitation payload for mcp tool approvals. - [ ] TODO: Update the UI to support the full spec. --- .../schema/json/EventMsg.json | 16 + .../McpServerElicitationRequestParams.json | 539 ++++- .../McpServerElicitationRequestResponse.json | 3 + .../schema/json/ServerRequest.json | 537 ++++- .../codex_app_server_protocol.schemas.json | 549 ++++- .../codex_app_server_protocol.v2.schemas.json | 9 + .../schema/typescript/ElicitationRequest.ts | 2 +- .../typescript/ElicitationRequestEvent.ts | 6 +- .../typescript/v2/McpElicitationArrayType.ts | 5 + .../v2/McpElicitationBooleanSchema.ts | 6 + .../v2/McpElicitationBooleanType.ts | 5 + .../v2/McpElicitationConstOption.ts | 5 + .../typescript/v2/McpElicitationEnumSchema.ts | 8 + .../McpElicitationLegacyTitledEnumSchema.ts | 6 + .../v2/McpElicitationMultiSelectEnumSchema.ts | 7 + .../v2/McpElicitationNumberSchema.ts | 6 + .../typescript/v2/McpElicitationNumberType.ts | 5 + .../typescript/v2/McpElicitationObjectType.ts | 5 + .../v2/McpElicitationPrimitiveSchema.ts | 9 + .../typescript/v2/McpElicitationSchema.ts | 13 + .../McpElicitationSingleSelectEnumSchema.ts | 7 + .../v2/McpElicitationStringFormat.ts | 5 + .../v2/McpElicitationStringSchema.ts | 7 + .../typescript/v2/McpElicitationStringType.ts | 5 + .../v2/McpElicitationTitledEnumItems.ts | 6 + ...pElicitationTitledMultiSelectEnumSchema.ts | 7 + ...ElicitationTitledSingleSelectEnumSchema.ts | 7 + .../v2/McpElicitationUntitledEnumItems.ts | 6 + ...licitationUntitledMultiSelectEnumSchema.ts | 7 + ...icitationUntitledSingleSelectEnumSchema.ts | 6 + .../v2/McpServerElicitationRequestParams.ts | 3 +- .../v2/McpServerElicitationRequestResponse.ts | 6 +- .../schema/typescript/v2/index.ts | 22 + .../src/protocol/common.rs | 21 +- .../app-server-protocol/src/protocol/v2.rs | 526 ++++- .../app-server/src/bespoke_event_handling.rs | 42 +- .../tests/suite/v2/mcp_server_elicitation.rs | 7 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 108 +- codex-rs/core/src/features.rs | 8 + codex-rs/core/src/mcp_connection_manager.rs | 14 +- codex-rs/core/src/mcp_tool_call.rs | 472 +++- codex-rs/core/src/state/turn.rs | 23 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/approvals.rs | 10 + codex-rs/protocol/src/protocol.rs | 3 + .../rmcp-client/src/logging_client_handler.rs | 1 + codex-rs/rmcp-client/src/rmcp_client.rs | 32 +- codex-rs/rmcp-client/tests/resources.rs | 1 + codex-rs/tui/src/app.rs | 96 +- .../tui/src/app/pending_interactive_replay.rs | 3 + .../tui/src/bottom_pane/approval_overlay.rs | 1 + .../tui/src/bottom_pane/bottom_pane_view.rs | 10 + .../src/bottom_pane/mcp_server_elicitation.rs | 1920 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 34 + ...on_approval_form_with_session_persist.snap | 19 + ...citation_approval_form_without_schema.snap | 19 + ...__mcp_server_elicitation_boolean_form.snap | 19 + codex-rs/tui/src/chatwidget.rs | 34 +- 59 files changed, 5165 insertions(+), 100 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts create mode 100644 codex-rs/tui/src/bottom_pane/mcp_server_elicitation.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_session_persist.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 7a2f49ab0..6636278e1 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -548,6 +548,7 @@ "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -568,6 +569,7 @@ }, { "properties": { + "_meta": true, "elicitation_id": { "type": "string" }, @@ -2025,6 +2027,13 @@ "server_name": { "type": "string" }, + "turn_id": { + "description": "Turn ID that this elicitation belongs to, when known.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "elicitation_request" @@ -7830,6 +7839,13 @@ "server_name": { "type": "string" }, + "turn_id": { + "description": "Turn ID that this elicitation belongs to, when known.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "elicitation_request" diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json index 52a2c33ff..aa7fa817a 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -1,8 +1,542 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + }, "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -12,7 +546,9 @@ ], "type": "string" }, - "requestedSchema": true + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } }, "required": [ "message", @@ -23,6 +559,7 @@ }, { "properties": { + "_meta": true, "elicitationId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json index 8c14f9094..13390a06c 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json @@ -11,6 +11,9 @@ } }, "properties": { + "_meta": { + "description": "Optional client metadata for form-mode action handling." + }, "action": { "$ref": "#/definitions/McpServerElicitationAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index ef7f673dc..ae0c4ee50 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -650,10 +650,542 @@ ], "type": "string" }, + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, "McpServerElicitationRequestParams": { "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -663,7 +1195,9 @@ ], "type": "string" }, - "requestedSchema": true + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } }, "required": [ "message", @@ -674,6 +1208,7 @@ }, { "properties": { + "_meta": true, "elicitationId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 33d3c0f63..0bebb007c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1788,6 +1788,7 @@ "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -1808,6 +1809,7 @@ }, { "properties": { + "_meta": true, "elicitation_id": { "type": "string" }, @@ -3266,6 +3268,13 @@ "server_name": { "type": "string" }, + "turn_id": { + "description": "Turn ID that this elicitation belongs to, when known.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "elicitation_request" @@ -5309,6 +5318,537 @@ }, "type": "object" }, + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, "McpInvocation": { "properties": { "arguments": { @@ -5342,6 +5882,7 @@ "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -5351,7 +5892,9 @@ ], "type": "string" }, - "requestedSchema": true + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } }, "required": [ "message", @@ -5362,6 +5905,7 @@ }, { "properties": { + "_meta": true, "elicitationId": { "type": "string" }, @@ -5412,6 +5956,9 @@ "McpServerElicitationRequestResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "_meta": { + "description": "Optional client metadata for form-mode action handling." + }, "action": { "$ref": "#/definitions/McpServerElicitationAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 516a54fb8..da67d650c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3349,6 +3349,7 @@ "oneOf": [ { "properties": { + "_meta": true, "message": { "type": "string" }, @@ -3369,6 +3370,7 @@ }, { "properties": { + "_meta": true, "elicitation_id": { "type": "string" }, @@ -4852,6 +4854,13 @@ "server_name": { "type": "string" }, + "turn_id": { + "description": "Turn ID that this elicitation belongs to, when known.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "elicitation_request" diff --git a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts index 7ecf4468e..7f8de7851 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "./serde_json/JsonValue"; -export type ElicitationRequest = { "mode": "form", message: string, requested_schema: JsonValue, } | { "mode": "url", message: string, url: string, elicitation_id: string, }; +export type ElicitationRequest = { "mode": "form", _meta?: JsonValue, message: string, requested_schema: JsonValue, } | { "mode": "url", _meta?: JsonValue, message: string, url: string, elicitation_id: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts index 8739e8935..0603291d7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ElicitationRequest } from "./ElicitationRequest"; -export type ElicitationRequestEvent = { server_name: string, id: string | number, request: ElicitationRequest, }; +export type ElicitationRequestEvent = { +/** + * Turn ID that this elicitation belongs to, when known. + */ +turn_id?: string, server_name: string, id: string | number, request: ElicitationRequest, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts new file mode 100644 index 000000000..066b44ea5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationArrayType = "array"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts new file mode 100644 index 000000000..ae0f4a497 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationBooleanType } from "./McpElicitationBooleanType"; + +export type McpElicitationBooleanSchema = { type: McpElicitationBooleanType, title?: string, description?: string, default?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts new file mode 100644 index 000000000..f2b9ed48d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationBooleanType = "boolean"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts new file mode 100644 index 000000000..2031655d8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationConstOption = { const: string, title: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts new file mode 100644 index 000000000..e9155db4a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationLegacyTitledEnumSchema } from "./McpElicitationLegacyTitledEnumSchema"; +import type { McpElicitationMultiSelectEnumSchema } from "./McpElicitationMultiSelectEnumSchema"; +import type { McpElicitationSingleSelectEnumSchema } from "./McpElicitationSingleSelectEnumSchema"; + +export type McpElicitationEnumSchema = McpElicitationSingleSelectEnumSchema | McpElicitationMultiSelectEnumSchema | McpElicitationLegacyTitledEnumSchema; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts new file mode 100644 index 000000000..8dcec3174 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationLegacyTitledEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, enum: Array, enumNames?: Array, default?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts new file mode 100644 index 000000000..48eb25e17 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationTitledMultiSelectEnumSchema } from "./McpElicitationTitledMultiSelectEnumSchema"; +import type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; + +export type McpElicitationMultiSelectEnumSchema = McpElicitationUntitledMultiSelectEnumSchema | McpElicitationTitledMultiSelectEnumSchema; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts new file mode 100644 index 000000000..6628db921 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationNumberType } from "./McpElicitationNumberType"; + +export type McpElicitationNumberSchema = { type: McpElicitationNumberType, title?: string, description?: string, minimum?: number, maximum?: number, default?: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts new file mode 100644 index 000000000..96a9ded76 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationNumberType = "number" | "integer"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts new file mode 100644 index 000000000..2449a0c1e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationObjectType = "object"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts new file mode 100644 index 000000000..2828ae589 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; +import type { McpElicitationEnumSchema } from "./McpElicitationEnumSchema"; +import type { McpElicitationNumberSchema } from "./McpElicitationNumberSchema"; +import type { McpElicitationStringSchema } from "./McpElicitationStringSchema"; + +export type McpElicitationPrimitiveSchema = McpElicitationEnumSchema | McpElicitationStringSchema | McpElicitationNumberSchema | McpElicitationBooleanSchema; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts new file mode 100644 index 000000000..1afa53334 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationObjectType } from "./McpElicitationObjectType"; +import type { McpElicitationPrimitiveSchema } from "./McpElicitationPrimitiveSchema"; + +/** + * Typed form schema for MCP `elicitation/create` requests. + * + * This matches the `requestedSchema` shape from the MCP 2025-11-25 + * `ElicitRequestFormParams` schema. + */ +export type McpElicitationSchema = { $schema?: string, type: McpElicitationObjectType, properties: { [key in string]?: McpElicitationPrimitiveSchema }, required?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts new file mode 100644 index 000000000..2ba7dadb1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitationTitledSingleSelectEnumSchema"; +import type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; + +export type McpElicitationSingleSelectEnumSchema = McpElicitationUntitledSingleSelectEnumSchema | McpElicitationTitledSingleSelectEnumSchema; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts new file mode 100644 index 000000000..9891d4c7c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationStringFormat = "email" | "uri" | "date" | "date-time"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts new file mode 100644 index 000000000..c2ca1eb83 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringFormat } from "./McpElicitationStringFormat"; +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationStringSchema = { type: McpElicitationStringType, title?: string, description?: string, minLength?: number, maxLength?: number, format?: McpElicitationStringFormat, default?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts new file mode 100644 index 000000000..bf2ddfab9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationStringType = "string"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts new file mode 100644 index 000000000..44ff2ef27 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationConstOption } from "./McpElicitationConstOption"; + +export type McpElicitationTitledEnumItems = { anyOf: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts new file mode 100644 index 000000000..75274d34e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationArrayType } from "./McpElicitationArrayType"; +import type { McpElicitationTitledEnumItems } from "./McpElicitationTitledEnumItems"; + +export type McpElicitationTitledMultiSelectEnumSchema = { type: McpElicitationArrayType, title?: string, description?: string, minItems?: bigint, maxItems?: bigint, items: McpElicitationTitledEnumItems, default?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts new file mode 100644 index 000000000..47b731914 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationConstOption } from "./McpElicitationConstOption"; +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationTitledSingleSelectEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, oneOf: Array, default?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts new file mode 100644 index 000000000..f790881fb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationUntitledEnumItems = { type: McpElicitationStringType, enum: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts new file mode 100644 index 000000000..5acf9fee0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationArrayType } from "./McpElicitationArrayType"; +import type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; + +export type McpElicitationUntitledMultiSelectEnumSchema = { type: McpElicitationArrayType, title?: string, description?: string, minItems?: bigint, maxItems?: bigint, items: McpElicitationUntitledEnumItems, default?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts new file mode 100644 index 000000000..49be545d5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationUntitledSingleSelectEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, enum: Array, default?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts index 5fa767afd..13855adc3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; +import type { McpElicitationSchema } from "./McpElicitationSchema"; export type McpServerElicitationRequestParams = { threadId: string, /** @@ -12,4 +13,4 @@ export type McpServerElicitationRequestParams = { threadId: string, * context is app-server correlation rather than part of the protocol identity of the * elicitation itself. */ -turnId: string | null, serverName: string, } & ({ "mode": "form", message: string, requestedSchema: JsonValue, } | { "mode": "url", message: string, url: string, elicitationId: string, }); +turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts index 34e161e4f..054ef23f4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts @@ -10,4 +10,8 @@ export type McpServerElicitationRequestResponse = { action: McpServerElicitation * * This is nullable because decline/cancel responses have no content. */ -content: JsonValue | null, }; +content: JsonValue | null, +/** + * Optional client metadata for form-mode action handling. + */ +_meta: JsonValue | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 5ded88865..50e489017 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -98,6 +98,28 @@ export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { McpAuthStatus } from "./McpAuthStatus"; +export type { McpElicitationArrayType } from "./McpElicitationArrayType"; +export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; +export type { McpElicitationBooleanType } from "./McpElicitationBooleanType"; +export type { McpElicitationConstOption } from "./McpElicitationConstOption"; +export type { McpElicitationEnumSchema } from "./McpElicitationEnumSchema"; +export type { McpElicitationLegacyTitledEnumSchema } from "./McpElicitationLegacyTitledEnumSchema"; +export type { McpElicitationMultiSelectEnumSchema } from "./McpElicitationMultiSelectEnumSchema"; +export type { McpElicitationNumberSchema } from "./McpElicitationNumberSchema"; +export type { McpElicitationNumberType } from "./McpElicitationNumberType"; +export type { McpElicitationObjectType } from "./McpElicitationObjectType"; +export type { McpElicitationPrimitiveSchema } from "./McpElicitationPrimitiveSchema"; +export type { McpElicitationSchema } from "./McpElicitationSchema"; +export type { McpElicitationSingleSelectEnumSchema } from "./McpElicitationSingleSelectEnumSchema"; +export type { McpElicitationStringFormat } from "./McpElicitationStringFormat"; +export type { McpElicitationStringSchema } from "./McpElicitationStringSchema"; +export type { McpElicitationStringType } from "./McpElicitationStringType"; +export type { McpElicitationTitledEnumItems } from "./McpElicitationTitledEnumItems"; +export type { McpElicitationTitledMultiSelectEnumSchema } from "./McpElicitationTitledMultiSelectEnumSchema"; +export type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitationTitledSingleSelectEnumSchema"; +export type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; +export type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; +export type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; export type { McpServerElicitationAction } from "./McpServerElicitationAction"; export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index da1feb496..463084ae9 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1058,21 +1058,23 @@ mod tests { #[test] fn serialize_mcp_server_elicitation_request() -> Result<()> { + let requested_schema: v2::McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": ["confirmed"] + }))?; let params = v2::McpServerElicitationRequestParams { thread_id: "thr_123".to_string(), turn_id: Some("turn_123".to_string()), server_name: "codex_apps".to_string(), request: v2::McpServerElicitationRequest::Form { + meta: None, message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean" - } - }, - "required": ["confirmed"] - }), + requested_schema, }, }; let request = ServerRequest::McpServerElicitationRequest { @@ -1089,6 +1091,7 @@ mod tests { "turnId": "turn_123", "serverName": "codex_apps", "mode": "form", + "_meta": null, "message": "Allow this request?", "requestedSchema": { "type": "object", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dd1fb5f21..c65c41d1a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4210,6 +4210,323 @@ pub struct McpServerElicitationRequestParams { // association. } +/// Typed form schema for MCP `elicitation/create` requests. +/// +/// This matches the `requestedSchema` shape from the MCP 2025-11-25 +/// `ElicitRequestFormParams` schema. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationSchema { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "$schema")] + pub schema_uri: Option, + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationObjectType, + pub properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub required: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationObjectType { + Object, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationPrimitiveSchema { + Enum(McpElicitationEnumSchema), + String(McpElicitationStringSchema), + Number(McpElicitationNumberSchema), + Boolean(McpElicitationBooleanSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationStringSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationStringType { + String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum McpElicitationStringFormat { + Email, + Uri, + Date, + DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationNumberSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationNumberType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationNumberType { + Number, + Integer, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationBooleanSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationBooleanType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationBooleanType { + Boolean, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationEnumSchema { + SingleSelect(McpElicitationSingleSelectEnumSchema), + MultiSelect(McpElicitationMultiSelectEnumSchema), + Legacy(McpElicitationLegacyTitledEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationLegacyTitledEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "enumNames")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationSingleSelectEnumSchema { + Untitled(McpElicitationUntitledSingleSelectEnumSchema), + Titled(McpElicitationTitledSingleSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "oneOf")] + #[ts(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationMultiSelectEnumSchema { + Untitled(McpElicitationUntitledMultiSelectEnumSchema), + Titled(McpElicitationTitledMultiSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationUntitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationTitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationArrayType { + Array, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledEnumItems { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledEnumItems { + #[serde(rename = "anyOf", alias = "oneOf")] + #[ts(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationConstOption { + #[serde(rename = "const")] + #[ts(rename = "const")] + pub const_: String, + pub title: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "mode", rename_all = "camelCase")] #[ts(tag = "mode")] @@ -4218,37 +4535,49 @@ pub enum McpServerElicitationRequest { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Form { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, message: String, - requested_schema: JsonValue, + requested_schema: McpElicitationSchema, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Url { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, message: String, url: String, elicitation_id: String, }, } -impl From for McpServerElicitationRequest { - fn from(value: CoreElicitationRequest) -> Self { +impl TryFrom for McpServerElicitationRequest { + type Error = serde_json::Error; + + fn try_from(value: CoreElicitationRequest) -> Result { match value { CoreElicitationRequest::Form { + meta, message, requested_schema, - } => Self::Form { + } => Ok(Self::Form { + meta, message, - requested_schema, - }, + requested_schema: serde_json::from_value(requested_schema)?, + }), CoreElicitationRequest::Url { + meta, message, url, elicitation_id, - } => Self::Url { + } => Ok(Self::Url { + meta, message, url, elicitation_id, - }, + }), } } } @@ -4262,6 +4591,10 @@ pub struct McpServerElicitationRequestResponse { /// /// This is nullable because decline/cancel responses have no content. pub content: Option, + /// Optional client metadata for form-mode action handling. + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, } impl From for rmcp::model::CreateElicitationResult { @@ -4278,6 +4611,7 @@ impl From for McpServerElicitationRequestR Self { action: value.action.into(), content: value.content, + meta: None, } } } @@ -4675,6 +5009,7 @@ mod tests { content: Some(json!({ "confirmed": true, })), + meta: None, } ); assert_eq!( @@ -4685,15 +5020,18 @@ mod tests { #[test] fn mcp_server_elicitation_request_from_core_url_request() { - let request = McpServerElicitationRequest::from(CoreElicitationRequest::Url { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { + meta: None, message: "Finish sign-in".to_string(), url: "https://example.com/complete".to_string(), elicitation_id: "elicitation-123".to_string(), - }); + }) + .expect("URL request should convert"); assert_eq!( request, McpServerElicitationRequest::Url { + meta: None, message: "Finish sign-in".to_string(), url: "https://example.com/complete".to_string(), elicitation_id: "elicitation-123".to_string(), @@ -4701,11 +5039,178 @@ mod tests { ); } + #[test] + fn mcp_server_elicitation_request_from_core_form_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + }), + }) + .expect("form request should convert"); + + let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + })) + .expect("expected schema should deserialize"); + + assert_eq!( + request, + McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: expected_schema, + } + ); + } + + #[test] + fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { + let schema: McpElicitationSchema = serde_json::from_value(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "title": "Email", + "description": "Work email address", + "format": "email", + "default": "dev@example.com", + }, + "count": { + "type": "integer", + "title": "Count", + "description": "How many items to create", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action", + "default": true, + }, + "legacyChoice": { + "type": "string", + "title": "Action", + "description": "Legacy titled enum form", + "enum": ["allow", "deny"], + "enumNames": ["Allow", "Deny"], + "default": "allow", + }, + }, + "required": ["email", "confirmed"], + })) + .expect("schema should deserialize"); + + assert_eq!( + schema, + McpElicitationSchema { + schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), + type_: McpElicitationObjectType::Object, + properties: BTreeMap::from([ + ( + "confirmed".to_string(), + McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { + type_: McpElicitationBooleanType::Boolean, + title: Some("Confirm".to_string()), + description: Some("Approve the pending action".to_string()), + default: Some(true), + }), + ), + ( + "count".to_string(), + McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: McpElicitationNumberType::Integer, + title: Some("Count".to_string()), + description: Some("How many items to create".to_string()), + minimum: Some(1.0), + maximum: Some(5.0), + default: Some(3.0), + }), + ), + ( + "email".to_string(), + McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { + type_: McpElicitationStringType::String, + title: Some("Email".to_string()), + description: Some("Work email address".to_string()), + min_length: None, + max_length: None, + format: Some(McpElicitationStringFormat::Email), + default: Some("dev@example.com".to_string()), + }), + ), + ( + "legacyChoice".to_string(), + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( + McpElicitationLegacyTitledEnumSchema { + type_: McpElicitationStringType::String, + title: Some("Action".to_string()), + description: Some("Legacy titled enum form".to_string()), + enum_: vec!["allow".to_string(), "deny".to_string()], + enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), + default: Some("allow".to_string()), + }, + )), + ), + ]), + required: Some(vec!["email".to_string(), "confirmed".to_string()]), + } + ); + } + + #[test] + fn mcp_server_elicitation_request_rejects_null_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: Some(json!({ + "persist": "session", + })), + message: "Allow this request?".to_string(), + requested_schema: JsonValue::Null, + }); + + assert!(result.is_err()); + } + + #[test] + fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "object", + } + }, + }), + }); + + assert!(result.is_err()); + } + #[test] fn mcp_server_elicitation_response_serializes_nullable_content() { let response = McpServerElicitationRequestResponse { action: McpServerElicitationAction::Decline, content: None, + meta: None, }; assert_eq!( @@ -4713,6 +5218,7 @@ mod tests { json!({ "action": "decline", "content": null, + "_meta": null, }) ); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d714098d6..33ae38c73 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -617,15 +617,43 @@ pub(crate) async fn apply_bespoke_event_handling( let permission_guard = thread_watch_manager .note_permission_requested(&conversation_id.to_string()) .await; - let turn_id = { - let state = thread_state.lock().await; - state.active_turn_snapshot().map(|turn| turn.id) + let turn_id = match request.turn_id.clone() { + Some(turn_id) => Some(turn_id), + None => { + let state = thread_state.lock().await; + state.active_turn_snapshot().map(|turn| turn.id) + } + }; + let server_name = request.server_name.clone(); + let request_body = match request.request.try_into() { + Ok(request_body) => request_body, + Err(err) => { + error!( + error = %err, + server_name, + request_id = ?request.id, + "failed to parse typed MCP elicitation schema" + ); + if let Err(err) = conversation + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: codex_protocol::approvals::ElicitationAction::Cancel, + content: None, + meta: None, + }) + .await + { + error!("failed to submit ResolveElicitation: {err}"); + } + return; + } }; let params = McpServerElicitationRequestParams { thread_id: conversation_id.to_string(), turn_id, server_name: request.server_name.clone(), - request: request.request.into(), + request: request_body, }; let (pending_request_id, rx) = outgoing .send_request(ServerRequestPayload::McpServerElicitationRequest(params)) @@ -2044,6 +2072,7 @@ async fn on_mcp_server_elicitation_response( request_id, decision: response.action.to_core(), content: response.content, + meta: response.meta, }) .await { @@ -2061,12 +2090,14 @@ fn mcp_server_elicitation_response_from_client_result( McpServerElicitationRequestResponse { action: McpServerElicitationAction::Decline, content: None, + meta: None, } }), Ok(Err(err)) if is_turn_transition_server_request_error(&err) => { McpServerElicitationRequestResponse { action: McpServerElicitationAction::Cancel, content: None, + meta: None, } } Ok(Err(err)) => { @@ -2074,6 +2105,7 @@ fn mcp_server_elicitation_response_from_client_result( McpServerElicitationRequestResponse { action: McpServerElicitationAction::Decline, content: None, + meta: None, } } Err(err) => { @@ -2081,6 +2113,7 @@ fn mcp_server_elicitation_response_from_client_result( McpServerElicitationRequestResponse { action: McpServerElicitationAction::Decline, content: None, + meta: None, } } } @@ -2491,6 +2524,7 @@ mod tests { McpServerElicitationRequestResponse { action: McpServerElicitationAction::Cancel, content: None, + meta: None, } ); } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 924b30e24..a784b3490 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -16,6 +16,7 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,12 +187,12 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); }; - let requested_schema = serde_json::to_value( + let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( ElicitationSchema::builder() .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) .build() .map_err(anyhow::Error::msg)?, - )?; + )?)?; assert_eq!( params, @@ -200,6 +201,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { turn_id: Some(turn.id.clone()), server_name: "codex_apps".to_string(), request: McpServerElicitationRequest::Form { + meta: None, message: ELICITATION_MESSAGE.to_string(), requested_schema, }, @@ -214,6 +216,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { content: Some(json!({ "confirmed": true, })), + meta: None, })?, ) .await?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6e5ed97b5..1f9446953 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -443,6 +443,9 @@ "steer": { "type": "boolean" }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, "undo": { "type": "boolean" }, @@ -1828,6 +1831,9 @@ "steer": { "type": "boolean" }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, "undo": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4e969003c..d1d0ae8d7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use async_channel::Receiver; use async_channel::Sender; use chrono::Local; use chrono::Utc; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -68,6 +70,7 @@ use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; @@ -2815,6 +2818,85 @@ impl Session { rx_response.await.ok() } + pub async fn request_mcp_server_elicitation( + &self, + turn_context: &TurnContext, + request_id: RequestId, + params: McpServerElicitationRequestParams, + ) -> Option { + let server_name = params.server_name.clone(); + let request = match params.request { + McpServerElicitationRequest::Form { + meta, + message, + requested_schema, + } => { + let requested_schema = match serde_json::to_value(requested_schema) { + Ok(requested_schema) => requested_schema, + Err(err) => { + warn!( + "failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}" + ); + return None; + } + }; + codex_protocol::approvals::ElicitationRequest::Form { + meta, + message, + requested_schema, + } + } + McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => codex_protocol::approvals::ElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + }, + }; + + let (tx_response, rx_response) = oneshot::channel(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_elicitation( + server_name.clone(), + request_id.clone(), + tx_response, + ) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!( + "Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}" + ); + } + let id = match request_id { + rmcp::model::NumberOrString::String(value) => { + codex_protocol::mcp::RequestId::String(value.to_string()) + } + rmcp::model::NumberOrString::Number(value) => { + codex_protocol::mcp::RequestId::Integer(value) + } + }; + let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: params.turn_id, + server_name, + id, + request, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + pub async fn notify_user_input_response( &self, sub_id: &str, @@ -2888,6 +2970,23 @@ impl Session { id: RequestId, response: ElicitationResponse, ) -> anyhow::Result<()> { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_elicitation(&server_name, &id) + } + None => None, + } + }; + if let Some(tx_response) = entry { + tx_response + .send(response) + .map_err(|e| anyhow::anyhow!("failed to send elicitation response: {e:?}"))?; + return Ok(()); + } + self.services .mcp_connection_manager .read() @@ -3883,6 +3982,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv request_id, decision, content, + meta, } => { handlers::resolve_elicitation( &sess, @@ -3890,6 +3990,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv request_id, decision, content, + meta, ) .await; false @@ -4117,6 +4218,7 @@ mod handlers { request_id: ProtocolRequestId, decision: codex_protocol::approvals::ElicitationAction, content: Option, + meta: Option, ) { let action = match decision { codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept, @@ -4128,7 +4230,11 @@ mod handlers { ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))), ElicitationAction::Decline | ElicitationAction::Cancel => None, }; - let response = ElicitationResponse { action, content }; + let response = ElicitationResponse { + action, + content, + meta, + }; let request_id = match request_id { ProtocolRequestId::String(value) => { rmcp::model::NumberOrString::String(std::sync::Arc::from(value)) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8aa5e181d..a400c3053 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -147,6 +147,8 @@ pub enum Feature { /// Enable collaboration modes (Plan, Default). /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. CollaborationModes, + /// Route MCP tool approval prompts through the MCP elicitation request path. + ToolCallMcpElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -693,6 +695,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: true, }, + FeatureSpec { + id: Feature::ToolCallMcpElicitation, + key: "tool_call_mcp_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index a30a7c7bb..e339d8722 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -295,25 +295,34 @@ impl ElicitationRequestManager { return Ok(ElicitationResponse { action: ElicitationAction::Decline, content: None, + meta: None, }); } let request = match elicitation { CreateElicitationRequestParams::FormElicitationParams { + meta, message, requested_schema, - .. } => ElicitationRequest::Form { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, message, requested_schema: serde_json::to_value(requested_schema) .context("failed to serialize MCP elicitation schema")?, }, CreateElicitationRequestParams::UrlElicitationParams { + meta, message, url, elicitation_id, - .. } => ElicitationRequest::Url { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, message, url, elicitation_id, @@ -328,6 +337,7 @@ impl ElicitationRequestManager { .send(Event { id: "mcp_elicitation_request".to_string(), msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: None, server_name, id: match id.clone() { rmcp::model::NumberOrString::String(value) => { diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f76eddec6..3900ea417 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,6 +1,11 @@ +use std::collections::BTreeMap; use std::time::Duration; use std::time::Instant; +use codex_app_server_protocol::McpElicitationObjectType; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use tracing::error; use crate::analytics_client::AppInvocation; @@ -10,6 +15,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::types::AppToolApproval; use crate::connectors; +use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -24,10 +30,13 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationAction; +use codex_rmcp_client::ElicitationResponse; use rmcp::model::ToolAnnotations; use serde::Serialize; use std::sync::Arc; @@ -68,7 +77,7 @@ pub(crate) async fn handle_mcp_tool_call( arguments: arguments_value.clone(), }; - let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await; + let metadata = lookup_mcp_tool_metadata(sess.as_ref(), turn_context, &server, &tool_name).await; let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { connectors::app_tool_policy( &turn_context.config, @@ -107,8 +116,7 @@ pub(crate) async fn handle_mcp_tool_call( sess.as_ref(), turn_context, &call_id, - &server, - &tool_name, + &invocation, metadata.as_ref(), app_tool_policy.approval, ) @@ -334,7 +342,9 @@ struct McpToolApprovalMetadata { annotations: Option, connector_id: Option, connector_name: Option, + connector_description: Option, tool_title: Option, + tool_description: Option, } const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval"; @@ -342,6 +352,18 @@ const MCP_TOOL_APPROVAL_ACCEPT: &str = "Approve Once"; const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Approve this Session"; const MCP_TOOL_APPROVAL_DECLINE: &str = "Deny"; const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel"; +const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind"; +const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +const MCP_TOOL_APPROVAL_PERSIST_KEY: &str = "persist"; +const MCP_TOOL_APPROVAL_PERSIST_SESSION: &str = "session"; +const MCP_TOOL_APPROVAL_SOURCE_KEY: &str = "source"; +const MCP_TOOL_APPROVAL_SOURCE_CONNECTOR: &str = "connector"; +const MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: &str = "connector_id"; +const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: &str = "connector_name"; +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"; #[derive(Debug, Serialize)] struct McpToolApprovalKey { @@ -354,8 +376,7 @@ async fn maybe_request_mcp_tool_approval( sess: &Session, turn_context: &TurnContext, call_id: &str, - server: &str, - tool_name: &str, + invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { @@ -374,13 +395,13 @@ async fn maybe_request_mcp_tool_approval( let approval_key = if approval_mode == AppToolApproval::Auto { let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone()); - if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() { + if invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() { None } else { Some(McpToolApprovalKey { - server: server.to_string(), + server: invocation.server.clone(), connector_id, - tool_name: tool_name.to_string(), + tool_name: invocation.tool.clone(), }) } } else { @@ -395,13 +416,44 @@ async fn maybe_request_mcp_tool_approval( let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); let question = build_mcp_tool_approval_question( question_id.clone(), - server, - tool_name, + &invocation.server, + &invocation.tool, metadata.and_then(|metadata| metadata.tool_title.as_deref()), metadata.and_then(|metadata| metadata.connector_name.as_deref()), annotations, approval_key.is_some(), ); + if turn_context + .config + .features + .enabled(Feature::ToolCallMcpElicitation) + { + let request_id = rmcp::model::RequestId::String( + format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(), + ); + let params = build_mcp_tool_approval_elicitation_request( + sess, + turn_context, + &invocation.server, + metadata, + invocation.arguments.as_ref(), + question.clone(), + approval_key.is_some(), + ); + let decision = parse_mcp_tool_approval_elicitation_response( + sess.request_mcp_server_elicitation(turn_context, request_id, params) + .await, + &question_id, + ); + let decision = normalize_approval_decision_for_mode(decision, approval_mode); + if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) + && let Some(key) = approval_key + { + remember_mcp_tool_approval(sess, key).await; + } + return Some(decision); + } + let args = RequestUserInputArgs { questions: vec![question], }; @@ -430,6 +482,7 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool { async fn lookup_mcp_tool_metadata( sess: &Session, + turn_context: &TurnContext, server: &str, tool_name: &str, ) -> Option { @@ -441,17 +494,40 @@ async fn lookup_mcp_tool_metadata( .list_all_tools() .await; - tools.into_values().find_map(|tool_info| { - if tool_info.server_name == server && tool_info.tool_name == tool_name { - Some(McpToolApprovalMetadata { - annotations: tool_info.tool.annotations, - connector_id: tool_info.connector_id, - connector_name: tool_info.connector_name, - tool_title: tool_info.tool.title, - }) - } else { - None - } + let tool_info = tools + .into_values() + .find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?; + let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME { + let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools( + turn_context.config.as_ref(), + ) + .await + { + Some(connectors) => Some(connectors), + None => { + connectors::list_accessible_connectors_from_mcp_tools(turn_context.config.as_ref()) + .await + .ok() + } + }; + connectors.and_then(|connectors| { + let connector_id = tool_info.connector_id.as_deref()?; + connectors + .into_iter() + .find(|connector| connector.id == connector_id) + .and_then(|connector| connector.description) + }) + } else { + None + }; + + Some(McpToolApprovalMetadata { + annotations: tool_info.tool.annotations, + connector_id: tool_info.connector_id, + connector_name: tool_info.connector_name, + connector_description, + tool_title: tool_info.tool.title, + tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned), }) } @@ -544,6 +620,173 @@ fn build_mcp_tool_approval_question( } } +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, + allow_session_persist: bool, +) -> 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}") + }; + + McpServerElicitationRequestParams { + thread_id: sess.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: server.to_string(), + request: McpServerElicitationRequest::Form { + meta: build_mcp_tool_approval_elicitation_meta( + server, + metadata, + tool_params, + allow_session_persist, + ), + message, + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } +} + +fn build_mcp_tool_approval_elicitation_meta( + server: &str, + metadata: Option<&McpToolApprovalMetadata>, + tool_params: Option<&serde_json::Value>, + allow_session_persist: bool, +) -> Option { + let mut meta = serde_json::Map::new(); + meta.insert( + MCP_TOOL_APPROVAL_KIND_KEY.to_string(), + serde_json::Value::String(MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL.to_string()), + ); + if allow_session_persist { + meta.insert( + MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(), + serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()), + ); + } + if let Some(metadata) = metadata { + if let Some(tool_title) = metadata.tool_title.as_ref() { + meta.insert( + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY.to_string(), + serde_json::Value::String(tool_title.clone()), + ); + } + if let Some(tool_description) = metadata.tool_description.as_ref() { + meta.insert( + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY.to_string(), + serde_json::Value::String(tool_description.clone()), + ); + } + if server == CODEX_APPS_MCP_SERVER_NAME + && (metadata.connector_id.is_some() + || metadata.connector_name.is_some() + || metadata.connector_description.is_some()) + { + meta.insert( + MCP_TOOL_APPROVAL_SOURCE_KEY.to_string(), + serde_json::Value::String(MCP_TOOL_APPROVAL_SOURCE_CONNECTOR.to_string()), + ); + if let Some(connector_id) = metadata.connector_id.as_deref() { + meta.insert( + MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY.to_string(), + serde_json::Value::String(connector_id.to_string()), + ); + } + if let Some(connector_name) = metadata.connector_name.as_ref() { + meta.insert( + MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY.to_string(), + serde_json::Value::String(connector_name.clone()), + ); + } + if let Some(connector_description) = metadata.connector_description.as_ref() { + meta.insert( + MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY.to_string(), + serde_json::Value::String(connector_description.clone()), + ); + } + } + } + if let Some(tool_params) = tool_params { + meta.insert( + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY.to_string(), + tool_params.clone(), + ); + } + (!meta.is_empty()).then_some(serde_json::Value::Object(meta)) +} + +fn parse_mcp_tool_approval_elicitation_response( + response: Option, + question_id: &str, +) -> McpToolApprovalDecision { + let Some(response) = response else { + return McpToolApprovalDecision::Cancel; + }; + match response.action { + ElicitationAction::Accept => { + if response + .meta + .as_ref() + .and_then(serde_json::Value::as_object) + .and_then(|meta| meta.get(MCP_TOOL_APPROVAL_PERSIST_KEY)) + .and_then(serde_json::Value::as_str) + == Some(MCP_TOOL_APPROVAL_PERSIST_SESSION) + { + return McpToolApprovalDecision::AcceptAndRemember; + } + + match parse_mcp_tool_approval_response( + request_user_input_response_from_elicitation_content(response.content), + question_id, + ) { + McpToolApprovalDecision::Cancel => McpToolApprovalDecision::Accept, + decision => decision, + } + } + ElicitationAction::Decline => McpToolApprovalDecision::Decline, + ElicitationAction::Cancel => McpToolApprovalDecision::Cancel, + } +} + +fn request_user_input_response_from_elicitation_content( + content: Option, +) -> Option { + let Some(content) = content else { + return Some(RequestUserInputResponse { + answers: std::collections::HashMap::new(), + }); + }; + let content = content.as_object()?; + let answers = content + .iter() + .filter_map(|(question_id, value)| { + let answers = match value { + serde_json::Value::String(answer) => vec![answer.clone()], + serde_json::Value::Array(values) => values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect(), + _ => return None, + }; + Some((question_id.clone(), RequestUserInputAnswer { answers })) + }) + .collect(); + + Some(RequestUserInputResponse { answers }) +} + fn parse_mcp_tool_approval_response( response: Option, question_id: &str, @@ -651,6 +894,23 @@ mod tests { } } + fn approval_metadata( + connector_id: Option<&str>, + connector_name: Option<&str>, + connector_description: Option<&str>, + tool_title: Option<&str>, + tool_description: Option<&str>, + ) -> McpToolApprovalMetadata { + McpToolApprovalMetadata { + annotations: None, + connector_id: connector_id.map(str::to_string), + connector_name: connector_name.map(str::to_string), + connector_description: connector_description.map(str::to_string), + tool_title: tool_title.map(str::to_string), + tool_description: tool_description.map(str::to_string), + } + } + #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); @@ -780,4 +1040,174 @@ mod tests { assert_eq!(got, original); } + + #[test] + fn accepted_elicitation_content_converts_to_request_user_input_response() { + let response = + request_user_input_response_from_elicitation_content(Some(serde_json::json!( + { + "approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER, + } + ))); + + assert_eq!( + response, + Some(RequestUserInputResponse { + answers: std::collections::HashMap::from([( + "approval".to_string(), + RequestUserInputAnswer { + answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()], + }, + )]), + }) + ); + } + + #[test] + fn approval_elicitation_meta_marks_tool_approvals() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta("custom_server", None, None, false), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + })) + ); + } + + #[test] + fn approval_elicitation_meta_keeps_session_persist_behavior() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + "custom_server", + Some(&approval_metadata( + None, + None, + None, + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({"id": 1})), + true, + ), + 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_TOOL_TITLE_KEY: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "id": 1, + }, + })) + ); + } + + #[test] + fn approval_elicitation_meta_includes_connector_source_for_codex_apps() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + CODEX_APPS_MCP_SERVER_NAME, + Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({ + "calendar_id": "primary", + })), + false, + ), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + 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: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "calendar_id": "primary", + }, + })) + ); + } + + #[test] + fn approval_elicitation_meta_merges_session_persist_with_connector_source() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + CODEX_APPS_MCP_SERVER_NAME, + Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({ + "calendar_id": "primary", + })), + true, + ), + 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_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: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "calendar_id": "primary", + }, + })) + ); + } + + #[test] + fn declined_elicitation_response_stays_decline() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Decline, + content: Some(serde_json::json!({ + "approval": MCP_TOOL_APPROVAL_ACCEPT, + })), + meta: None, + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::Decline); + } + + #[test] + fn accepted_elicitation_response_uses_session_persist_meta() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, + })), + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember); + } + + #[test] + fn accepted_elicitation_without_content_defaults_to_accept() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::Accept); + } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 28ae8fec6..4bbcf21c9 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -11,6 +11,8 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationResponse; +use rmcp::model::RequestId; use tokio::sync::oneshot; use crate::codex::TurnContext; @@ -72,6 +74,7 @@ impl ActiveTurn { pub(crate) struct TurnState { pending_approvals: HashMap>, pending_user_input: HashMap>, + pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, pending_input: Vec, pub(crate) tool_calls: u64, @@ -97,6 +100,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); self.pending_user_input.clear(); + self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); self.pending_input.clear(); } @@ -116,6 +120,25 @@ impl TurnState { self.pending_user_input.remove(key) } + pub(crate) fn insert_pending_elicitation( + &mut self, + server_name: String, + request_id: RequestId, + tx: oneshot::Sender, + ) -> Option> { + self.pending_elicitations + .insert((server_name, request_id), tx) + } + + pub(crate) fn remove_pending_elicitation( + &mut self, + server_name: &str, + request_id: &RequestId, + ) -> Option> { + self.pending_elicitations + .remove(&(server_name.to_string(), request_id.clone())) + } + pub(crate) fn insert_pending_dynamic_tool( &mut self, key: String, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 3d2faa02b..55978021f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -692,6 +692,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { request_id: ev.id.clone(), decision: ElicitationAction::Cancel, content: None, + meta: None, }) .await?; } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index bd1b01d93..680462b72 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -196,10 +196,16 @@ impl ExecApprovalRequestEvent { #[ts(tag = "mode")] pub enum ElicitationRequest { Form { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "_meta")] + meta: Option, message: String, requested_schema: JsonValue, }, Url { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "_meta")] + meta: Option, message: String, url: String, elicitation_id: String, @@ -216,6 +222,10 @@ impl ElicitationRequest { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ElicitationRequestEvent { + /// Turn ID that this elicitation belongs to, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub turn_id: Option, pub server_name: String, #[ts(type = "string | number")] pub id: RequestId, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e7b9bf090..c5f94f974 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -334,6 +334,9 @@ pub enum Op { /// Structured user input supplied for accepted elicitations. #[serde(default, skip_serializing_if = "Option::is_none")] content: Option, + /// Optional client metadata associated with the elicitation response. + #[serde(default, skip_serializing_if = "Option::is_none")] + meta: Option, }, /// Resolve a request_user_input tool call. diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs index 45a1031c7..0c3da0fe2 100644 --- a/codex-rs/rmcp-client/src/logging_client_handler.rs +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -42,6 +42,7 @@ impl ClientHandler for LoggingClientHandler { ) -> Result { (self.send_elicitation)(context.id, request) .await + .map(Into::into) .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None)) } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index e1d704596..6dbe46dfd 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -21,6 +21,7 @@ use rmcp::model::CreateElicitationRequestParams; use rmcp::model::CreateElicitationResult; use rmcp::model::CustomNotification; use rmcp::model::CustomRequest; +use rmcp::model::ElicitationAction; use rmcp::model::Extensions; use rmcp::model::InitializeRequestParams; use rmcp::model::InitializeResult; @@ -42,6 +43,8 @@ use rmcp::transport::auth::AuthError; use rmcp::transport::auth::OAuthState; use rmcp::transport::child_process::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use serde::Deserialize; +use serde::Serialize; use serde_json::Value; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; @@ -147,7 +150,34 @@ impl Drop for ProcessGroupGuard { } pub type Elicitation = CreateElicitationRequestParams; -pub type ElicitationResponse = CreateElicitationResult; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ElicitationResponse { + pub action: ElicitationAction, + pub content: Option, + #[serde(rename = "_meta")] + pub meta: Option, +} + +impl From for ElicitationResponse { + fn from(value: CreateElicitationResult) -> Self { + Self { + action: value.action, + content: value.content, + meta: None, + } + } +} + +impl From for CreateElicitationResult { + fn from(value: ElicitationResponse) -> Self { + Self { + action: value.action, + content: value.content, + } + } +} /// Interface for sending elicitation requests to the UI and awaiting a response. pub type SendElicitation = Box< diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs index 9e357bfcb..ba1a8e431 100644 --- a/codex-rs/rmcp-client/tests/resources.rs +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -73,6 +73,7 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { Ok(ElicitationResponse { action: ElicitationAction::Accept, content: Some(json!({})), + meta: None, }) } .boxed() diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 206ebbb7b..32f5d5963 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,6 +7,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; +use crate::bottom_pane::McpServerElicitationFormRequest; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; @@ -116,6 +117,11 @@ use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; + +enum ThreadInteractiveRequest { + Approval(ApprovalRequest), + McpServerElicitation(McpServerElicitationFormRequest), +} /// Baseline cadence for periodic stream commit animation ticks. /// /// Smooth-mode streaming drains one line per tick, so this interval controls @@ -1010,41 +1016,55 @@ impl App { } } - async fn approval_request_for_thread_event( + async fn interactive_request_for_thread_event( &self, thread_id: ThreadId, event: &Event, - ) -> Option { + ) -> Option { let thread_label = Some(self.thread_label(thread_id)); match &event.msg { - EventMsg::ExecApprovalRequest(ev) => Some(ApprovalRequest::Exec { - thread_id, - thread_label, - id: ev.effective_approval_id(), - command: ev.command.clone(), - reason: ev.reason.clone(), - available_decisions: ev.effective_available_decisions(), - network_approval_context: ev.network_approval_context.clone(), - additional_permissions: ev.additional_permissions.clone(), - }), - EventMsg::ApplyPatchApprovalRequest(ev) => Some(ApprovalRequest::ApplyPatch { - thread_id, - thread_label, - id: ev.call_id.clone(), - reason: ev.reason.clone(), - cwd: self - .thread_cwd(thread_id) - .await - .unwrap_or_else(|| self.config.cwd.clone()), - changes: ev.changes.clone(), - }), - EventMsg::ElicitationRequest(ev) => Some(ApprovalRequest::McpElicitation { - thread_id, - thread_label, - server_name: ev.server_name.clone(), - request_id: ev.id.clone(), - message: ev.request.message().to_string(), - }), + EventMsg::ExecApprovalRequest(ev) => { + Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { + thread_id, + thread_label, + id: ev.effective_approval_id(), + command: ev.command.clone(), + reason: ev.reason.clone(), + available_decisions: ev.effective_available_decisions(), + network_approval_context: ev.network_approval_context.clone(), + additional_permissions: ev.additional_permissions.clone(), + })) + } + EventMsg::ApplyPatchApprovalRequest(ev) => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::ApplyPatch { + thread_id, + thread_label, + id: ev.call_id.clone(), + reason: ev.reason.clone(), + cwd: self + .thread_cwd(thread_id) + .await + .unwrap_or_else(|| self.config.cwd.clone()), + changes: ev.changes.clone(), + }, + )), + EventMsg::ElicitationRequest(ev) => { + if let Some(request) = + McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) + { + Some(ThreadInteractiveRequest::McpServerElicitation(request)) + } else { + Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::McpElicitation { + thread_id, + thread_label, + server_name: ev.server_name.clone(), + request_id: ev.id.clone(), + message: ev.request.message().to_string(), + }, + )) + } + } _ => None, } } @@ -1112,8 +1132,8 @@ impl App { async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { let refresh_pending_thread_approvals = ThreadEventStore::event_can_change_pending_thread_approvals(&event); - let inactive_approval_request = if self.active_thread_id != Some(thread_id) { - self.approval_request_for_thread_event(thread_id, &event) + let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { + self.interactive_request_for_thread_event(thread_id, &event) .await } else { None @@ -1146,8 +1166,16 @@ impl App { tracing::warn!("thread {thread_id} event channel closed"); } } - } else if let Some(request) = inactive_approval_request { - self.chat_widget.push_approval_request(request); + } else if let Some(request) = inactive_interactive_request { + match request { + ThreadInteractiveRequest::Approval(request) => { + self.chat_widget.push_approval_request(request); + } + ThreadInteractiveRequest::McpServerElicitation(request) => { + self.chat_widget + .push_mcp_server_elicitation_request(request); + } + } } if refresh_pending_thread_approvals { self.refresh_pending_thread_approvals().await; diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 6ab071244..8ed6d2653 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -586,9 +586,11 @@ mod tests { store.push_event(Event { id: "ev-1".to_string(), msg: EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), server_name: "server-1".to_string(), id: request_id.clone(), request: codex_protocol::approvals::ElicitationRequest::Form { + meta: None, message: "Please confirm".to_string(), requested_schema: serde_json::json!({ "type": "object", @@ -603,6 +605,7 @@ mod tests { request_id, decision: codex_protocol::approvals::ElicitationAction::Accept, content: None, + meta: None, }); let snapshot = store.snapshot(); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index dd056b0c7..55fc2ed03 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -283,6 +283,7 @@ impl ApprovalOverlay { request_id: request_id.clone(), decision, content: None, + meta: None, }, }); } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 9c40adc69..a8561fe3d 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,4 +1,5 @@ use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::McpServerElicitationFormRequest; use crate::render::renderable::Renderable; use codex_protocol::request_user_input::RequestUserInputEvent; use crossterm::event::KeyEvent; @@ -71,4 +72,13 @@ pub(crate) trait BottomPaneView: Renderable { ) -> Option { Some(request) } + + /// Try to handle a supported MCP server elicitation form request; return the original value if + /// not consumed. + fn try_consume_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) -> Option { + Some(request) + } } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs new file mode 100644 index 000000000..891f79b38 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -0,0 +1,1920 @@ +use std::collections::HashSet; +use std::collections::VecDeque; +use std::path::PathBuf; + +use codex_app_server_protocol::McpElicitationEnumSchema; +use codex_app_server_protocol::McpElicitationPrimitiveSchema; +use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema; +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use serde_json::Value; +use unicode_width::UnicodeWidthStr; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::measure_rows_height; +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +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; + +const ANSWER_PLACEHOLDER: &str = "Type your answer"; +const OPTIONAL_ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +const FOOTER_SEPARATOR: &str = " | "; +const MIN_COMPOSER_HEIGHT: u16 = 3; +const MIN_OVERLAY_HEIGHT: u16 = 8; +const APPROVAL_FIELD_ID: &str = "__approval"; +const APPROVAL_ACCEPT_ONCE_VALUE: &str = "accept"; +const APPROVAL_ACCEPT_SESSION_VALUE: &str = "accept_session"; +const APPROVAL_DECLINE_VALUE: &str = "decline"; +const APPROVAL_CANCEL_VALUE: &str = "cancel"; +const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind"; +const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +const APPROVAL_PERSIST_KEY: &str = "persist"; +const APPROVAL_PERSIST_SESSION_VALUE: &str = "session"; + +#[derive(Clone, PartialEq, Default)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, +} + +impl ComposerDraft { + fn text_with_pending(&self) -> String { + if self.pending_pastes.is_empty() { + return self.text.clone(); + } + debug_assert!( + !self.text_elements.is_empty(), + "pending pastes should always have matching text elements" + ); + let (expanded, _) = ChatComposer::expand_pending_pastes( + &self.text, + self.text_elements.clone(), + &self.pending_pastes, + ); + expanded + } +} + +#[derive(Clone, Debug, PartialEq)] +struct McpServerElicitationOption { + label: String, + description: Option, + value: Value, +} + +#[derive(Clone, Debug, PartialEq)] +enum McpServerElicitationFieldInput { + Select { + options: Vec, + default_idx: Option, + }, + Text { + secret: bool, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct McpServerElicitationField { + id: String, + label: String, + prompt: String, + required: bool, + input: McpServerElicitationFieldInput, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum McpServerElicitationResponseMode { + FormContent, + ApprovalAction, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct McpServerElicitationFormRequest { + thread_id: ThreadId, + server_name: String, + request_id: McpRequestId, + message: String, + response_mode: McpServerElicitationResponseMode, + fields: Vec, +} + +#[derive(Default)] +struct McpServerElicitationAnswerState { + selection: ScrollState, + draft: ComposerDraft, + answer_committed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FooterTip { + text: String, + highlight: bool, +} + +impl FooterTip { + fn new(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: false, + } + } + + fn highlighted(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: true, + } + } +} + +impl McpServerElicitationFormRequest { + pub(crate) fn from_event( + thread_id: ThreadId, + request: ElicitationRequestEvent, + ) -> Option { + let ElicitationRequest::Form { + meta, + message, + requested_schema, + } = request.request + else { + return None; + }; + + let is_tool_approval = meta + .as_ref() + .and_then(Value::as_object) + .and_then(|meta| meta.get(APPROVAL_META_KIND_KEY)) + .and_then(Value::as_str) + == Some(APPROVAL_META_KIND_MCP_TOOL_CALL); + let is_empty_object_schema = requested_schema.as_object().is_some_and(|schema| { + schema.get("type").and_then(Value::as_str) == Some("object") + && schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + }); + + let (response_mode, fields) = + if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) { + let mut options = vec![McpServerElicitationOption { + label: "Approve Once".to_string(), + description: Some("Run the tool and continue.".to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }]; + if meta + .as_ref() + .and_then(Value::as_object) + .and_then(|meta| meta.get(APPROVAL_PERSIST_KEY)) + .and_then(Value::as_str) + == Some(APPROVAL_PERSIST_SESSION_VALUE) + { + options.push(McpServerElicitationOption { + label: "Approve this Session".to_string(), + description: Some( + "Run the tool and remember this choice for this session.".to_string(), + ), + value: Value::String(APPROVAL_ACCEPT_SESSION_VALUE.to_string()), + }); + } + options.extend([ + McpServerElicitationOption { + label: "Deny".to_string(), + description: Some("Decline this tool call and continue.".to_string()), + value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this tool call".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ]); + ( + McpServerElicitationResponseMode::ApprovalAction, + vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options, + default_idx: Some(0), + }, + }], + ) + } else { + ( + McpServerElicitationResponseMode::FormContent, + parse_fields_from_schema(&requested_schema)?, + ) + }; + + Some(Self { + thread_id, + server_name: request.server_name, + request_id: request.id, + message, + response_mode, + fields, + }) + } +} + +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") { + return None; + } + let required = schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>(); + let properties = schema.get("properties")?.as_object()?; + let mut fields = Vec::new(); + for (id, property_schema) in properties { + let property = + serde_json::from_value::(property_schema.clone()) + .ok()?; + fields.push(parse_field(id, property, required.contains(id))?); + } + if fields.is_empty() { + return None; + } + Some(fields) +} + +fn parse_field( + id: &str, + property: McpElicitationPrimitiveSchema, + required: bool, +) -> Option { + match property { + McpElicitationPrimitiveSchema::String(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Text { secret: false }, + }) + } + McpElicitationPrimitiveSchema::Boolean(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema.default.map(|value| if value { 0 } else { 1 }); + let options = [true, false] + .into_iter() + .map(|value| { + let label = if value { "True" } else { "False" }.to_string(); + McpServerElicitationOption { + label, + description: None, + value: Value::Bool(value), + } + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy(schema)) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema + .default + .as_ref() + .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); + let enum_names = schema.enum_names.unwrap_or_default(); + let options = schema + .enum_ + .into_iter() + .enumerate() + .map(|(idx, value)| McpServerElicitationOption { + label: enum_names + .get(idx) + .cloned() + .unwrap_or_else(|| value.clone()), + description: None, + value: Value::String(value), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::SingleSelect(schema)) => { + parse_single_select_field(id, schema, required) + } + McpElicitationPrimitiveSchema::Number(_) + | McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::MultiSelect(_)) => None, + } +} + +fn parse_single_select_field( + id: &str, + schema: McpElicitationSingleSelectEnumSchema, + required: bool, +) -> Option { + match schema { + McpElicitationSingleSelectEnumSchema::Untitled(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema + .default + .as_ref() + .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); + let options = schema + .enum_ + .into_iter() + .map(|value| McpServerElicitationOption { + label: value.clone(), + description: None, + value: Value::String(value), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationSingleSelectEnumSchema::Titled(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema.default.as_ref().and_then(|value| { + schema + .one_of + .iter() + .position(|entry| entry.const_.as_str() == value) + }); + let options = schema + .one_of + .into_iter() + .map(|entry| McpServerElicitationOption { + label: entry.title, + description: None, + value: Value::String(entry.const_), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + } +} + +pub(crate) struct McpServerElicitationOverlay { + app_event_tx: AppEventSender, + request: McpServerElicitationFormRequest, + queue: VecDeque, + composer: ChatComposer, + answers: Vec, + current_idx: usize, + done: bool, + validation_error: Option, +} + +impl McpServerElicitationOverlay { + pub(crate) fn new( + request: McpServerElicitationFormRequest, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + ) -> Self { + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + ANSWER_PLACEHOLDER.to_string(), + disable_paste_burst, + ChatComposerConfig::plain_text(), + ); + composer.set_footer_hint_override(Some(Vec::new())); + let mut overlay = Self { + app_event_tx, + request, + queue: VecDeque::new(), + composer, + answers: Vec::new(), + current_idx: 0, + done: false, + validation_error: None, + }; + overlay.reset_for_request(); + overlay.restore_current_draft(); + overlay + } + + fn reset_for_request(&mut self) { + self.answers = self + .request + .fields + .iter() + .map(|field| { + let mut selection = ScrollState::new(); + let (draft, answer_committed) = match &field.input { + McpServerElicitationFieldInput::Select { default_idx, .. } => { + selection.selected_idx = default_idx.or(Some(0)); + (ComposerDraft::default(), default_idx.is_some()) + } + McpServerElicitationFieldInput::Text { .. } => { + (ComposerDraft::default(), false) + } + }; + McpServerElicitationAnswerState { + selection, + draft, + answer_committed, + } + }) + .collect(); + self.current_idx = 0; + self.validation_error = None; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + } + + fn field_count(&self) -> usize { + self.request.fields.len() + } + + fn current_index(&self) -> usize { + self.current_idx + } + + fn current_field(&self) -> Option<&McpServerElicitationField> { + self.request.fields.get(self.current_index()) + } + + fn current_answer(&self) -> Option<&McpServerElicitationAnswerState> { + self.answers.get(self.current_index()) + } + + fn current_answer_mut(&mut self) -> Option<&mut McpServerElicitationAnswerState> { + let idx = self.current_idx; + self.answers.get_mut(idx) + } + + fn capture_composer_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.composer.current_text(), + text_elements: self.composer.text_elements(), + local_image_paths: self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect(), + pending_pastes: self.composer.pending_pastes(), + } + } + + fn restore_current_draft(&mut self) { + self.composer + .set_placeholder_text(self.answer_placeholder().to_string()); + self.composer.set_footer_hint_override(Some(Vec::new())); + if self.current_field_is_select() { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + } + let Some(answer) = self.current_answer() else { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + }; + let draft = answer.draft.clone(); + self.composer + .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); + self.composer.set_pending_pastes(draft.pending_pastes); + self.composer.move_cursor_to_end(); + } + + fn save_current_draft(&mut self) { + if self.current_field_is_select() { + return; + } + let draft = self.capture_composer_draft(); + if let Some(answer) = self.current_answer_mut() { + if answer.answer_committed && answer.draft != draft { + answer.answer_committed = false; + } + answer.draft = draft; + } + } + + fn clear_current_draft(&mut self) { + if self.current_field_is_select() { + return; + } + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft::default(); + answer.answer_committed = false; + } + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + } + + fn answer_placeholder(&self) -> &'static str { + self.current_field().map_or(ANSWER_PLACEHOLDER, |field| { + if field.required { + ANSWER_PLACEHOLDER + } else { + OPTIONAL_ANSWER_PLACEHOLDER + } + }) + } + + fn current_field_is_select(&self) -> bool { + matches!( + self.current_field().map(|field| &field.input), + Some(McpServerElicitationFieldInput::Select { .. }) + ) + } + + fn current_field_is_secret(&self) -> bool { + matches!( + self.current_field().map(|field| &field.input), + Some(McpServerElicitationFieldInput::Text { secret: true }) + ) + } + + fn selected_option_index(&self) -> Option { + self.current_answer() + .and_then(|answer| answer.selection.selected_idx) + } + + fn options_len(&self) -> usize { + self.current_options().len() + } + + fn current_options(&self) -> &[McpServerElicitationOption] { + match self.current_field().map(|field| &field.input) { + Some(McpServerElicitationFieldInput::Select { options, .. }) => options.as_slice(), + _ => &[], + } + } + + fn option_rows(&self) -> Vec { + let selected_idx = self.selected_option_index(); + self.current_options() + .iter() + .enumerate() + .map(|(idx, option)| { + let prefix = if selected_idx.is_some_and(|selected| selected == idx) { + '›' + } else { + ' ' + }; + let number = idx + 1; + let prefix_label = format!("{prefix} {number}. "); + let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); + GenericDisplayRow { + name: format!("{prefix_label}{}", option.label), + description: option.description.clone(), + wrap_indent: Some(wrap_indent), + ..Default::default() + } + }) + .collect() + } + + fn wrapped_prompt_lines(&self, width: u16) -> Vec { + textwrap::wrap(&self.current_prompt_text(), width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect() + } + + fn current_prompt_text(&self) -> String { + let Some(field) = self.current_field() else { + return self.request.message.clone(); + }; + let mut sections = Vec::new(); + if !self.request.message.trim().is_empty() { + sections.push(self.request.message.trim().to_string()); + } + let field_prompt = if field.label.trim().is_empty() + || field.prompt.trim().is_empty() + || field.label == field.prompt + { + if field.prompt.trim().is_empty() { + field.label.clone() + } else { + field.prompt.clone() + } + } else { + format!("{}\n{}", field.label, field.prompt) + }; + if !field_prompt.trim().is_empty() { + sections.push(field_prompt); + } + sections.join("\n\n") + } + + fn footer_tips(&self) -> Vec { + let mut tips = Vec::new(); + let is_last_field = self.current_index().saturating_add(1) >= self.field_count(); + if self.current_field_is_select() { + if self.field_count() == 1 { + tips.push(FooterTip::highlighted("enter to submit")); + } else if is_last_field { + tips.push(FooterTip::highlighted("enter to submit all")); + } else { + tips.push(FooterTip::new("enter to submit answer")); + } + } else if self.field_count() == 1 { + tips.push(FooterTip::highlighted("enter to submit")); + } else if is_last_field { + tips.push(FooterTip::highlighted("enter to submit all")); + } else { + tips.push(FooterTip::new("enter to submit answer")); + } + if self.field_count() > 1 { + if self.current_field_is_select() { + tips.push(FooterTip::new("←/→ to navigate fields")); + } else { + tips.push(FooterTip::new("ctrl + p / ctrl + n change field")); + } + } + tips.push(FooterTip::new("esc to cancel")); + tips + } + + fn footer_tip_lines(&self, width: u16) -> Vec> { + let mut tips = Vec::new(); + if let Some(error) = self.validation_error.as_ref() { + tips.push(FooterTip::highlighted(error.clone())); + } + tips.extend(self.footer_tips()); + wrap_footer_tips(width, tips) + } + + fn options_required_height(&self, width: u16) -> u16 { + let rows = self.option_rows(); + if rows.is_empty() { + return 0; + } + let mut state = self + .current_answer() + .map(|answer| answer.selection) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + measure_rows_height(&rows, &state, rows.len(), width.max(1)) + } + + fn input_height(&self, width: u16) -> u16 { + if self.current_field_is_select() { + return self.options_required_height(width); + } + self.composer + .desired_height(width.max(1)) + .clamp(MIN_COMPOSER_HEIGHT, MIN_COMPOSER_HEIGHT.saturating_add(5)) + } + + fn move_field(&mut self, next: bool) { + let len = self.field_count(); + if len == 0 { + return; + } + self.save_current_draft(); + let offset = if next { 1 } else { len.saturating_sub(1) }; + self.current_idx = (self.current_idx + offset) % len; + self.validation_error = None; + self.restore_current_draft(); + } + + fn jump_to_field(&mut self, idx: usize) { + if idx >= self.field_count() { + return; + } + self.save_current_draft(); + self.current_idx = idx; + self.restore_current_draft(); + } + + fn field_value(&self, idx: usize) -> Option { + let field = self.request.fields.get(idx)?; + let answer = self.answers.get(idx)?; + match &field.input { + McpServerElicitationFieldInput::Select { options, .. } => { + if !answer.answer_committed { + return None; + } + let selected_idx = answer.selection.selected_idx?; + options.get(selected_idx).map(|option| option.value.clone()) + } + McpServerElicitationFieldInput::Text { .. } => { + if !answer.answer_committed { + return None; + } + let text = answer.draft.text_with_pending(); + let text = text.trim(); + (!text.is_empty()).then(|| Value::String(text.to_string())) + } + } + } + + fn required_unanswered_count(&self) -> usize { + self.request + .fields + .iter() + .enumerate() + .filter(|(idx, field)| field.required && self.field_value(*idx).is_none()) + .count() + } + + fn first_required_unanswered_index(&self) -> Option { + self.request + .fields + .iter() + .enumerate() + .find(|(idx, field)| field.required && self.field_value(*idx).is_none()) + .map(|(idx, _)| idx) + } + + fn is_current_field_answered(&self) -> bool { + self.field_value(self.current_index()).is_some() + } + + fn option_index_for_digit(&self, ch: char) -> Option { + let digit = ch.to_digit(10)?; + if digit == 0 { + return None; + } + let idx = (digit - 1) as usize; + (idx < self.options_len()).then_some(idx) + } + + fn select_current_option(&mut self, committed: bool) { + let options_len = self.options_len(); + if let Some(answer) = self.current_answer_mut() { + answer.selection.clamp_selection(options_len); + answer.answer_committed = committed; + } + } + + fn clear_selection(&mut self) { + if let Some(answer) = self.current_answer_mut() { + answer.selection.reset(); + answer.answer_committed = false; + } + } + + fn dispatch_cancel(&self) { + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.request.thread_id, + op: Op::ResolveElicitation { + server_name: self.request.server_name.clone(), + request_id: self.request.request_id.clone(), + decision: ElicitationAction::Cancel, + content: None, + meta: None, + }, + }); + } + + fn submit_answers(&mut self) { + self.save_current_draft(); + if let Some(idx) = self.first_required_unanswered_index() { + self.validation_error = Some("Answer required fields before submitting.".to_string()); + self.jump_to_field(idx); + return; + } + self.validation_error = None; + if self.request.response_mode == McpServerElicitationResponseMode::ApprovalAction { + let (decision, meta) = match self.field_value(0).as_ref().and_then(Value::as_str) { + Some(APPROVAL_ACCEPT_ONCE_VALUE) => (ElicitationAction::Accept, None), + Some(APPROVAL_ACCEPT_SESSION_VALUE) => ( + ElicitationAction::Accept, + Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, + })), + ), + Some(APPROVAL_DECLINE_VALUE) => (ElicitationAction::Decline, None), + Some(APPROVAL_CANCEL_VALUE) => (ElicitationAction::Cancel, None), + _ => (ElicitationAction::Cancel, None), + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.request.thread_id, + op: Op::ResolveElicitation { + server_name: self.request.server_name.clone(), + request_id: self.request.request_id.clone(), + decision, + content: None, + meta, + }, + }); + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.reset_for_request(); + self.restore_current_draft(); + } else { + self.done = true; + } + return; + } + let content = self + .request + .fields + .iter() + .enumerate() + .filter_map(|(idx, field)| self.field_value(idx).map(|value| (field.id.clone(), value))) + .collect::>(); + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.request.thread_id, + op: Op::ResolveElicitation { + server_name: self.request.server_name.clone(), + request_id: self.request.request_id.clone(), + decision: ElicitationAction::Accept, + content: Some(Value::Object(content)), + meta: None, + }, + }); + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.reset_for_request(); + self.restore_current_draft(); + } else { + self.done = true; + } + } + + fn go_next_or_submit(&mut self) { + if self.current_index() + 1 >= self.field_count() { + self.submit_answers(); + } else { + self.move_field(true); + } + } + + fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft { + text: text.clone(), + text_elements: text_elements.clone(), + local_image_paths: local_image_paths.clone(), + pending_pastes: Vec::new(), + }; + answer.answer_committed = !text.trim().is_empty(); + } + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); + self.composer.set_footer_hint_override(Some(Vec::new())); + } + + fn handle_composer_input_result(&mut self, result: InputResult) -> bool { + match result { + InputResult::Submitted { + text, + text_elements, + } + | InputResult::Queued { + text, + text_elements, + } => { + self.apply_submission_to_draft(text, text_elements); + self.validation_error = None; + self.go_next_or_submit(); + true + } + _ => false, + } + } + + fn render_prompt(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let answered = self.is_current_field_answered(); + for (offset, line) in self.wrapped_prompt_lines(area.width).iter().enumerate() { + let y = area.y.saturating_add(offset as u16); + if y >= area.y + area.height { + break; + } + let line = if answered { + Line::from(line.clone()) + } else { + Line::from(line.clone()).cyan() + }; + Paragraph::new(line).render( + Rect { + x: area.x, + y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn render_input(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + if self.current_field_is_select() { + let rows = self.option_rows(); + let mut state = self + .current_answer() + .map(|answer| answer.selection) + .unwrap_or_default(); + if state.selected_idx.is_none() && !rows.is_empty() { + state.selected_idx = Some(0); + } + state.ensure_visible(rows.len(), area.height as usize); + render_rows(area, buf, &rows, &state, rows.len().max(1), "No options"); + return; + } + if self.current_field_is_secret() { + self.composer.render_with_mask(area, buf, Some('*')); + } else { + self.composer.render(area, buf); + } + } + + fn render_footer(&self, area: Rect, input_area_height: u16, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let options_hidden = self.current_field_is_select() + && input_area_height > 0 + && self.options_required_height(area.width) > input_area_height; + let option_tip = if options_hidden { + let selected = self.selected_option_index().unwrap_or(0).saturating_add(1); + let total = self.options_len(); + Some(FooterTip::new(format!("option {selected}/{total}"))) + } else { + None + }; + let mut tip_lines = self.footer_tip_lines(area.width); + if let Some(prefix) = option_tip { + let mut tips = vec![prefix]; + if let Some(first_line) = tip_lines.first_mut() { + let mut first = Vec::new(); + std::mem::swap(first_line, &mut first); + tips.extend(first); + *first_line = tips; + } else { + tip_lines.push(tips); + } + } + for (row_idx, tips) in tip_lines.into_iter().take(area.height as usize).enumerate() { + let mut spans = Vec::new(); + for (tip_idx, tip) in tips.into_iter().enumerate() { + if tip_idx > 0 { + spans.push(FOOTER_SEPARATOR.into()); + } + if tip.highlight { + spans.push(tip.text.cyan().bold().not_dim()); + } else { + spans.push(tip.text.into()); + } + } + let line = Line::from(spans).dim(); + Paragraph::new(line).render( + Rect { + x: area.x, + y: area.y.saturating_add(row_idx as u16), + width: area.width, + height: 1, + }, + buf, + ); + } + } +} + +impl Renderable for McpServerElicitationOverlay { + fn desired_height(&self, width: u16) -> u16 { + let outer = Rect::new(0, 0, width, u16::MAX); + let inner = menu_surface_inset(outer); + let inner_width = inner.width.max(1); + let height = 1u16 + .saturating_add(self.wrapped_prompt_lines(inner_width).len() as u16) + .saturating_add(self.input_height(inner_width)) + .saturating_add(self.footer_tip_lines(inner_width).len() as u16) + .saturating_add(menu_surface_padding_height()); + height.max(MIN_OVERLAY_HEIGHT) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + if content_area.width == 0 || content_area.height == 0 { + return; + } + let prompt_lines = self.wrapped_prompt_lines(content_area.width); + let footer_lines = self.footer_tip_lines(content_area.width); + let mut remaining = content_area.height; + + let progress_height = u16::from(remaining > 0); + remaining = remaining.saturating_sub(progress_height); + + let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); + remaining = remaining.saturating_sub(footer_height); + + let min_input_height = if self.current_field_is_select() { + u16::from(remaining > 0) + } else { + MIN_COMPOSER_HEIGHT.min(remaining) + }; + let mut input_height = min_input_height; + remaining = remaining.saturating_sub(input_height); + + let prompt_height = (prompt_lines.len() as u16).min(remaining); + remaining = remaining.saturating_sub(prompt_height); + input_height = input_height.saturating_add(remaining); + + let progress_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: progress_height, + }; + let prompt_area = Rect { + x: content_area.x, + y: progress_area.y.saturating_add(progress_area.height), + width: content_area.width, + height: prompt_height, + }; + let input_area = Rect { + x: content_area.x, + y: prompt_area.y.saturating_add(prompt_area.height), + width: content_area.width, + height: input_height, + }; + let footer_area = Rect { + x: content_area.x, + y: input_area.y.saturating_add(input_area.height), + width: content_area.width, + height: footer_height, + }; + + let unanswered = self.required_unanswered_count(); + let progress_line = if self.field_count() > 0 { + let idx = self.current_index() + 1; + let total = self.field_count(); + let base = format!("Field {idx}/{total}"); + if unanswered > 0 { + Line::from(format!("{base} ({unanswered} required unanswered)").dim()) + } else { + Line::from(base.dim()) + } + } else { + Line::from("No fields".dim()) + }; + Paragraph::new(progress_line).render(progress_area, buf); + self.render_prompt(prompt_area, buf); + self.render_input(input_area, buf); + self.render_footer(footer_area, input_area.height, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if self.current_field_is_select() { + return None; + } + let content_area = menu_surface_inset(area); + if content_area.width == 0 || content_area.height == 0 { + return None; + } + let prompt_lines = self.wrapped_prompt_lines(content_area.width); + let footer_lines = self.footer_tip_lines(content_area.width); + let mut remaining = content_area.height; + remaining = remaining.saturating_sub(u16::from(remaining > 0)); + let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); + remaining = remaining.saturating_sub(footer_height); + let min_input_height = MIN_COMPOSER_HEIGHT.min(remaining); + let mut input_height = min_input_height; + remaining = remaining.saturating_sub(input_height); + let prompt_height = (prompt_lines.len() as u16).min(remaining); + remaining = remaining.saturating_sub(prompt_height); + input_height = input_height.saturating_add(remaining); + let input_area = Rect { + x: content_area.x, + y: content_area + .y + .saturating_add(1) + .saturating_add(prompt_height), + width: content_area.width, + height: input_height, + }; + self.composer.cursor_pos(input_area) + } +} + +impl BottomPaneView for McpServerElicitationOverlay { + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if matches!(key_event.code, KeyCode::Esc) { + self.dispatch_cancel(); + self.done = true; + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::PageUp, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_field(false); + return; + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::PageDown, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_field(true); + return; + } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } if self.current_field_is_select() => { + self.move_field(false); + return; + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } if self.current_field_is_select() => { + self.move_field(true); + return; + } + _ => {} + } + + if self.current_field_is_select() { + self.validation_error = None; + let options_len = self.options_len(); + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => { + if let Some(answer) = self.current_answer_mut() { + answer.selection.move_up_wrap(options_len); + answer.answer_committed = false; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(answer) = self.current_answer_mut() { + answer.selection.move_down_wrap(options_len); + answer.answer_committed = false; + } + } + KeyCode::Backspace | KeyCode::Delete => self.clear_selection(), + KeyCode::Char(' ') => self.select_current_option(true), + KeyCode::Enter => { + if self.selected_option_index().is_some() { + self.select_current_option(true); + } + self.go_next_or_submit(); + } + KeyCode::Char(ch) => { + if let Some(option_idx) = self.option_index_for_digit(ch) { + if let Some(answer) = self.current_answer_mut() { + answer.selection.selected_idx = Some(option_idx); + } + self.select_current_option(true); + self.go_next_or_submit(); + } + } + _ => {} + } + return; + } + + let before = self.capture_composer_draft(); + let (result, _) = self.composer.handle_key_event(key_event); + let submitted = self.handle_composer_input_result(result); + if submitted { + return; + } + let after = self.capture_composer_draft(); + if before != after { + self.validation_error = None; + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = false; + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if !self.current_field_is_select() && !self.composer.current_text_with_pending().is_empty() + { + self.clear_current_draft(); + return CancellationEvent::Handled; + } + self.dispatch_cancel(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() || self.current_field_is_select() { + return false; + } + self.validation_error = None; + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = false; + } + self.composer.handle_paste(pasted) + } + + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + + fn try_consume_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) -> Option { + self.queue.push_back(request); + None + } +} + +fn wrap_footer_tips(width: u16, tips: Vec) -> Vec> { + let max_width = width.max(1) as usize; + let separator_width = UnicodeWidthStr::width(FOOTER_SEPARATOR); + if tips.is_empty() { + return vec![Vec::new()]; + } + + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut used = 0usize; + + for tip in tips { + let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(max_width); + let extra = if current.is_empty() { + tip_width + } else { + separator_width.saturating_add(tip_width) + }; + if !current.is_empty() && used.saturating_add(extra) > max_width { + lines.push(current); + current = Vec::new(); + used = 0; + } + if current.is_empty() { + used = tip_width; + } else { + used = used + .saturating_add(separator_width) + .saturating_add(tip_width); + } + current.push(tip); + } + + if current.is_empty() { + lines.push(Vec::new()); + } else { + lines.push(current); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::UnboundedReceiver; + use tokio::sync::mpsc::unbounded_channel; + + fn test_sender() -> (AppEventSender, UnboundedReceiver) { + let (tx_raw, rx) = unbounded_channel::(); + (AppEventSender::new(tx_raw), rx) + } + + fn form_request( + message: &str, + requested_schema: Value, + meta: Option, + ) -> ElicitationRequestEvent { + ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "server-1".to_string(), + id: McpRequestId::String("request-1".to_string()), + request: ElicitationRequest::Form { + meta, + message: message.to_string(), + requested_schema, + }, + } + } + + fn empty_object_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {}, + }) + } + + fn tool_approval_meta(include_session_persist: bool) -> 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()), + )]); + if include_session_persist { + meta.insert( + APPROVAL_PERSIST_KEY.to_string(), + Value::String(APPROVAL_PERSIST_SESSION_VALUE.to_string()), + ); + } + Some(Value::Object(meta)) + } + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(overlay: &McpServerElicitationOverlay, area: Rect) -> String { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshot_buffer(&buf) + } + + #[test] + fn parses_boolean_form_request() { + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + None, + ), + ) + .expect("expected supported form"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + message: "Allow this request?".to_string(), + response_mode: McpServerElicitationResponseMode::FormContent, + fields: vec![McpServerElicitationField { + id: "confirmed".to_string(), + label: "Confirm".to_string(), + prompt: "Approve the pending action.".to_string(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "True".to_string(), + description: None, + value: Value::Bool(true), + }, + McpServerElicitationOption { + label: "False".to_string(), + description: None, + value: Value::Bool(false), + }, + ], + default_idx: None, + }, + }], + } + ); + } + + #[test] + fn unsupported_numeric_form_falls_back() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Pick a number", + serde_json::json!({ + "type": "object", + "properties": { + "count": { + "type": "integer", + "title": "Count", + } + }, + }), + None, + ), + ); + + assert_eq!(request, None); + } + + #[test] + fn missing_schema_uses_approval_actions() { + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request("Allow this request?", Value::Null, None), + ) + .expect("expected approval fallback"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + message: "Allow this request?".to_string(), + response_mode: McpServerElicitationResponseMode::ApprovalAction, + fields: vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "Approve Once".to_string(), + description: Some("Run the tool and continue.".to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Deny".to_string(), + description: Some( + "Decline this tool call and continue.".to_string(), + ), + value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this tool call".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ], + default_idx: Some(0), + }, + }], + } + ); + } + + #[test] + fn empty_tool_approval_schema_uses_approval_actions() { + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta(false), + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + message: "Allow this request?".to_string(), + response_mode: McpServerElicitationResponseMode::ApprovalAction, + fields: vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "Approve Once".to_string(), + description: Some("Run the tool and continue.".to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Deny".to_string(), + description: Some( + "Decline this tool call and continue.".to_string(), + ), + value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this tool call".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ], + default_idx: Some(0), + }, + }], + } + ); + } + + #[test] + fn empty_unmarked_schema_falls_back() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request("Empty form", empty_object_schema(), None), + ); + + assert_eq!(request, None); + } + + #[test] + fn submit_sends_accept_with_typed_content() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + overlay.select_current_option(true); + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: Some(serde_json::json!({ + "confirmed": true, + })), + meta: None, + } + ); + } + + #[test] + fn empty_tool_approval_schema_session_choice_sets_persist_meta() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta(true), + ), + ) + .expect("expected approval fallback"); + let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + if let Some(answer) = overlay.current_answer_mut() { + answer.selection.selected_idx = Some(1); + } + overlay.select_current_option(true); + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, + })), + } + ); + } + + #[test] + fn ctrl_c_cancels_elicitation() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = McpServerElicitationFormRequest::from_event( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + assert_eq!(overlay.on_ctrl_c(), CancellationEvent::Handled); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Cancel, + content: None, + meta: None, + } + ); + } + + #[test] + fn queues_requests_fifo() { + let (tx, _rx) = test_sender(); + let first = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "First", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + None, + ), + ) + .expect("expected supported form"); + let second = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Second", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + None, + ), + ) + .expect("expected supported form"); + let third = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Third", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new(first, tx, true, false, false); + + overlay.try_consume_mcp_server_elicitation_request(second); + overlay.try_consume_mcp_server_elicitation_request(third); + overlay.select_current_option(true); + overlay.submit_answers(); + + assert_eq!(overlay.request.message, "Second"); + + overlay.select_current_option(true); + overlay.submit_answers(); + + assert_eq!(overlay.request.message, "Third"); + } + + #[test] + fn boolean_form_snapshot() { + let (tx, _rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + None, + ), + ) + .expect("expected supported form"); + let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + insta::assert_snapshot!( + "mcp_server_elicitation_boolean_form", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn approval_form_tool_approval_snapshot() { + let (tx, _rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta(false), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_without_schema", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn approval_form_tool_approval_with_session_persist_snapshot() { + let (tx, _rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta(true), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_with_session_persist", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1e6e6fce0..7f4f7dd61 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -42,6 +42,7 @@ use std::time::Duration; mod app_link_view; mod approval_overlay; +mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; @@ -49,6 +50,8 @@ pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; +pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; +pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; @@ -920,6 +923,37 @@ impl BottomPane { self.push_view(Box::new(modal)); } + pub(crate) fn push_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_mcp_server_elicitation_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + let modal = McpServerElicitationOverlay::new( + request, + self.app_event_tx.clone(), + self.has_input_focus, + self.enhanced_keys_supported, + self.disable_paste_burst, + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Respond to the MCP server request to continue.".to_string()), + ); + self.push_view(Box::new(modal)); + } + fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); self.set_composer_input_enabled(true, None); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.snap new file mode 100644 index 000000000..5cee893a7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.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 this request? + › 1. Approve Once Run the tool and continue. + 2. Approve this Session Run the tool and remember this choice for this session. + 3. Deny Decline this tool call and continue. + 4. Cancel Cancel this tool call + + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap new file mode 100644 index 000000000..66f1d6f35 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.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 this request? + › 1. Approve Once Run the tool and continue. + 2. Deny Decline this tool call and continue. + 3. Cancel Cancel this tool call + + + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap new file mode 100644 index 000000000..0415c2407 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.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 (1 required unanswered) + Allow this request? + + Confirm + Approve the pending action. + › 1. True + 2. False + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7f8126023..c7b7aff7a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -224,6 +224,7 @@ use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; +use crate::bottom_pane::McpServerElicitationFormRequest; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; @@ -2871,15 +2872,21 @@ impl ChatWidget { server_name: ev.server_name.clone(), }); - let request = ApprovalRequest::McpElicitation { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - server_name: ev.server_name, - request_id: ev.id, - message: ev.request.message().to_string(), - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + let thread_id = self.thread_id.unwrap_or_default(); + if let Some(request) = McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + } else { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: ev.server_name, + request_id: ev.id, + message: ev.request.message().to_string(), + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } self.request_redraw(); } @@ -2889,6 +2896,15 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn push_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + self.request_redraw(); + } + pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) { self.flush_answer_stream_with_separator(); self.notify(Notification::UserInputRequested {