diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 81dc07bd3..e3c58e0e2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1469,6 +1469,7 @@ dependencies = [ "codex-utils-cargo-bin", "inventory", "pretty_assertions", + "rmcp", "schemars 0.8.22", "serde", "serde_json", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index dc6823a4a..8566779a0 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -24,6 +24,12 @@ serde_with = { workspace = true } shlex = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } +rmcp = { workspace = true, default-features = false, features = [ + "base64", + "macros", + "schemars", + "server", +] } ts-rs = { workspace = true } inventory = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 39af1ac96..26f15b8fe 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -544,6 +544,56 @@ } ] }, + "ElicitationRequest": { + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requested_schema": true + }, + "required": [ + "message", + "mode", + "requested_schema" + ], + "type": "object" + }, + { + "properties": { + "elicitation_id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitation_id", + "message", + "mode", + "url" + ], + "type": "object" + } + ] + }, "EventMsg": { "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", "oneOf": [ @@ -1963,8 +2013,8 @@ "id": { "$ref": "#/definitions/RequestId" }, - "message": { - "type": "string" + "request": { + "$ref": "#/definitions/ElicitationRequest" }, "server_name": { "type": "string" @@ -1979,7 +2029,7 @@ }, "required": [ "id", - "message", + "request", "server_name", "type" ], @@ -7756,8 +7806,8 @@ "id": { "$ref": "#/definitions/RequestId" }, - "message": { - "type": "string" + "request": { + "$ref": "#/definitions/ElicitationRequest" }, "server_name": { "type": "string" @@ -7772,7 +7822,7 @@ }, "required": [ "id", - "message", + "request", "server_name", "type" ], diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json new file mode 100644 index 000000000..52a2c33ff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, + { + "properties": { + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "title": "McpServerElicitationRequestParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json new file mode 100644 index 000000000..8c14f9094 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerElicitationAction": { + "enum": [ + "accept", + "decline", + "cancel" + ], + "type": "string" + } + }, + "properties": { + "action": { + "$ref": "#/definitions/McpServerElicitationAction" + }, + "content": { + "description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content." + } + }, + "required": [ + "action" + ], + "title": "McpServerElicitationRequestResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index fce6ec619..8abf14095 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -652,6 +652,76 @@ } ] }, + "McpServerElicitationRequestParams": { + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, + { + "properties": { + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "type": "object" + }, "NetworkApprovalContext": { "properties": { "host": { @@ -981,6 +1051,31 @@ "title": "Item/tool/requestUserInputRequest", "type": "object" }, + { + "description": "Request input for an MCP server elicitation.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/elicitation/request" + ], + "title": "McpServer/elicitation/requestRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerElicitationRequestParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/elicitation/requestRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { 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 ff9f8d7d3..e9c792189 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 @@ -1774,6 +1774,56 @@ "title": "DynamicToolCallResponse", "type": "object" }, + "ElicitationRequest": { + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requested_schema": true + }, + "required": [ + "message", + "mode", + "requested_schema" + ], + "type": "object" + }, + { + "properties": { + "elicitation_id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitation_id", + "message", + "mode", + "url" + ], + "type": "object" + } + ] + }, "EventMsg": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", @@ -3194,8 +3244,8 @@ "id": { "$ref": "#/definitions/v2/RequestId" }, - "message": { - "type": "string" + "request": { + "$ref": "#/definitions/ElicitationRequest" }, "server_name": { "type": "string" @@ -3210,7 +3260,7 @@ }, "required": [ "id", - "message", + "request", "server_name", "type" ], @@ -5259,6 +5309,102 @@ ], "type": "object" }, + "McpServerElicitationAction": { + "enum": [ + "accept", + "decline", + "cancel" + ], + "type": "string" + }, + "McpServerElicitationRequestParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, + { + "properties": { + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "title": "McpServerElicitationRequestParams", + "type": "object" + }, + "McpServerElicitationRequestResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "action": { + "$ref": "#/definitions/McpServerElicitationAction" + }, + "content": { + "description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content." + } + }, + "required": [ + "action" + ], + "title": "McpServerElicitationRequestResponse", + "type": "object" + }, "McpStartupFailure": { "properties": { "error": { @@ -6972,6 +7118,31 @@ "title": "Item/tool/requestUserInputRequest", "type": "object" }, + { + "description": "Request input for an MCP server elicitation.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "mcpServer/elicitation/request" + ], + "title": "McpServer/elicitation/requestRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerElicitationRequestParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/elicitation/requestRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { 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 da523ea97..2aac99cb3 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 @@ -3286,6 +3286,56 @@ ], "type": "object" }, + "ElicitationRequest": { + "oneOf": [ + { + "properties": { + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requested_schema": true + }, + "required": [ + "message", + "mode", + "requested_schema" + ], + "type": "object" + }, + { + "properties": { + "elicitation_id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitation_id", + "message", + "mode", + "url" + ], + "type": "object" + } + ] + }, "ErrorNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -4731,8 +4781,8 @@ "id": { "$ref": "#/definitions/RequestId" }, - "message": { - "type": "string" + "request": { + "$ref": "#/definitions/ElicitationRequest" }, "server_name": { "type": "string" @@ -4747,7 +4797,7 @@ }, "required": [ "id", - "message", + "request", "server_name", "type" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.ts new file mode 100644 index 000000000..7ecf4468e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequest.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 { JsonValue } from "./serde_json/JsonValue"; + +export type ElicitationRequest = { "mode": "form", message: string, requested_schema: JsonValue, } | { "mode": "url", 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 045e304bd..8739e8935 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts @@ -1,5 +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 { ElicitationRequest } from "./ElicitationRequest"; -export type ElicitationRequestEvent = { server_name: string, id: string | number, message: string, }; +export type ElicitationRequestEvent = { server_name: string, id: string | number, request: ElicitationRequest, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 17c66959a..107c5eebc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -8,9 +8,10 @@ import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefre import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams"; import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams"; +import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams"; import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams"; /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index fd0d333e4..21272ecf2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -48,6 +48,7 @@ export type { DeprecationNoticeEvent } from "./DeprecationNoticeEvent"; export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; export type { DynamicToolCallRequest } from "./DynamicToolCallRequest"; export type { DynamicToolCallResponseEvent } from "./DynamicToolCallResponseEvent"; +export type { ElicitationRequest } from "./ElicitationRequest"; export type { ElicitationRequestEvent } from "./ElicitationRequestEvent"; export type { ErrorEvent } from "./ErrorEvent"; export type { EventMsg } from "./EventMsg"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.ts new file mode 100644 index 000000000..7be134c01 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.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 McpServerElicitationAction = "accept" | "decline" | "cancel"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts new file mode 100644 index 000000000..5fa767afd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -0,0 +1,15 @@ +// 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 { JsonValue } from "../serde_json/JsonValue"; + +export type McpServerElicitationRequestParams = { threadId: string, +/** + * Active Codex turn when this elicitation was observed, if app-server could correlate one. + * + * This is nullable because MCP models elicitation as a standalone server-to-client request + * identified by the MCP server request id. It may be triggered during a turn, but turn + * 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, }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts new file mode 100644 index 000000000..34e161e4f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.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 { JsonValue } from "../serde_json/JsonValue"; +import type { McpServerElicitationAction } from "./McpServerElicitationAction"; + +export type McpServerElicitationRequestResponse = { action: McpServerElicitationAction, +/** + * Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + * + * This is nullable because decline/cancel responses have no content. + */ +content: 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 9776cea4d..b34d28e5b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -97,6 +97,9 @@ export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { McpAuthStatus } from "./McpAuthStatus"; +export type { McpServerElicitationAction } from "./McpServerElicitationAction"; +export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; +export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification"; export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams"; export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index fa95cbf24..3cb0806cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -650,6 +650,12 @@ server_request_definitions! { response: v2::ToolRequestUserInputResponse, }, + /// Request input for an MCP server elicitation. + McpServerElicitationRequest => "mcpServer/elicitation/request" { + params: v2::McpServerElicitationRequestParams, + response: v2::McpServerElicitationRequestResponse, + }, + /// Execute a dynamic tool call on the client. DynamicToolCall => "item/tool/call" { params: v2::DynamicToolCallParams, @@ -1046,6 +1052,60 @@ mod tests { Ok(()) } + #[test] + fn serialize_mcp_server_elicitation_request() -> Result<()> { + 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 { + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": ["confirmed"] + }), + }, + }; + let request = ServerRequest::McpServerElicitationRequest { + request_id: RequestId::Integer(9), + params: params.clone(), + }; + + assert_eq!( + json!({ + "method": "mcpServer/elicitation/request", + "id": 9, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "serverName": "codex_apps", + "mode": "form", + "message": "Allow this request?", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": ["confirmed"] + } + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::McpServerElicitationRequest(params); + assert_eq!(request.id(), &RequestId::Integer(9)); + assert_eq!(payload.request_with_id(RequestId::Integer(9)), request); + Ok(()) + } + #[test] fn serialize_get_account_rate_limits() -> Result<()> { let request = ClientRequest::GetAccountRateLimits { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 83499ae88..40c9b3196 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -6,6 +6,7 @@ use crate::RequestId; use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; @@ -637,7 +638,7 @@ pub struct NetworkRequirements { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub enum ResidencyRequirement { Us, @@ -2393,8 +2394,8 @@ pub enum HazelnutScope { } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] -#[serde(rename_all = "lowercase")] -#[ts(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub enum ProductSurface { Chatgpt, @@ -4081,6 +4082,138 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationAction { + Accept, + Decline, + Cancel, +} + +impl McpServerElicitationAction { + pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { + match self { + Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, + Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, + Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, + } + } +} + +impl From for rmcp::model::ElicitationAction { + fn from(value: McpServerElicitationAction) -> Self { + match value { + McpServerElicitationAction::Accept => Self::Accept, + McpServerElicitationAction::Decline => Self::Decline, + McpServerElicitationAction::Cancel => Self::Cancel, + } + } +} + +impl From for McpServerElicitationAction { + fn from(value: rmcp::model::ElicitationAction) -> Self { + match value { + rmcp::model::ElicitationAction::Accept => Self::Accept, + rmcp::model::ElicitationAction::Decline => Self::Decline, + rmcp::model::ElicitationAction::Cancel => Self::Cancel, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestParams { + pub thread_id: String, + /// Active Codex turn when this elicitation was observed, if app-server could correlate one. + /// + /// This is nullable because MCP models elicitation as a standalone server-to-client request + /// identified by the MCP server request id. It may be triggered during a turn, but turn + /// context is app-server correlation rather than part of the protocol identity of the + /// elicitation itself. + pub turn_id: Option, + pub server_name: String, + #[serde(flatten)] + pub request: McpServerElicitationRequest, + // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated + // McpToolCall item id here as an optional field. The current core event does not carry that + // association. +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationRequest { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Form { + message: String, + requested_schema: JsonValue, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Url { + message: String, + url: String, + elicitation_id: String, + }, +} + +impl From for McpServerElicitationRequest { + fn from(value: CoreElicitationRequest) -> Self { + match value { + CoreElicitationRequest::Form { + message, + requested_schema, + } => Self::Form { + message, + requested_schema, + }, + CoreElicitationRequest::Url { + message, + url, + elicitation_id, + } => Self::Url { + message, + url, + elicitation_id, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestResponse { + pub action: McpServerElicitationAction, + /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + /// + /// This is nullable because decline/cancel responses have no content. + pub content: Option, +} + +impl From for rmcp::model::CreateElicitationResult { + fn from(value: McpServerElicitationRequestResponse) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::CreateElicitationResult) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4417,6 +4550,65 @@ mod tests { assert_eq!(back_to_v2, v2_policy); } + #[test] + fn mcp_server_elicitation_response_round_trips_rmcp_result() { + let rmcp_result = rmcp::model::CreateElicitationResult { + action: rmcp::model::ElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + }; + + let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); + assert_eq!( + v2_response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + } + ); + assert_eq!( + rmcp::model::CreateElicitationResult::from(v2_response), + rmcp_result + ); + } + + #[test] + fn mcp_server_elicitation_request_from_core_url_request() { + let request = McpServerElicitationRequest::from(CoreElicitationRequest::Url { + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + }); + + assert_eq!( + request, + McpServerElicitationRequest::Url { + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + } + ); + } + + #[test] + fn mcp_server_elicitation_response_serializes_nullable_content() { + let response = McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "action": "decline", + "content": null, + }) + ); + } + #[test] fn sandbox_policy_round_trips_workspace_write_read_only_access() { let readable_root = test_absolute_path(); diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ca7f7bffb..c8bcdfcea 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -69,6 +69,7 @@ core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ + "elicitation", "server", "transport-streamable-http-server", ] } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 41a3bc5f5..72c35b4ec 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -764,6 +764,20 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. +### MCP server elicitations + +MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. + +Order of messages: + +1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either: + - a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }` + - a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }` +2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. +3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. + +`turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. + ### Dynamic tool calls (experimental) `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4d005706d..d714098d6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -45,6 +45,9 @@ use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; @@ -609,6 +612,38 @@ pub(crate) async fn apply_bespoke_event_handling( } } } + EventMsg::ElicitationRequest(request) => { + if matches!(api_version, ApiVersion::V2) { + 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 params = McpServerElicitationRequestParams { + thread_id: conversation_id.to_string(), + turn_id, + server_name: request.server_name.clone(), + request: request.request.into(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::McpServerElicitationRequest(params)) + .await; + tokio::spawn(async move { + on_mcp_server_elicitation_response( + request.server_name, + request.id, + pending_request_id, + rx, + conversation, + thread_state, + permission_guard, + ) + .await; + }); + } + } EventMsg::DynamicToolCallRequest(request) => { if matches!(api_version, ApiVersion::V2) { let call_id = request.call_id; @@ -1989,6 +2024,68 @@ async fn on_request_user_input_response( } } +async fn on_mcp_server_elicitation_response( + server_name: String, + request_id: codex_protocol::mcp::RequestId, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, + permission_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(permission_guard); + let response = mcp_server_elicitation_response_from_client_result(response); + + if let Err(err) = conversation + .submit(Op::ResolveElicitation { + server_name, + request_id, + decision: response.action.to_core(), + content: response.content, + }) + .await + { + error!("failed to submit ResolveElicitation: {err}"); + } +} + +fn mcp_server_elicitation_response_from_client_result( + response: std::result::Result, +) -> McpServerElicitationRequestResponse { + match response { + Ok(Ok(value)) => serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize McpServerElicitationRequestResponse: {err}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + } + }), + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => { + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + } + } + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + } + } + Err(err) => { + error!("request failed: {err:?}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + } + } + } +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { @@ -2334,6 +2431,7 @@ mod tests { use anyhow::Result; use anyhow::anyhow; use anyhow::bail; + use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_protocol::mcp::CallToolResult; use codex_protocol::plan_tool::PlanItemArg; @@ -2378,6 +2476,25 @@ mod tests { assert_eq!(completion_status, None); } + #[test] + fn mcp_server_elicitation_turn_transition_error_maps_to_cancel() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(serde_json::json!({ "reason": "turnTransition" })), + }; + + let response = mcp_server_elicitation_response_from_client_result(Ok(Err(error))); + + assert_eq!( + response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + } + ); + } + #[test] fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { 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 new file mode 100644 index 000000000..924b30e24 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -0,0 +1,477 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +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::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::BooleanSchema; +use rmcp::model::CallToolRequestParams; +use rmcp::model::CallToolResult; +use rmcp::model::Content; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::ElicitationSchema; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::PrimitiveSchema; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const CONNECTOR_ID: &str = "calendar"; +const CONNECTOR_NAME: &str = "Calendar"; +const TOOL_NAME: &str = "calendar_confirm_action"; +const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action"; +const TOOL_CALL_ID: &str = "call-calendar-confirm"; +const ELICITATION_MESSAGE: &str = "Allow this request?"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_elicitation_round_trip() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let tool_call_arguments = serde_json::to_string(&json!({}))?; + let response_mock = responses::mount_sse_sequence( + &responses_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-0"), + responses::ev_assistant_message("msg-0", "Warmup"), + responses::ev_completed("resp-0"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call( + TOOL_CALL_ID, + QUALIFIED_TOOL_NAME, + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let (apps_server_url, apps_server_handle) = start_apps_server().await?; + + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let warmup_turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let warmup_turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; + + let warmup_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let warmup_completed: TurnCompletedNotification = serde_json::from_value( + warmup_completed + .params + .clone() + .expect("warmup turn/completed params"), + )?; + assert_eq!(warmup_completed.thread_id, thread.id); + assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + let requested_schema = serde_json::to_value( + ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(anyhow::Error::msg)?, + )?; + + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id.clone(), + turn_id: Some(turn.id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }, + } + ); + + let resolved_request_id = request_id.clone(); + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + })?, + ) + .await?; + + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!( + resolved, + ServerRequestResolvedNotification { + thread_id: thread.id.clone(), + request_id: resolved_request_id.clone(), + } + ); + saw_resolved = true; + } + "turn/completed" => { + let completed: TurnCompletedNotification = serde_json::from_value( + notification.params.clone().expect("turn/completed params"), + )?; + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + break; + } + _ => {} + } + } + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); + assert_eq!( + function_call_output.get("type"), + Some(&Value::String("function_call_output".to_string())) + ); + assert_eq!( + function_call_output.get("call_id"), + Some(&Value::String(TOOL_CALL_ID.to_string())) + ); + let output = function_call_output + .get("output") + .and_then(Value::as_str) + .expect("function_call_output output should be a JSON string"); + assert_eq!( + serde_json::from_str::(output)?, + json!([{ + "type": "text", + "text": "accepted" + }]) + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + expected_bearer: String, + expected_account_id: String, +} + +#[derive(Clone, Default)] +struct ElicitationAppsMcpServer; + +impl ServerHandler for ElicitationAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18, + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Borrowed(TOOL_NAME), + Cow::Borrowed("Confirm a calendar action."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(CONNECTOR_ID)); + meta.0 + .insert("connector_name".to_string(), json!(CONNECTOR_NAME)); + tool.meta = Some(meta); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!( + result.content, + Some(json!({ + "confirmed": true, + })) + ); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + + Ok(CallToolResult::success(vec![Content::text(output)])) + } +} + +async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { + let state = Arc::new(AppsServerState { + expected_bearer: "Bearer chatgpt-token".to_string(), + expected_account_id: "account-123".to_string(), + }); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let mcp_service = StreamableHttpService::new( + move || Ok(ElicitationAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result, StatusCode> { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + Ok(Json(json!({ + "apps": [{ + "id": CONNECTOR_ID, + "name": CONNECTOR_NAME, + "description": "Calendar connector", + "logo_url": null, + "logo_url_dark": null, + "distribution_channel": null, + "branding": null, + "app_metadata": null, + "labels": null, + "install_url": null, + "is_accessible": false, + "is_enabled": true + }], + "next_token": null + }))) + } +} + +fn write_config_toml( + codex_home: &std::path::Path, + responses_server_uri: &str, + apps_server_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 7b7010086..ce029b48b 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -11,6 +11,7 @@ mod dynamic_tools; mod experimental_api; mod experimental_feature_list; mod initialize; +mod mcp_server_elicitation; mod model_list; mod output_schema; mod plan_item; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 509e8739a..a9e7883d3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3869,8 +3869,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv server_name, request_id, decision, + content, } => { - handlers::resolve_elicitation(&sess, server_name, request_id, decision).await; + handlers::resolve_elicitation( + &sess, + server_name, + request_id, + decision, + content, + ) + .await; false } Op::Shutdown => handlers::shutdown(&sess, sub.id.clone()).await, @@ -3958,6 +3966,7 @@ mod handlers { use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; + use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; use tracing::info; @@ -4092,16 +4101,16 @@ mod handlers { server_name: String, request_id: ProtocolRequestId, decision: codex_protocol::approvals::ElicitationAction, + content: Option, ) { let action = match decision { codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept, codex_protocol::approvals::ElicitationAction::Decline => ElicitationAction::Decline, codex_protocol::approvals::ElicitationAction::Cancel => ElicitationAction::Cancel, }; - // When accepting, send an empty object as content to satisfy MCP servers - // that expect non-null content on Accept. For Decline/Cancel, content is None. let content = match action { - ElicitationAction::Accept => Some(serde_json::json!({})), + // Preserve the legacy fallback for clients that only send an action. + ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))), ElicitationAction::Decline | ElicitationAction::Cancel => None, }; let response = ElicitationResponse { action, content }; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 2d22351d1..f332bc19e 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -27,6 +27,7 @@ use async_channel::Sender; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_config::Constrained; +use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::RequestId as ProtocolRequestId; @@ -294,6 +295,27 @@ impl ElicitationRequestManager { }); } + let request = match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => ElicitationRequest::Form { + message, + requested_schema: serde_json::to_value(requested_schema) + .context("failed to serialize MCP elicitation schema")?, + }, + CreateElicitationRequestParams::UrlElicitationParams { + message, + url, + elicitation_id, + .. + } => ElicitationRequest::Url { + message, + url, + elicitation_id, + }, + }; let (tx, rx) = oneshot::channel(); { let mut lock = elicitation_requests.lock().await; @@ -312,16 +334,7 @@ impl ElicitationRequestManager { ProtocolRequestId::Integer(value) } }, - message: match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - message, - .. - } - | CreateElicitationRequestParams::UrlElicitationParams { - message, - .. - } => message, - }, + request, }), }) .await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0c7271c56..3d2faa02b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -691,6 +691,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { server_name: ev.server_name.clone(), request_id: ev.id.clone(), decision: ElicitationAction::Cancel, + content: None, }) .await?; } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 12bd65ec5..bd1b01d93 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -11,6 +11,7 @@ use crate::protocol::SandboxPolicy; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq)] @@ -190,15 +191,35 @@ impl ExecApprovalRequestEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "snake_case")] +#[ts(tag = "mode")] +pub enum ElicitationRequest { + Form { + message: String, + requested_schema: JsonValue, + }, + Url { + message: String, + url: String, + elicitation_id: String, + }, +} + +impl ElicitationRequest { + pub fn message(&self) -> &str { + match self { + Self::Form { message, .. } | Self::Url { message, .. } => message, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ElicitationRequestEvent { pub server_name: String, #[ts(type = "string | number")] pub id: RequestId, - pub message: String, - // TODO: MCP servers can request we fill out a schema for the elicitation. We don't support - // this yet. - // pub requested_schema: ElicitRequestParamsRequestedSchema, + pub request: ElicitationRequest, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dbae3f3b5..3d3abaf82 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -331,6 +331,9 @@ pub enum Op { request_id: RequestId, /// User's decision for the request. decision: ElicitationAction, + /// Structured user input supplied for accepted elicitations. + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, }, /// Resolve a request_user_input tool call. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5722c5020..206ebbb7b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1043,7 +1043,7 @@ impl App { thread_label, server_name: ev.server_name.clone(), request_id: ev.id.clone(), - message: ev.message.clone(), + message: ev.request.message().to_string(), }), _ => None, } diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index d82d01482..6ab071244 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -588,7 +588,13 @@ mod tests { msg: EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent { server_name: "server-1".to_string(), id: request_id.clone(), - message: "Please confirm".to_string(), + request: codex_protocol::approvals::ElicitationRequest::Form { + message: "Please confirm".to_string(), + requested_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + }, }), }); @@ -596,6 +602,7 @@ mod tests { server_name: "server-1".to_string(), request_id, decision: codex_protocol::approvals::ElicitationAction::Accept, + content: 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 21f0261cf..f5db62981 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -280,6 +280,7 @@ impl ApprovalOverlay { server_name: server_name.to_string(), request_id: request_id.clone(), decision, + content: None, }, }); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5d0a9dbec..2e21c68f0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2878,7 +2878,7 @@ impl ChatWidget { thread_label: None, server_name: ev.server_name, request_id: ev.id, - message: ev.message, + message: ev.request.message().to_string(), }; self.bottom_pane .push_approval_request(request, &self.config.features);