feat(app-server): support mcp elicitations in v2 api (#13425)

This adds a first-class server request for MCP server elicitations:
`mcpServer/elicitation/request`.

Until now, MCP elicitation requests only showed up as a raw
`codex/event/elicitation_request` event from core. That made it hard for
v2 clients to handle elicitations using the same request/response flow
as other server-driven interactions (like shell and `apply_patch`
tools).

This also updates the underlying MCP elicitation request handling in
core to pass through the full MCP request (including URL and form data)
so we can expose it properly in app-server.

### Why not `item/mcpToolCall/elicitationRequest`?
This is because MCP elicitations are related to MCP servers first, and
only optionally to a specific MCP tool call.

In the MCP protocol, elicitation is a server-to-client capability: the
server sends `elicitation/create`, and the client replies with an
elicitation result. RMCP models it that way as well.

In practice an elicitation is often triggered by an MCP tool call, but
not always.

### What changed
- add `mcpServer/elicitation/request` to the v2 app-server API
- translate core `codex/event/elicitation_request` events into the new
v2 server request
- map client responses back into `Op::ResolveElicitation` so the MCP
server can continue
- update app-server docs and generated protocol schema
- add an end-to-end app-server test that covers the full round trip
through a real RMCP elicitation flow
- The new test exercises a realistic case where an MCP tool call
triggers an elicitation, the app-server emits
mcpServer/elicitation/request, the client accepts it, and the tool call
resumes and completes successfully.

### app-server API flow
- Client starts a thread with `thread/start`.
- Client starts a turn with `turn/start`.
- App-server sends `item/started` for the `mcpToolCall`.
- While that tool call is in progress, app-server sends
`mcpServer/elicitation/request`.
- Client responds to that request with `{ action: "accept" | "decline" |
"cancel" }`.
- App-server sends `serverRequest/resolved`.
- App-server sends `item/completed` for the mcpToolCall.
- App-server sends `turn/completed`.
- If the turn is interrupted while the elicitation is pending,
app-server still sends `serverRequest/resolved` before the turn
finishes.
This commit is contained in:
Owen Lin
2026-03-05 07:20:20 -08:00
committed by GitHub
parent 5e92f4af12
commit 926b2f19e8
32 changed files with 1472 additions and 39 deletions

View File

@@ -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 {

View File

@@ -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<McpServerElicitationAction> 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<rmcp::model::ElicitationAction> 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<String>,
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<CoreElicitationRequest> 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<JsonValue>,
}
impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitationResult {
fn from(value: McpServerElicitationRequestResponse) -> Self {
Self {
action: value.action.into(),
content: value.content,
}
}
}
impl From<rmcp::model::CreateElicitationResult> 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();