mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Support openai/elicitation form requests (#41447)
## What changed - Advertise `openai/elicitation` form support when the client declares an object-valued `form` capability, without deriving it from the legacy `openai/form` capability. - Handle `openai/elicitation/create` requests in `form` mode and forward their metadata and opaque schema through app-server as `openaiForm`, preserving `x-openai-*` annotations. - Keep legacy form handling independent and have the TUI automatically decline the new form requests it cannot render. ## Testing - Add an app-server round-trip test for capability negotiation, request forwarding, and accepted responses. - Extend MCP capability filtering coverage for `openai/elicitation.form`. GitOrigin-RevId: 88f39257bffafe4ee98a9c910e38507843cd7eeb
This commit is contained in:
+21
@@ -578,6 +578,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openaiForm"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
@@ -1436,6 +1436,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openaiForm"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
+21
@@ -3999,6 +3999,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openaiForm"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -13,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", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
|
||||
turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "openaiForm", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
|
||||
|
||||
@@ -755,6 +755,7 @@ pub enum McpServerElicitationRequest {
|
||||
message: String,
|
||||
requested_schema: McpElicitationSchema,
|
||||
},
|
||||
// TODO(victor): Deprecate once migrated to `openai/elicitation/create`.
|
||||
#[serde(rename = "openai/form", rename_all = "camelCase")]
|
||||
#[ts(rename = "openai/form", rename_all = "camelCase")]
|
||||
OpenAiForm {
|
||||
@@ -764,6 +765,15 @@ pub enum McpServerElicitationRequest {
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename = "openaiForm", rename_all = "camelCase")]
|
||||
#[ts(rename = "openaiForm", rename_all = "camelCase")]
|
||||
OpenAiElicitationForm {
|
||||
#[serde(rename = "_meta")]
|
||||
#[ts(rename = "_meta")]
|
||||
meta: Option<JsonValue>,
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Url {
|
||||
@@ -799,6 +809,15 @@ impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
|
||||
message,
|
||||
requested_schema,
|
||||
}),
|
||||
CoreElicitationRequest::OpenAiElicitationForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => Ok(Self::OpenAiElicitationForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
}),
|
||||
CoreElicitationRequest::Url {
|
||||
meta,
|
||||
message,
|
||||
|
||||
@@ -98,6 +98,7 @@ legacy alias for declaring the `openai/form` extension.
|
||||
"capabilities": {
|
||||
"extensions": {
|
||||
"openai/form": {},
|
||||
"openai/elicitation": { "form": {} },
|
||||
"io.modelcontextprotocol/ui": {
|
||||
"mimeTypes": ["text/html;profile=mcp-app"]
|
||||
}
|
||||
@@ -106,6 +107,15 @@ legacy alias for declaring the `openai/form` extension.
|
||||
}
|
||||
```
|
||||
|
||||
`openai/elicitation.form: {}` declares support for forms received as
|
||||
`mode: "openaiForm"`. It does not follow from the legacy capability.
|
||||
App-server retains only the `form` key under `openai/elicitation`, preserving
|
||||
its value when present. Form requests require an object-valued `form`
|
||||
declaration. A bare namespace does not imply form support. User verification
|
||||
requests are not implemented.
|
||||
Clients must only advertise features supported by both the client and the
|
||||
connected app-server.
|
||||
|
||||
App-server keeps the complete value under `io.modelcontextprotocol/ui`, rather
|
||||
than deriving a WebView boolean, so clients can advertise additional supported
|
||||
MIME types and future extension settings. The MCP extension profile is fixed
|
||||
@@ -1931,16 +1941,25 @@ Order of messages:
|
||||
|
||||
1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either:
|
||||
- a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }`
|
||||
- an OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }`
|
||||
- an OpenAI form request: `{ "mode": "openaiForm", "message": "...", "requestedSchema": { ... } }`
|
||||
- a legacy OpenAI extended form request: `{ "mode": "openai/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`.
|
||||
|
||||
For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The
|
||||
client owns validation and rendering of supported field types and must return a
|
||||
valid `decline` or `cancel` response when it cannot render a form.
|
||||
MCP `openai/elicitation/create` requests must explicitly specify `mode: "form"`.
|
||||
App-server forwards them as `mode: "openaiForm"`, preserving
|
||||
`requestedSchema` as opaque JSON, including `x-openai-*` annotations. The legacy
|
||||
`openai/form` route remains independent and also preserves its schema.
|
||||
|
||||
The client owns validation and rendering. Graphical clients show an unsupported
|
||||
state for unknown semantic inputs, never a partial form or generic approval,
|
||||
and wait for the user to decline or cancel instead of returning a JSON-RPC
|
||||
error. The TUI automatically declines OpenAI forms, including requests replayed
|
||||
from another client. Capability advertisement describes the session's initial
|
||||
client, not all clients that may later attach.
|
||||
|
||||
For MCP tool approval elicitations, form request `meta` includes
|
||||
`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`,
|
||||
|
||||
@@ -112,6 +112,7 @@ enum ElicitationScenario {
|
||||
StandardForm,
|
||||
LegacySep1034Defaults,
|
||||
OpenAiForm,
|
||||
OpenAiElicitationForm,
|
||||
Strict(StrictReviewScenario),
|
||||
}
|
||||
|
||||
@@ -272,6 +273,39 @@ async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> {
|
||||
fixture.finish(request_id, "accepted monthly-review").await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn mcp_server_openai_elicitation_form_round_trip() -> Result<()> {
|
||||
let mut fixture =
|
||||
ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiElicitationForm).await?;
|
||||
let (request_id, params) = fixture.read_elicitation().await?;
|
||||
assert_eq!(
|
||||
params,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: fixture.thread_id.clone(),
|
||||
turn_id: Some(fixture.turn_id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: McpServerElicitationRequest::OpenAiElicitationForm {
|
||||
meta: Some(json!({ "example/request": "template-picker" })),
|
||||
message: OPENAI_FORM_MESSAGE.to_string(),
|
||||
requested_schema: openai_elicitation_form_schema(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
fixture
|
||||
.mcp
|
||||
.send_response(
|
||||
request_id.clone(),
|
||||
serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({ "template": "monthly-review" })),
|
||||
meta: Some(json!({ "example/response": "selected" })),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
fixture.finish(request_id, "accepted monthly-review").await
|
||||
}
|
||||
|
||||
#[test_case(Review::Approved; "approved")]
|
||||
#[test_case(Review::ApproveForMe; "approve_for_me")]
|
||||
#[test_case(Review::Never; "never")]
|
||||
@@ -666,6 +700,13 @@ impl ElicitationRoundTripFixture {
|
||||
.with_codex_home(codex_home.path())
|
||||
.build()
|
||||
.await?;
|
||||
let mut extensions = HashMap::from([(
|
||||
OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID.to_string(),
|
||||
json!({}),
|
||||
)]);
|
||||
if matches!(scenario, ElicitationScenario::OpenAiElicitationForm) {
|
||||
extensions.insert("openai/elicitation".to_string(), json!({ "form": {} }));
|
||||
}
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_capabilities(
|
||||
@@ -676,11 +717,11 @@ impl ElicitationRoundTripFixture {
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
extensions: Some(HashMap::from([(
|
||||
OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID.to_string(),
|
||||
json!({}),
|
||||
)])),
|
||||
mcp_server_openai_form_elicitation: !matches!(
|
||||
scenario,
|
||||
ElicitationScenario::OpenAiElicitationForm
|
||||
),
|
||||
extensions: Some(extensions),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
@@ -979,6 +1020,18 @@ impl ServerHandler for ElicitationAppsMcpServer {
|
||||
Some(json!({}))
|
||||
);
|
||||
}
|
||||
let extensions = request.capabilities.extensions.as_ref();
|
||||
assert_eq!(
|
||||
extensions
|
||||
.and_then(|extensions| extensions.get("openai/elicitation"))
|
||||
.cloned()
|
||||
.map(Value::Object),
|
||||
matches!(self.scenario, ElicitationScenario::OpenAiElicitationForm)
|
||||
.then(|| json!({ "form": {} })),
|
||||
);
|
||||
if matches!(self.scenario, ElicitationScenario::OpenAiElicitationForm) {
|
||||
assert!(extensions.is_some_and(|extensions| !extensions.contains_key("openai/form")));
|
||||
}
|
||||
context.peer.set_peer_info(request);
|
||||
Ok(self.get_info())
|
||||
}
|
||||
@@ -1203,6 +1256,34 @@ impl ServerHandler for ElicitationAppsMcpServer {
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
ElicitationScenario::OpenAiElicitationForm => {
|
||||
let result = context
|
||||
.peer
|
||||
.send_request(McpServerRequest::CustomRequest(CustomRequest::new(
|
||||
"openai/elicitation/create",
|
||||
Some(json!({
|
||||
"mode": "form",
|
||||
"_meta": { "example/request": "template-picker" },
|
||||
"message": OPENAI_FORM_MESSAGE,
|
||||
"requestedSchema": openai_elicitation_form_schema(),
|
||||
})),
|
||||
)))
|
||||
.await
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(result)
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?,
|
||||
json!({
|
||||
"action": "accept",
|
||||
"content": { "template": "monthly-review" },
|
||||
"_meta": { "example/response": "selected" },
|
||||
}),
|
||||
);
|
||||
Ok(
|
||||
CallToolResult::success(vec![ContentBlock::text("accepted monthly-review")])
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
ElicitationScenario::OpenAiForm => {
|
||||
let result = context
|
||||
.peer
|
||||
@@ -1258,6 +1339,24 @@ impl ServerHandler for ElicitationAppsMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_elicitation_form_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "string",
|
||||
"title": "Template",
|
||||
"oneOf": [{
|
||||
"const": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"x-openai-preview": { "src": IMAGE_DATA_URL },
|
||||
}],
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
})
|
||||
}
|
||||
|
||||
fn sep1034_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID;
|
||||
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
|
||||
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
|
||||
use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID;
|
||||
use serde_json::Map;
|
||||
@@ -23,12 +24,19 @@ pub fn client_mcp_extensions(
|
||||
matches!(
|
||||
id.as_str(),
|
||||
OPENAI_FORM_EXTENSION_ID
|
||||
| OPENAI_ELICITATION_EXTENSION_ID
|
||||
| OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID
|
||||
| MCP_APP_UI_EXTENSION_ID
|
||||
)
|
||||
})
|
||||
.map(|(id, value)| (id.clone(), value.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
if let Some(settings) = selected
|
||||
.get_mut(OPENAI_ELICITATION_EXTENSION_ID)
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
settings.retain(|key, _| key == "form");
|
||||
}
|
||||
if legacy_openai_form_elicitation {
|
||||
selected
|
||||
.entry(OPENAI_FORM_EXTENSION_ID.to_string())
|
||||
|
||||
@@ -14,7 +14,12 @@ fn selects_only_supported_mcp_extensions() {
|
||||
],
|
||||
"futureField": {"preserved": true},
|
||||
});
|
||||
let form = json!({"futureField": {"preserved": true}});
|
||||
let extensions = HashMap::from([
|
||||
(
|
||||
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
|
||||
json!({"form": form, "userVerification": {}, "unsupported": {}}),
|
||||
),
|
||||
(MCP_APP_UI_EXTENSION_ID.to_string(), app_ui.clone()),
|
||||
(OPENAI_FORM_EXTENSION_ID.to_string(), json!({})),
|
||||
(
|
||||
@@ -30,6 +35,10 @@ fn selects_only_supported_mcp_extensions() {
|
||||
/*legacy_openai_form_elicitation*/ false,
|
||||
),
|
||||
ClientMcpExtensions::new(HashMap::from([
|
||||
(
|
||||
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
|
||||
json!({"form": form}),
|
||||
),
|
||||
(MCP_APP_UI_EXTENSION_ID.to_string(), app_ui),
|
||||
(OPENAI_FORM_EXTENSION_ID.to_string(), json!({})),
|
||||
(
|
||||
|
||||
@@ -320,19 +320,24 @@ impl ElicitationRequestManager {
|
||||
|
||||
let should_surface_form_in_full_access = router.full_access_form_input_enabled()
|
||||
&& permission_prompt_is_auto_approved
|
||||
&& matches!(
|
||||
&elicitation,
|
||||
&& !elicitation
|
||||
.meta()
|
||||
.is_some_and(|meta| meta.contains_key(APPROVAL_KIND_KEY))
|
||||
&& match &elicitation {
|
||||
Elicitation::Mcp(
|
||||
rmcp::model::ElicitRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
requested_schema,
|
||||
..
|
||||
}
|
||||
) if !requested_schema.properties.is_empty()
|
||||
&& !meta
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta.contains_key(APPROVAL_KIND_KEY))
|
||||
);
|
||||
},
|
||||
) => !requested_schema.properties.is_empty(),
|
||||
Elicitation::OpenAiElicitationForm {
|
||||
requested_schema, ..
|
||||
} => requested_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|properties| !properties.is_empty()),
|
||||
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
|
||||
};
|
||||
|
||||
if !should_surface_form_in_full_access {
|
||||
if elicitation_is_rejected_by_policy(approval_policy) {
|
||||
@@ -412,6 +417,15 @@ impl ElicitationRequestManager {
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
Elicitation::OpenAiElicitationForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => ElicitationRequest::OpenAiElicitationForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
|
||||
@@ -475,7 +489,9 @@ fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
|
||||
// Auto-accept confirm/approval elicitations without schema requirements.
|
||||
requested_schema.properties.is_empty()
|
||||
}
|
||||
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
|
||||
Elicitation::Mcp(_)
|
||||
| Elicitation::OpenAiForm { .. }
|
||||
| Elicitation::OpenAiElicitationForm { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -942,7 +942,9 @@ fn guardian_elicitation_review_request(
|
||||
"guardian MCP elicitation review does not support this elicitation mode",
|
||||
);
|
||||
}
|
||||
Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested,
|
||||
Elicitation::OpenAiForm { .. } | Elicitation::OpenAiElicitationForm { .. } => {
|
||||
return GuardianElicitationReview::NotRequested;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(meta) = meta.as_ref().map(|meta| &meta.0.0) else {
|
||||
|
||||
@@ -389,6 +389,15 @@ pub enum ElicitationRequest {
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename = "openaiForm")]
|
||||
#[ts(rename = "openaiForm")]
|
||||
OpenAiElicitationForm {
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional, rename = "_meta")]
|
||||
meta: Option<JsonValue>,
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
Url {
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional, rename = "_meta")]
|
||||
|
||||
@@ -23,7 +23,9 @@ pub enum McpServerConnectionStatus {
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Extension ID for OpenAI form elicitation.
|
||||
/// Extension ID for OpenAI elicitation modes.
|
||||
pub const OPENAI_ELICITATION_EXTENSION_ID: &str = "openai/elicitation";
|
||||
/// Extension ID for legacy OpenAI form elicitation.
|
||||
pub const OPENAI_FORM_EXTENSION_ID: &str = "openai/form";
|
||||
/// Extension ID for standard MCP form elicitations that require user-entered input.
|
||||
pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form-input";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
|
||||
|
||||
use rmcp::RoleClient;
|
||||
use rmcp::model::ClientInfo;
|
||||
use rmcp::model::ClientResult;
|
||||
@@ -30,6 +32,14 @@ use crate::rmcp_client::SendElicitation;
|
||||
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
|
||||
const MCP_ELICITATION_CREATE_METHOD: &str = "elicitation/create";
|
||||
const OPENAI_FORM_METHOD: &str = "openai/form";
|
||||
const OPENAI_ELICITATION_METHOD: &str = "openai/elicitation/create";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "mode")]
|
||||
enum OpenAiElicitationRequestParams {
|
||||
#[serde(rename = "form")]
|
||||
Form(OpenAiFormRequestParams),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -44,6 +54,7 @@ struct OpenAiFormRequestParams {
|
||||
pub(crate) struct ElicitationClientService {
|
||||
handler: LoggingClientHandler,
|
||||
supports_openai_form: bool,
|
||||
supports_openai_elicitation_form: bool,
|
||||
send_elicitation: Arc<SendElicitation>,
|
||||
pause_state: ElicitationPauseState,
|
||||
}
|
||||
@@ -59,6 +70,13 @@ impl ElicitationClientService {
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD));
|
||||
let supports_openai_elicitation_form = client_info
|
||||
.capabilities
|
||||
.extensions
|
||||
.as_ref()
|
||||
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID))
|
||||
.and_then(|settings| settings.get("form"))
|
||||
.is_some_and(Value::is_object);
|
||||
let send_elicitation = Arc::new(send_elicitation);
|
||||
Self {
|
||||
handler: LoggingClientHandler::new(
|
||||
@@ -66,6 +84,7 @@ impl ElicitationClientService {
|
||||
clone_send_elicitation(Arc::clone(&send_elicitation)),
|
||||
),
|
||||
supports_openai_form,
|
||||
supports_openai_elicitation_form,
|
||||
send_elicitation,
|
||||
pause_state,
|
||||
}
|
||||
@@ -144,6 +163,33 @@ impl Service<RoleClient> for ElicitationClientService {
|
||||
response,
|
||||
)?))
|
||||
}
|
||||
ServerRequest::CustomRequest(request)
|
||||
if request.method == OPENAI_ELICITATION_METHOD
|
||||
&& self.supports_openai_elicitation_form =>
|
||||
{
|
||||
let params = request
|
||||
.params_as::<OpenAiElicitationRequestParams>()
|
||||
.map_err(|err| {
|
||||
rmcp::ErrorData::invalid_params(err.to_string(), /*data*/ None)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
rmcp::ErrorData::invalid_params("missing params", /*data*/ None)
|
||||
})?;
|
||||
let OpenAiElicitationRequestParams::Form(params) = params;
|
||||
let response = self
|
||||
.create_elicitation(
|
||||
Elicitation::OpenAiElicitationForm {
|
||||
meta: params.meta,
|
||||
message: params.message,
|
||||
requested_schema: params.requested_schema,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
Ok(ClientResult::CustomResult(elicitation_response_result(
|
||||
response,
|
||||
)?))
|
||||
}
|
||||
request => {
|
||||
<LoggingClientHandler as Service<RoleClient>>::handle_request(
|
||||
&self.handler,
|
||||
@@ -209,7 +255,7 @@ fn restore_context_meta(
|
||||
.meta_mut()
|
||||
.get_or_insert_with(RequestMetaObject::new)
|
||||
.extend(context_meta),
|
||||
Elicitation::OpenAiForm { meta, .. } => {
|
||||
Elicitation::OpenAiForm { meta, .. } | Elicitation::OpenAiElicitationForm { meta, .. } => {
|
||||
let meta = meta
|
||||
.get_or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut();
|
||||
|
||||
@@ -302,13 +302,20 @@ pub enum Elicitation {
|
||||
message: String,
|
||||
requested_schema: serde_json::Value,
|
||||
},
|
||||
OpenAiElicitationForm {
|
||||
meta: Option<serde_json::Value>,
|
||||
message: String,
|
||||
requested_schema: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl Elicitation {
|
||||
pub fn meta(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
match self {
|
||||
Self::Mcp(request) => request.meta().map(|meta| &meta.0.0),
|
||||
Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object),
|
||||
Self::OpenAiForm { meta, .. } | Self::OpenAiElicitationForm { meta, .. } => {
|
||||
meta.as_ref().and_then(serde_json::Value::as_object)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@ impl App {
|
||||
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm {
|
||||
..
|
||||
}
|
||||
| codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm { .. }
|
||||
| codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
thread_id,
|
||||
@@ -1673,7 +1674,10 @@ impl App {
|
||||
|
||||
match ¶ms.request {
|
||||
codex_app_server_protocol::McpServerElicitationRequest::Form { .. } => true,
|
||||
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } => false,
|
||||
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. }
|
||||
| codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm {
|
||||
..
|
||||
} => false,
|
||||
request @ codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
|
||||
let thread_id = ThreadId::from_string(¶ms.thread_id)
|
||||
.unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default());
|
||||
|
||||
@@ -377,6 +377,7 @@ impl ChatWidget {
|
||||
.push_approval_request(request, &self.config.features);
|
||||
}
|
||||
McpServerElicitationRequest::OpenAiForm { .. }
|
||||
| McpServerElicitationRequest::OpenAiElicitationForm { .. }
|
||||
| McpServerElicitationRequest::Url { .. } => {
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
thread_id,
|
||||
|
||||
Reference in New Issue
Block a user