mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Support openai/form extended form elicitations (#27500)
# Summary Allow App Server clients to opt into `openai/form` MCP elicitations.
This commit is contained in:
committed by
GitHub
Unverified
parent
32a696dbac
commit
21a599fa56
@@ -774,6 +774,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
},
|
||||
product_client_id: DEFAULT_ORIGINATOR.to_string(),
|
||||
@@ -1669,6 +1670,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
},
|
||||
product_client_id: DEFAULT_ORIGINATOR.to_string(),
|
||||
@@ -1818,6 +1820,7 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
},
|
||||
product_client_id: DEFAULT_ORIGINATOR.to_string(),
|
||||
@@ -1946,6 +1949,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
},
|
||||
product_client_id: DEFAULT_ORIGINATOR.to_string(),
|
||||
|
||||
@@ -350,6 +350,8 @@ pub struct InProcessClientStartArgs {
|
||||
pub client_version: String,
|
||||
/// Whether experimental APIs are requested at initialize time.
|
||||
pub experimental_api: bool,
|
||||
/// Whether MCP servers may send `openai/form` elicitation requests.
|
||||
pub mcp_server_openai_form_elicitation: bool,
|
||||
/// Notification methods this client opts out of receiving.
|
||||
pub opt_out_notification_methods: Vec<String>,
|
||||
/// Queue capacity for command/event channels (clamped to at least 1).
|
||||
@@ -374,6 +376,7 @@ impl InProcessClientStartArgs {
|
||||
} else {
|
||||
Some(self.opt_out_notification_methods.clone())
|
||||
},
|
||||
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
|
||||
};
|
||||
|
||||
InitializeParams {
|
||||
@@ -1044,6 +1047,7 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity,
|
||||
})
|
||||
@@ -1237,11 +1241,25 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: 8,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_initialize_params_forward_openai_form_capability() {
|
||||
let mut args = test_remote_connect_args("ws://localhost/rpc".to_string());
|
||||
args.mcp_server_openai_form_elicitation = true;
|
||||
|
||||
assert!(
|
||||
args.initialize_params()
|
||||
.capabilities
|
||||
.expect("initialize capabilities")
|
||||
.mcp_server_openai_form_elicitation
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_request_roundtrip_works() {
|
||||
let client = start_test_client(SessionSource::Exec).await;
|
||||
@@ -1512,6 +1530,7 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: 8,
|
||||
})
|
||||
@@ -1600,6 +1619,7 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: 8,
|
||||
})
|
||||
@@ -1619,6 +1639,7 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: 8,
|
||||
})
|
||||
@@ -2189,7 +2210,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_start_args_forward_environment_manager() {
|
||||
async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() {
|
||||
let config = Arc::new(build_test_config().await);
|
||||
let environment_manager = Arc::new(
|
||||
EnvironmentManager::create_for_tests(
|
||||
@@ -2222,12 +2243,20 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
}
|
||||
.into_runtime_start_args();
|
||||
|
||||
assert_eq!(runtime_args.config, config);
|
||||
assert!(
|
||||
runtime_args
|
||||
.initialize
|
||||
.capabilities
|
||||
.expect("initialize capabilities")
|
||||
.mcp_server_openai_form_elicitation
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
&runtime_args.environment_manager,
|
||||
&environment_manager
|
||||
@@ -2263,6 +2292,7 @@ mod tests {
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@ pub struct RemoteAppServerConnectArgs {
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
pub experimental_api: bool,
|
||||
pub mcp_server_openai_form_elicitation: bool,
|
||||
pub opt_out_notification_methods: Vec<String>,
|
||||
pub channel_capacity: usize,
|
||||
}
|
||||
impl RemoteAppServerConnectArgs {
|
||||
fn initialize_params(&self) -> InitializeParams {
|
||||
pub(crate) fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
@@ -99,6 +100,7 @@ impl RemoteAppServerConnectArgs {
|
||||
} else {
|
||||
Some(self.opt_out_notification_methods.clone())
|
||||
},
|
||||
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
|
||||
};
|
||||
|
||||
InitializeParams {
|
||||
|
||||
@@ -1272,6 +1272,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcpServerOpenaiFormElicitation": {
|
||||
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"optOutNotificationMethods": {
|
||||
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
|
||||
"items": {
|
||||
|
||||
+21
@@ -557,6 +557,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openai/form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
@@ -1390,6 +1390,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openai/form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
+25
@@ -2900,6 +2900,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcpServerOpenaiFormElicitation": {
|
||||
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"optOutNotificationMethods": {
|
||||
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
|
||||
"items": {
|
||||
@@ -3655,6 +3659,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openai/form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
+4
@@ -7440,6 +7440,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcpServerOpenaiFormElicitation": {
|
||||
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"optOutNotificationMethods": {
|
||||
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
|
||||
"items": {
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcpServerOpenaiFormElicitation": {
|
||||
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"optOutNotificationMethods": {
|
||||
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
|
||||
"items": {
|
||||
|
||||
@@ -14,6 +14,10 @@ experimentalApi: boolean,
|
||||
* Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
|
||||
*/
|
||||
requestAttestation: boolean,
|
||||
/**
|
||||
* Allow downstream MCP servers to request OpenAI extended form elicitations.
|
||||
*/
|
||||
mcpServerOpenaiFormElicitation?: boolean,
|
||||
/**
|
||||
* Exact notification method names that should be suppressed for this
|
||||
* connection (for example `thread/started`).
|
||||
|
||||
+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": "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": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
|
||||
|
||||
@@ -2150,7 +2150,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
||||
fn serialize_initialize_capabilities() -> Result<()> {
|
||||
let request = ClientRequest::Initialize {
|
||||
request_id: RequestId::Integer(42),
|
||||
params: v1::InitializeParams {
|
||||
@@ -2162,6 +2162,7 @@ mod tests {
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
@@ -2183,6 +2184,7 @@ mod tests {
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"requestAttestation": true,
|
||||
"mcpServerOpenaiFormElicitation": true,
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -2196,7 +2198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
||||
fn deserialize_initialize_capabilities() -> Result<()> {
|
||||
let request: ClientRequest = serde_json::from_value(json!({
|
||||
"method": "initialize",
|
||||
"id": 42,
|
||||
@@ -2209,6 +2211,7 @@ mod tests {
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"requestAttestation": true,
|
||||
"mcpServerOpenaiFormElicitation": true,
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -2230,6 +2233,7 @@ mod tests {
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct InitializeCapabilities {
|
||||
/// Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
|
||||
#[serde(default)]
|
||||
pub request_attestation: bool,
|
||||
/// Allow downstream MCP servers to request OpenAI extended form elicitations.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub mcp_server_openai_form_elicitation: bool,
|
||||
/// Exact notification method names that should be suppressed for this
|
||||
/// connection (for example `thread/started`).
|
||||
#[ts(optional = nullable)]
|
||||
|
||||
@@ -632,6 +632,15 @@ pub enum McpServerElicitationRequest {
|
||||
message: String,
|
||||
requested_schema: McpElicitationSchema,
|
||||
},
|
||||
#[serde(rename = "openai/form", rename_all = "camelCase")]
|
||||
#[ts(rename = "openai/form", rename_all = "camelCase")]
|
||||
OpenAiForm {
|
||||
#[serde(rename = "_meta")]
|
||||
#[ts(rename = "_meta")]
|
||||
meta: Option<JsonValue>,
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Url {
|
||||
@@ -658,6 +667,15 @@ impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
|
||||
message,
|
||||
requested_schema: serde_json::from_value(requested_schema)?,
|
||||
}),
|
||||
CoreElicitationRequest::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => Ok(Self::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
}),
|
||||
CoreElicitationRequest::Url {
|
||||
meta,
|
||||
message,
|
||||
|
||||
@@ -1901,6 +1901,40 @@ fn mcp_server_elicitation_request_from_core_form_request() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_elicitation_request_from_core_openai_form_request() {
|
||||
let requested_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"title": "Template",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=",
|
||||
}],
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
});
|
||||
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm {
|
||||
meta: None,
|
||||
message: "Choose a report".to_string(),
|
||||
requested_schema: requested_schema.clone(),
|
||||
})
|
||||
.expect("OpenAI form request should convert");
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
McpServerElicitationRequest::OpenAiForm {
|
||||
meta: None,
|
||||
message: "Choose a report".to_string(),
|
||||
requested_schema,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() {
|
||||
let schema: McpElicitationSchema = serde_json::from_value(json!({
|
||||
|
||||
@@ -1668,6 +1668,7 @@ impl CodexClient {
|
||||
.map(|method| (*method).to_string())
|
||||
.collect(),
|
||||
),
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,6 +86,13 @@ Clients must send a single `initialize` request per transport connection before
|
||||
|
||||
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
|
||||
|
||||
Clients that handle OpenAI extended MCP forms, including a fallback for
|
||||
unsupported field types, set
|
||||
`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`.
|
||||
App-server then advertises the downstream `openai/form` MCP extension for
|
||||
threads started, resumed, or forked by that connection. Clients that cannot
|
||||
handle the request envelope omit the field or set it to `false`.
|
||||
|
||||
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
|
||||
|
||||
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
|
||||
@@ -1470,12 +1477,17 @@ 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": { ... } }`
|
||||
- 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.
|
||||
|
||||
For MCP tool approval elicitations, form request `meta` includes
|
||||
`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`,
|
||||
`persist: "always"`, or `persist: ["session", "always"]` to advertise whether
|
||||
|
||||
@@ -223,6 +223,7 @@ pub(crate) struct InitializedConnectionSessionState {
|
||||
pub(crate) app_server_client_name: String,
|
||||
pub(crate) client_version: String,
|
||||
pub(crate) request_attestation: bool,
|
||||
pub(crate) supports_openai_form_elicitation: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectionSessionState {
|
||||
@@ -274,6 +275,12 @@ impl ConnectionSessionState {
|
||||
.is_some_and(|session| session.request_attestation)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_openai_form_elicitation(&self) -> bool {
|
||||
self.initialized
|
||||
.get()
|
||||
.is_some_and(|session| session.supports_openai_form_elicitation)
|
||||
}
|
||||
|
||||
pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> {
|
||||
self.initialized.set(session).map_err(|_| ())
|
||||
}
|
||||
@@ -884,6 +891,7 @@ impl MessageProcessor {
|
||||
let serialization_scope = codex_request.serialization_scope();
|
||||
let app_server_client_name = session.app_server_client_name().map(str::to_string);
|
||||
let client_version = session.client_version().map(str::to_string);
|
||||
let supports_openai_form_elicitation = session.supports_openai_form_elicitation();
|
||||
let error_request_id = connection_request_id.clone();
|
||||
let rpc_gate = Arc::clone(&session.rpc_gate);
|
||||
let processor = Arc::clone(self);
|
||||
@@ -899,6 +907,7 @@ impl MessageProcessor {
|
||||
request_context,
|
||||
app_server_client_name,
|
||||
client_version,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
@@ -928,6 +937,7 @@ impl MessageProcessor {
|
||||
request_context: RequestContext,
|
||||
app_server_client_name: Option<String>,
|
||||
client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let connection_id = connection_request_id.connection_id;
|
||||
let request_id = ConnectionRequestId {
|
||||
@@ -1080,6 +1090,7 @@ impl MessageProcessor {
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
client_version.clone(),
|
||||
supports_openai_form_elicitation,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
@@ -1096,6 +1107,8 @@ impl MessageProcessor {
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
client_version.clone(),
|
||||
/*supports_openai_form_elicitation*/
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1106,6 +1119,8 @@ impl MessageProcessor {
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
client_version.clone(),
|
||||
/*supports_openai_form_elicitation*/
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1305,6 +1320,8 @@ impl MessageProcessor {
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
client_version.clone(),
|
||||
/*supports_openai_form_elicitation*/
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -67,17 +67,13 @@ impl InitializeRequestProcessor {
|
||||
// experimental API). Proposed direction is instance-global first-write-wins
|
||||
// with initialize-time mismatch rejection.
|
||||
let analytics_initialize_params = params.clone();
|
||||
let (experimental_api_enabled, request_attestation, opt_out_notification_methods) =
|
||||
match params.capabilities {
|
||||
Some(capabilities) => (
|
||||
capabilities.experimental_api,
|
||||
capabilities.request_attestation,
|
||||
capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
None => (false, false, Vec::new()),
|
||||
};
|
||||
let capabilities = params.capabilities.unwrap_or_default();
|
||||
let experimental_api_enabled = capabilities.experimental_api;
|
||||
let request_attestation = capabilities.request_attestation;
|
||||
let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation;
|
||||
let opt_out_notification_methods = capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default();
|
||||
let ClientInfo {
|
||||
name,
|
||||
title: _title,
|
||||
@@ -101,6 +97,7 @@ impl InitializeRequestProcessor {
|
||||
app_server_client_name: name.clone(),
|
||||
client_version: version,
|
||||
request_attestation,
|
||||
supports_openai_form_elicitation,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -417,6 +417,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
request_context: RequestContext,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_start_inner(
|
||||
@@ -424,6 +425,7 @@ impl ThreadRequestProcessor {
|
||||
params,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
supports_openai_form_elicitation,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
@@ -446,12 +448,14 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadResumeParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_resume_inner(
|
||||
request_id,
|
||||
params,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
.map(|()| None)
|
||||
@@ -463,12 +467,14 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadForkParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_fork_inner(
|
||||
request_id,
|
||||
params,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
.map(|()| None)
|
||||
@@ -874,6 +880,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
request_context: RequestContext,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let ThreadStartParams {
|
||||
@@ -945,6 +952,7 @@ impl ThreadRequestProcessor {
|
||||
request_id,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
supports_openai_form_elicitation,
|
||||
config,
|
||||
typesafe_overrides,
|
||||
dynamic_tools,
|
||||
@@ -1018,6 +1026,7 @@ impl ThreadRequestProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
config_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
dynamic_tools: Option<Vec<DynamicToolSpec>>,
|
||||
@@ -1146,6 +1155,7 @@ impl ThreadRequestProcessor {
|
||||
parent_trace: request_trace,
|
||||
environments,
|
||||
thread_extension_init,
|
||||
supports_openai_form_elicitation,
|
||||
})
|
||||
.instrument(tracing::info_span!(
|
||||
"app_server.thread_start.create_thread",
|
||||
@@ -2506,6 +2516,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadResumeParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& self
|
||||
@@ -2650,6 +2661,7 @@ impl ThreadRequestProcessor {
|
||||
thread_history,
|
||||
self.auth_manager.clone(),
|
||||
self.request_trace_context(&request_id).await,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3267,6 +3279,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadForkParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let ThreadForkParams {
|
||||
thread_id,
|
||||
@@ -3376,6 +3389,7 @@ impl ThreadRequestProcessor {
|
||||
}),
|
||||
thread_source.map(Into::into),
|
||||
self.request_trace_context(&request_id).await,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
|
||||
@@ -101,12 +101,14 @@ impl TurnRequestProcessor {
|
||||
params: TurnStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.turn_start_inner(
|
||||
request_id,
|
||||
params,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
/*supports_openai_form_elicitation*/ supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
@@ -384,6 +386,7 @@ impl TurnRequestProcessor {
|
||||
params: TurnStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<TurnStartResponse, JSONRPCErrorError> {
|
||||
let (thread_id, thread) =
|
||||
self.load_thread(¶ms.thread_id)
|
||||
@@ -410,6 +413,14 @@ impl TurnRequestProcessor {
|
||||
.inspect_err(|error| {
|
||||
self.track_error_response(&request_id, error, /*error_type*/ None);
|
||||
})?;
|
||||
thread
|
||||
.set_openai_form_elicitation_support(supports_openai_form_elicitation)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to update OpenAI form elicitation support: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let environment_selections =
|
||||
resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?;
|
||||
@@ -1164,6 +1175,7 @@ impl TurnRequestProcessor {
|
||||
}),
|
||||
/*thread_source*/ None,
|
||||
self.request_trace_context(request_id).await,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -706,7 +706,7 @@ pub(super) async fn send_request(
|
||||
send_jsonrpc(stream, message).await
|
||||
}
|
||||
|
||||
async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> {
|
||||
pub(super) async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> {
|
||||
let payload = serde_json::to_string(&message)?;
|
||||
stream
|
||||
.send(WebSocketMessage::Text(payload.into()))
|
||||
|
||||
@@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -116,6 +118,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -150,6 +153,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -184,6 +188,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -234,6 +239,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -272,6 +278,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -309,6 +316,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,9 @@ use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
@@ -24,6 +27,7 @@ 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::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
@@ -34,6 +38,7 @@ use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::BooleanSchema;
|
||||
@@ -41,14 +46,18 @@ use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::model::CallToolResult;
|
||||
use rmcp::model::Content;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::CustomRequest;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationSchema;
|
||||
use rmcp::model::InitializeRequestParams;
|
||||
use rmcp::model::InitializeResult;
|
||||
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::ServerRequest as McpServerRequest;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use rmcp::service::RequestContext;
|
||||
@@ -63,6 +72,15 @@ use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::connection_handling_websocket::WsClient;
|
||||
use super::connection_handling_websocket::connect_websocket;
|
||||
use super::connection_handling_websocket::read_jsonrpc_message;
|
||||
use super::connection_handling_websocket::read_notification_for_method;
|
||||
use super::connection_handling_websocket::read_response_for_id;
|
||||
use super::connection_handling_websocket::send_jsonrpc;
|
||||
use super::connection_handling_websocket::send_request;
|
||||
use super::connection_handling_websocket::spawn_websocket_server;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
@@ -71,9 +89,278 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action";
|
||||
const TOOL_NAME: &str = "calendar_confirm_action";
|
||||
const TOOL_CALL_ID: &str = "call-calendar-confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
const OPENAI_FORM_MESSAGE: &str = "Select a template";
|
||||
const IMAGE_DATA_URL: &str =
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ElicitationScenario {
|
||||
StandardForm,
|
||||
OpenAiForm,
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn mcp_server_elicitation_round_trip() -> Result<()> {
|
||||
async fn mcp_server_form_elicitation_round_trip() -> Result<()> {
|
||||
let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::StandardForm).await?;
|
||||
let (request_id, params) = fixture.read_elicitation().await?;
|
||||
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
|
||||
ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
)?)?;
|
||||
assert_eq!(
|
||||
params,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: fixture.thread_id.clone(),
|
||||
turn_id: Some(fixture.turn_id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: None,
|
||||
message: ELICITATION_MESSAGE.to_string(),
|
||||
requested_schema,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
fixture
|
||||
.accept(request_id.clone(), json!({ "confirmed": true }))
|
||||
.await?;
|
||||
fixture.finish(request_id, "accepted").await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> {
|
||||
let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).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::OpenAiForm {
|
||||
meta: None,
|
||||
message: OPENAI_FORM_MESSAGE.to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"title": "Template",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": IMAGE_DATA_URL,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
fixture
|
||||
.accept(request_id.clone(), json!({ "template": "monthly-review" }))
|
||||
.await?;
|
||||
fixture.finish(request_id, "accepted monthly-review").await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn openai_form_capability_follows_the_turn_starting_connection() -> Result<()> {
|
||||
let (responses_server, response_mock, apps_server_url, apps_server_handle) =
|
||||
start_elicitation_services(ElicitationScenario::OpenAiForm).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 process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
|
||||
let mut supported_client = connect_websocket(bind_addr).await?;
|
||||
initialize_websocket_client(
|
||||
&mut supported_client,
|
||||
/*id*/ 1,
|
||||
"supported-client",
|
||||
/*supports_openai_form_elicitation*/ true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
send_request(
|
||||
&mut supported_client,
|
||||
"thread/start",
|
||||
/*id*/ 2,
|
||||
Some(serde_json::to_value(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } =
|
||||
to_response(read_response_for_id(&mut supported_client, /*id*/ 2).await?)?;
|
||||
|
||||
send_request(
|
||||
&mut supported_client,
|
||||
"turn/start",
|
||||
/*id*/ 3,
|
||||
Some(serde_json::to_value(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 _: TurnStartResponse =
|
||||
to_response(read_response_for_id(&mut supported_client, /*id*/ 3).await?)?;
|
||||
let _: TurnCompletedNotification = serde_json::from_value(
|
||||
read_notification_for_method(&mut supported_client, "turn/completed")
|
||||
.await?
|
||||
.params
|
||||
.expect("turn/completed params"),
|
||||
)?;
|
||||
|
||||
let mut unsupported_client = connect_websocket(bind_addr).await?;
|
||||
initialize_websocket_client(
|
||||
&mut unsupported_client,
|
||||
/*id*/ 4,
|
||||
"unsupported-client",
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await?;
|
||||
send_request(
|
||||
&mut unsupported_client,
|
||||
"thread/resume",
|
||||
/*id*/ 5,
|
||||
Some(serde_json::to_value(ThreadResumeParams {
|
||||
thread_id: thread.id.clone(),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let _ = read_response_for_id(&mut unsupported_client, /*id*/ 5).await?;
|
||||
|
||||
send_request(
|
||||
&mut supported_client,
|
||||
"turn/start",
|
||||
/*id*/ 6,
|
||||
Some(serde_json::to_value(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec to run the calendar tool.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let TurnStartResponse { turn } =
|
||||
to_response(read_response_for_id(&mut supported_client, /*id*/ 6).await?)?;
|
||||
|
||||
let (request_id, params) = loop {
|
||||
let JSONRPCMessage::Request(request) = read_jsonrpc_message(&mut supported_client).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let request: ServerRequest = serde_json::from_value(serde_json::to_value(request)?)?;
|
||||
let ServerRequest::McpServerElicitationRequest { request_id, params } = request else {
|
||||
continue;
|
||||
};
|
||||
break (request_id, params);
|
||||
};
|
||||
assert_eq!(
|
||||
params.request,
|
||||
McpServerElicitationRequest::OpenAiForm {
|
||||
meta: None,
|
||||
message: OPENAI_FORM_MESSAGE.to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"title": "Template",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": IMAGE_DATA_URL,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
}),
|
||||
}
|
||||
);
|
||||
send_jsonrpc(
|
||||
&mut supported_client,
|
||||
JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request_id,
|
||||
result: serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({ "template": "monthly-review" })),
|
||||
meta: None,
|
||||
})?,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
read_notification_for_method(&mut supported_client, "turn/completed")
|
||||
.await?
|
||||
.params
|
||||
.expect("turn/completed params"),
|
||||
)?;
|
||||
assert_eq!(completed.thread_id, thread.id);
|
||||
assert_eq!(completed.turn.id, turn.id);
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
assert_eq!(response_mock.requests().len(), 3);
|
||||
|
||||
process.kill().await?;
|
||||
apps_server_handle.abort();
|
||||
let _ = apps_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize_websocket_client(
|
||||
client: &mut WsClient,
|
||||
id: i64,
|
||||
name: &str,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<()> {
|
||||
send_request(
|
||||
client,
|
||||
"initialize",
|
||||
id,
|
||||
Some(serde_json::to_value(InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: name.to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: supports_openai_form_elicitation,
|
||||
..Default::default()
|
||||
}),
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let _ = read_response_for_id(client, id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_elicitation_services(
|
||||
scenario: ElicitationScenario,
|
||||
) -> Result<(wiremock::MockServer, ResponseMock, String, JoinHandle<()>)> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let tool_call_arguments = serde_json::to_string(&json!({}))?;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
@@ -102,201 +389,223 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?;
|
||||
Ok((
|
||||
responses_server,
|
||||
response_mock,
|
||||
apps_server_url,
|
||||
apps_server_handle,
|
||||
))
|
||||
}
|
||||
|
||||
let (apps_server_url, apps_server_handle) = start_apps_server().await?;
|
||||
struct ElicitationRoundTripFixture {
|
||||
mcp: TestAppServer,
|
||||
response_mock: ResponseMock,
|
||||
_responses_server: wiremock::MockServer,
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
apps_server_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
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,
|
||||
)?;
|
||||
impl ElicitationRoundTripFixture {
|
||||
async fn start(scenario: ElicitationScenario) -> Result<Self> {
|
||||
let (responses_server, response_mock, apps_server_url, apps_server_handle) =
|
||||
start_elicitation_services(scenario).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 = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
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(),
|
||||
client_user_message_id: None,
|
||||
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(),
|
||||
client_user_message_id: None,
|
||||
input: vec 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)?;
|
||||
|
||||
Ok(Self {
|
||||
mcp,
|
||||
response_mock,
|
||||
_responses_server: responses_server,
|
||||
thread_id: thread.id,
|
||||
turn_id: turn.id,
|
||||
apps_server_handle,
|
||||
})
|
||||
.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(),
|
||||
client_user_message_id: None,
|
||||
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(),
|
||||
client_user_message_id: None,
|
||||
input: vec 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: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
|
||||
ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
)?)?;
|
||||
|
||||
assert_eq!(
|
||||
params,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: thread.id.clone(),
|
||||
turn_id: Some(turn.id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: None,
|
||||
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,
|
||||
})),
|
||||
meta: None,
|
||||
})?,
|
||||
)
|
||||
.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");
|
||||
let payload = assert_regex_match(
|
||||
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
|
||||
output,
|
||||
)
|
||||
.get(1)
|
||||
.expect("wall-time wrapped output should include payload")
|
||||
.as_str();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(payload)?,
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": "accepted"
|
||||
}])
|
||||
);
|
||||
async fn read_elicitation(&mut self) -> Result<(RequestId, McpServerElicitationRequestParams)> {
|
||||
let request = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
self.mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::McpServerElicitationRequest { request_id, params } = request else {
|
||||
panic!("expected McpServerElicitationRequest request, got: {request:?}");
|
||||
};
|
||||
Ok((request_id, params))
|
||||
}
|
||||
|
||||
apps_server_handle.abort();
|
||||
let _ = apps_server_handle.await;
|
||||
Ok(())
|
||||
async fn accept(&mut self, request_id: RequestId, content: Value) -> Result<()> {
|
||||
self.mcp
|
||||
.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(content),
|
||||
meta: None,
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn finish(mut self, request_id: RequestId, expected_text: &str) -> Result<()> {
|
||||
let mut resolved = false;
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, self.mcp.read_next_message()).await??;
|
||||
let JSONRPCMessage::Notification(notification) = message else {
|
||||
continue;
|
||||
};
|
||||
match notification.method.as_str() {
|
||||
"serverRequest/resolved" => {
|
||||
let notification: ServerRequestResolvedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("serverRequest/resolved params"),
|
||||
)?;
|
||||
assert_eq!(notification.thread_id, self.thread_id);
|
||||
assert_eq!(notification.request_id, request_id);
|
||||
resolved = true;
|
||||
}
|
||||
"turn/completed" => {
|
||||
let notification: TurnCompletedNotification = serde_json::from_value(
|
||||
notification.params.clone().expect("turn/completed params"),
|
||||
)?;
|
||||
assert!(
|
||||
resolved,
|
||||
"server request should resolve before turn completion"
|
||||
);
|
||||
assert_eq!(notification.thread_id, self.thread_id);
|
||||
assert_eq!(notification.turn.id, self.turn_id);
|
||||
assert_eq!(notification.turn.status, TurnStatus::Completed);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let requests = self.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");
|
||||
let payload = assert_regex_match(
|
||||
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
|
||||
output,
|
||||
)
|
||||
.get(1)
|
||||
.expect("wall-time wrapped output should include payload")
|
||||
.as_str();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(payload)?,
|
||||
json!([{ "type": "text", "text": expected_text }])
|
||||
);
|
||||
|
||||
self.apps_server_handle.abort();
|
||||
let _ = self.apps_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -305,10 +614,33 @@ struct AppsServerState {
|
||||
expected_account_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ElicitationAppsMcpServer;
|
||||
#[derive(Clone)]
|
||||
struct ElicitationAppsMcpServer {
|
||||
scenario: ElicitationScenario,
|
||||
}
|
||||
|
||||
impl ServerHandler for ElicitationAppsMcpServer {
|
||||
async fn initialize(
|
||||
&self,
|
||||
request: InitializeRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, rmcp::ErrorData> {
|
||||
if matches!(self.scenario, ElicitationScenario::OpenAiForm) {
|
||||
assert_eq!(
|
||||
request
|
||||
.capabilities
|
||||
.extensions
|
||||
.as_ref()
|
||||
.and_then(|extensions| extensions.get("openai/form"))
|
||||
.cloned()
|
||||
.map(Value::Object),
|
||||
Some(json!({}))
|
||||
);
|
||||
}
|
||||
context.peer.set_peer_info(request);
|
||||
Ok(self.get_info())
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18)
|
||||
@@ -351,40 +683,91 @@ impl ServerHandler for ElicitationAppsMcpServer {
|
||||
_request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
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 => {
|
||||
match self.scenario {
|
||||
ElicitationScenario::StandardForm => {
|
||||
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))?;
|
||||
assert_eq!(
|
||||
result.content,
|
||||
Some(json!({
|
||||
"confirmed": true,
|
||||
}))
|
||||
);
|
||||
"accepted"
|
||||
let output = match result.action {
|
||||
ElicitationAction::Accept => "accepted",
|
||||
ElicitationAction::Decline => "declined",
|
||||
ElicitationAction::Cancel => "cancelled",
|
||||
};
|
||||
Ok(CallToolResult::success(vec![Content::text(output)]))
|
||||
}
|
||||
ElicitationAction::Decline => "declined",
|
||||
ElicitationAction::Cancel => "cancelled",
|
||||
};
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(output)]))
|
||||
ElicitationScenario::OpenAiForm => {
|
||||
let result = context
|
||||
.peer
|
||||
.send_request(McpServerRequest::CustomRequest(CustomRequest::new(
|
||||
"openai/form",
|
||||
Some(json!({
|
||||
"message": OPENAI_FORM_MESSAGE,
|
||||
"requestedSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"title": "Template",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": IMAGE_DATA_URL,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
},
|
||||
})),
|
||||
)))
|
||||
.await
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
let result = match result {
|
||||
rmcp::model::ClientResult::CustomResult(result) => result.0,
|
||||
rmcp::model::ClientResult::CreateElicitationResult(result) => {
|
||||
serde_json::to_value(result)
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?
|
||||
}
|
||||
result => {
|
||||
return Err(rmcp::ErrorData::internal_error(
|
||||
format!("unexpected OpenAI form response: {result:?}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"action": "accept",
|
||||
"content": {
|
||||
"template": "monthly-review",
|
||||
},
|
||||
})
|
||||
);
|
||||
Ok(CallToolResult::success(vec![Content::text(
|
||||
"accepted monthly-review",
|
||||
)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
|
||||
async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
expected_bearer: "Bearer chatgpt-token".to_string(),
|
||||
expected_account_id: "account-123".to_string(),
|
||||
@@ -394,7 +777,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
|
||||
let addr = listener.local_addr()?;
|
||||
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || Ok(ElicitationAppsMcpServer),
|
||||
move || Ok(ElicitationAppsMcpServer { scenario }),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
@@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,6 +133,7 @@ impl McpConnectionManager {
|
||||
host_owned_codex_apps_enabled: bool,
|
||||
prefix_mcp_tool_names: bool,
|
||||
client_elicitation_capability: ElicitationCapability,
|
||||
supports_openai_form_elicitation: bool,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
auth: Option<&CodexAuth>,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
@@ -209,6 +210,7 @@ impl McpConnectionManager {
|
||||
runtime_context.clone(),
|
||||
runtime_auth_provider,
|
||||
client_elicitation_capability.clone(),
|
||||
supports_openai_form_elicitation,
|
||||
);
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
|
||||
@@ -251,13 +251,15 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
|
||||
|
||||
let response = sender(
|
||||
NumberOrString::Number(1),
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: "Confirm?".to_string(),
|
||||
requested_schema: rmcp::model::ElicitationSchema::builder()
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
codex_rmcp_client::Elicitation::Mcp(
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: "Confirm?".to_string(),
|
||||
requested_schema: rmcp::model::ElicitationSchema::builder()
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("elicitation should auto accept");
|
||||
@@ -284,17 +286,19 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
|
||||
|
||||
let response = sender(
|
||||
NumberOrString::Number(1),
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: "What should I say?".to_string(),
|
||||
requested_schema: rmcp::model::ElicitationSchema::builder()
|
||||
.required_property(
|
||||
"message",
|
||||
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
|
||||
)
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
codex_rmcp_client::Elicitation::Mcp(
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: "What should I say?".to_string(),
|
||||
requested_schema: rmcp::model::ElicitationSchema::builder()
|
||||
.required_property(
|
||||
"message",
|
||||
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
|
||||
)
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("elicitation should auto decline");
|
||||
@@ -1265,6 +1269,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
/*host_owned_codex_apps_enabled*/ false,
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
/*auth*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
|
||||
@@ -22,11 +22,11 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_rmcp_client::Elicitation;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::SendElicitation;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::RequestId;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -36,7 +36,7 @@ use tokio::sync::oneshot;
|
||||
pub struct ElicitationReviewRequest {
|
||||
pub server_name: String,
|
||||
pub request_id: RequestId,
|
||||
pub elicitation: CreateElicitationRequestParams,
|
||||
pub elicitation: Elicitation,
|
||||
}
|
||||
|
||||
pub trait ElicitationReviewer: Send + Sync {
|
||||
@@ -172,11 +172,13 @@ impl ElicitationRequestManager {
|
||||
}
|
||||
|
||||
let request = match elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => ElicitationRequest::Form {
|
||||
Elicitation::Mcp(
|
||||
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
) => ElicitationRequest::Form {
|
||||
meta: meta
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
@@ -185,12 +187,14 @@ impl ElicitationRequestManager {
|
||||
requested_schema: serde_json::to_value(requested_schema)
|
||||
.context("failed to serialize MCP elicitation schema")?,
|
||||
},
|
||||
CreateElicitationRequestParams::UrlElicitationParams {
|
||||
meta,
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
} => ElicitationRequest::Url {
|
||||
Elicitation::Mcp(
|
||||
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
|
||||
meta,
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
},
|
||||
) => ElicitationRequest::Url {
|
||||
meta: meta
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
@@ -199,6 +203,15 @@ impl ElicitationRequestManager {
|
||||
url,
|
||||
elicitation_id,
|
||||
},
|
||||
Elicitation::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => ElicitationRequest::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
@@ -243,14 +256,18 @@ pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval)
|
||||
|
||||
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
|
||||
|
||||
fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool {
|
||||
fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
|
||||
match elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
requested_schema, ..
|
||||
} => {
|
||||
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
||||
requested_schema,
|
||||
..
|
||||
}) => {
|
||||
// Auto-accept confirm/approval elicitations without schema requirements.
|
||||
requested_schema.properties.is_empty()
|
||||
}
|
||||
CreateElicitationRequestParams::UrlElicitationParams { .. } => false,
|
||||
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
|
||||
..
|
||||
})
|
||||
| Elicitation::OpenAiForm { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ pub async fn read_mcp_resource(
|
||||
host_owned_codex_apps_enabled,
|
||||
config.prefix_mcp_tool_names,
|
||||
config.client_elicitation_capability.clone(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
tool_plugin_provenance(config),
|
||||
auth,
|
||||
/*elicitation_reviewer*/ None,
|
||||
@@ -379,6 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
host_owned_codex_apps_enabled,
|
||||
config.prefix_mcp_tool_names,
|
||||
config.client_elicitation_capability.clone(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
/*elicitation_reviewer*/ None,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! [`crate::connection_manager`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
@@ -62,6 +63,7 @@ use rmcp::model::ClientCapabilities;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::Implementation;
|
||||
use rmcp::model::InitializeRequestParams;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ProtocolVersion;
|
||||
use rmcp::model::Tool as RmcpTool;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -70,6 +72,7 @@ use tracing::warn;
|
||||
/// MCP server capability indicating that Codex should include [`SandboxState`]
|
||||
/// in tool-call request `_meta` under this key.
|
||||
pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta";
|
||||
pub const OPENAI_FORM_CAPABILITY: &str = "openai/form";
|
||||
|
||||
pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
|
||||
pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str =
|
||||
@@ -151,6 +154,7 @@ impl AsyncManagedClient {
|
||||
runtime_context: McpRuntimeContext,
|
||||
runtime_auth_provider: Option<SharedAuthProvider>,
|
||||
client_elicitation_capability: ElicitationCapability,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Self {
|
||||
let tool_filter = server
|
||||
.configured_config()
|
||||
@@ -204,6 +208,7 @@ impl AsyncManagedClient {
|
||||
elicitation_requests,
|
||||
codex_apps_tools_cache_context,
|
||||
client_elicitation_capability,
|
||||
supports_openai_form_elicitation,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -483,14 +488,12 @@ async fn start_server_task(
|
||||
elicitation_requests,
|
||||
codex_apps_tools_cache_context,
|
||||
client_elicitation_capability,
|
||||
supports_openai_form_elicitation,
|
||||
} = params;
|
||||
let mut capabilities = ClientCapabilities::default();
|
||||
capabilities.elicitation = Some(client_elicitation_capability);
|
||||
let params = InitializeRequestParams::new(
|
||||
capabilities,
|
||||
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2025_06_18);
|
||||
let params = mcp_initialize_request_params(
|
||||
client_elicitation_capability,
|
||||
supports_openai_form_elicitation,
|
||||
);
|
||||
|
||||
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
|
||||
|
||||
@@ -550,6 +553,25 @@ async fn start_server_task(
|
||||
Ok(managed)
|
||||
}
|
||||
|
||||
fn mcp_initialize_request_params(
|
||||
client_elicitation_capability: ElicitationCapability,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> InitializeRequestParams {
|
||||
let mut capabilities = ClientCapabilities::default();
|
||||
capabilities.elicitation = Some(client_elicitation_capability);
|
||||
if supports_openai_form_elicitation {
|
||||
capabilities.extensions = Some(BTreeMap::from([(
|
||||
OPENAI_FORM_CAPABILITY.to_string(),
|
||||
JsonObject::new(),
|
||||
)]));
|
||||
}
|
||||
InitializeRequestParams::new(
|
||||
capabilities,
|
||||
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2025_06_18)
|
||||
}
|
||||
|
||||
fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo {
|
||||
McpServerInfo {
|
||||
name: server_info.name,
|
||||
@@ -574,6 +596,7 @@ struct StartServerTaskParams {
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
client_elicitation_capability: ElicitationCapability,
|
||||
supports_openai_form_elicitation: bool,
|
||||
}
|
||||
|
||||
async fn make_rmcp_client(
|
||||
@@ -668,6 +691,27 @@ mod tests {
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Meta;
|
||||
|
||||
#[test]
|
||||
fn mcp_initialize_advertises_openai_form_only_when_supported() {
|
||||
let unsupported = mcp_initialize_request_params(
|
||||
ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
);
|
||||
assert_eq!(unsupported.capabilities.extensions, None);
|
||||
|
||||
let supported = mcp_initialize_request_params(
|
||||
ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ true,
|
||||
);
|
||||
assert_eq!(
|
||||
supported.capabilities.extensions,
|
||||
Some(BTreeMap::from([(
|
||||
OPENAI_FORM_CAPABILITY.to_string(),
|
||||
JsonObject::new(),
|
||||
)]))
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_with_connector_meta() -> RmcpTool {
|
||||
RmcpTool::new(
|
||||
"capture_file_upload",
|
||||
|
||||
@@ -113,6 +113,10 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
parent_trace: None,
|
||||
environment_selections: parent_ctx.environments.to_selections(),
|
||||
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation: parent_session
|
||||
.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
||||
thread_store: Arc::clone(&parent_session.services.thread_store),
|
||||
attestation_provider: parent_session.services.attestation_provider.clone(),
|
||||
|
||||
@@ -335,6 +335,13 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> {
|
||||
self.codex
|
||||
.session
|
||||
.set_openai_form_elicitation_support(supported)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Preview persistent thread settings overrides without committing them.
|
||||
pub async fn preview_thread_settings_overrides(
|
||||
&self,
|
||||
|
||||
@@ -291,6 +291,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
host_owned_codex_apps_enabled,
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*elicitation_reviewer*/ None,
|
||||
|
||||
@@ -1314,6 +1314,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
|
||||
/*host_owned_codex_apps_enabled*/ true,
|
||||
turn_context.config.prefix_mcp_tool_names(),
|
||||
rmcp::model::ElicitationCapability::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
codex_mcp::ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*elicitation_reviewer*/ None,
|
||||
|
||||
@@ -16,7 +16,7 @@ use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_T
|
||||
use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY;
|
||||
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY;
|
||||
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use codex_rmcp_client::Elicitation;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::Meta;
|
||||
use serde_json::Map;
|
||||
@@ -143,6 +143,15 @@ impl Session {
|
||||
requested_schema,
|
||||
}
|
||||
}
|
||||
McpServerElicitationRequest::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} => codex_protocol::approvals::ElicitationRequest::OpenAiForm {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
McpServerElicitationRequest::Url {
|
||||
meta,
|
||||
message,
|
||||
@@ -353,6 +362,9 @@ impl Session {
|
||||
host_owned_codex_apps_enabled,
|
||||
mcp_config.prefix_mcp_tool_names,
|
||||
mcp_config.client_elicitation_capability,
|
||||
self.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth.as_ref(),
|
||||
elicitation_reviewer,
|
||||
@@ -419,6 +431,34 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn set_openai_form_elicitation_support(
|
||||
&self,
|
||||
supported: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
if self
|
||||
.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
== supported
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = self.get_config().await;
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(config.mcp_servers.get())?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
)?,
|
||||
auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?,
|
||||
};
|
||||
self.services
|
||||
.supports_openai_form_elicitation
|
||||
.store(supported, std::sync::atomic::Ordering::Relaxed);
|
||||
*self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_mcp_servers_now(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
@@ -510,12 +550,15 @@ fn guardian_elicitation_review_request(
|
||||
request: &ElicitationReviewRequest,
|
||||
) -> GuardianElicitationReview {
|
||||
let (meta, requested_schema) = match &request.elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
requested_schema,
|
||||
..
|
||||
} => (meta, Some(requested_schema)),
|
||||
CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => {
|
||||
}) => (meta, Some(requested_schema)),
|
||||
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
|
||||
meta,
|
||||
..
|
||||
}) => {
|
||||
return if meta_requests_approval_request(meta) {
|
||||
GuardianElicitationReview::Decline(
|
||||
"guardian MCP elicitation review only supports form elicitations",
|
||||
@@ -524,6 +567,7 @@ fn guardian_elicitation_review_request(
|
||||
GuardianElicitationReview::NotRequested
|
||||
};
|
||||
}
|
||||
Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested,
|
||||
};
|
||||
|
||||
let Some(meta) = meta.as_ref().map(|meta| &meta.0) else {
|
||||
@@ -585,13 +629,10 @@ fn guardian_elicitation_review_request(
|
||||
))
|
||||
}
|
||||
|
||||
fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> {
|
||||
match elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams { meta, .. }
|
||||
| CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta
|
||||
.as_ref()
|
||||
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)),
|
||||
}
|
||||
fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> {
|
||||
elicitation
|
||||
.meta()
|
||||
.and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY))
|
||||
}
|
||||
|
||||
fn meta_requests_approval_request(meta: &Option<Meta>) -> bool {
|
||||
|
||||
@@ -30,13 +30,15 @@ fn form_request(meta: Option<Meta>) -> ElicitationReviewRequest {
|
||||
ElicitationReviewRequest {
|
||||
server_name: "browser-use".to_string(),
|
||||
request_id: rmcp::model::NumberOrString::Number(7),
|
||||
elicitation: CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
message: "Allow origin?".to_string(),
|
||||
requested_schema: ElicitationSchema::builder()
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
elicitation: Elicitation::Mcp(
|
||||
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta,
|
||||
message: "Allow origin?".to_string(),
|
||||
requested_schema: ElicitationSchema::builder()
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +173,14 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
|
||||
let url_request = ElicitationReviewRequest {
|
||||
server_name: "browser-use".to_string(),
|
||||
request_id: rmcp::model::NumberOrString::Number(8),
|
||||
elicitation: CreateElicitationRequestParams::UrlElicitationParams {
|
||||
meta: guardian_meta(Some(json!({}))),
|
||||
message: "Open URL".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
elicitation_id: "elicit-1".to_string(),
|
||||
},
|
||||
elicitation: Elicitation::Mcp(
|
||||
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
|
||||
meta: guardian_meta(Some(json!({}))),
|
||||
message: "Open URL".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
elicitation_id: "elicit-1".to_string(),
|
||||
},
|
||||
),
|
||||
};
|
||||
assert!(matches!(
|
||||
guardian_elicitation_review_request(&url_request),
|
||||
@@ -186,14 +190,16 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
|
||||
let non_empty_schema_request = ElicitationReviewRequest {
|
||||
server_name: "browser-use".to_string(),
|
||||
request_id: rmcp::model::NumberOrString::Number(9),
|
||||
elicitation: CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: guardian_meta(Some(json!({}))),
|
||||
message: "Allow origin?".to_string(),
|
||||
requested_schema: ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
elicitation: Elicitation::Mcp(
|
||||
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: guardian_meta(Some(json!({}))),
|
||||
message: "Allow origin?".to_string(),
|
||||
requested_schema: ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.expect("schema should build"),
|
||||
},
|
||||
),
|
||||
};
|
||||
assert!(matches!(
|
||||
guardian_elicitation_review_request(&non_empty_schema_request),
|
||||
|
||||
@@ -435,6 +435,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) parent_trace: Option<W3cTraceContext>,
|
||||
pub(crate) environment_selections: Vec<TurnEnvironmentSelection>,
|
||||
pub(crate) thread_extension_init: ExtensionDataInit,
|
||||
pub(crate) supports_openai_form_elicitation: bool,
|
||||
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
@@ -517,6 +518,7 @@ impl Codex {
|
||||
parent_trace: _,
|
||||
environment_selections,
|
||||
thread_extension_init,
|
||||
supports_openai_form_elicitation,
|
||||
analytics_events_client,
|
||||
thread_store,
|
||||
attestation_provider,
|
||||
@@ -659,6 +661,7 @@ impl Codex {
|
||||
mcp_manager.clone(),
|
||||
extensions,
|
||||
thread_extension_init,
|
||||
supports_openai_form_elicitation,
|
||||
agent_control,
|
||||
environment_manager,
|
||||
inherited_environments,
|
||||
|
||||
@@ -484,6 +484,7 @@ impl Session {
|
||||
mcp_manager: Arc<McpManager>,
|
||||
extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
|
||||
thread_extension_init: ExtensionDataInit,
|
||||
supports_openai_form_elicitation: bool,
|
||||
agent_control: AgentControl,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
inherited_environments: Option<TurnEnvironmentSnapshot>,
|
||||
@@ -1009,6 +1010,9 @@ impl Session {
|
||||
session_extension_data,
|
||||
thread_extension_data,
|
||||
mcp_thread_init,
|
||||
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(
|
||||
supports_openai_form_elicitation,
|
||||
),
|
||||
agent_control,
|
||||
network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)),
|
||||
network_proxy_audit_metadata,
|
||||
@@ -1149,6 +1153,9 @@ impl Session {
|
||||
host_owned_codex_apps_enabled,
|
||||
config.prefix_mcp_tool_names(),
|
||||
client_elicitation_capability,
|
||||
sess.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
|
||||
@@ -4856,6 +4856,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
|
||||
mcp_manager,
|
||||
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
|
||||
codex_extension_api::ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
AgentControl::default(),
|
||||
environment_manager,
|
||||
/*inherited_environments*/ None,
|
||||
@@ -5018,6 +5019,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
),
|
||||
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
|
||||
mcp_thread_init: codex_extension_api::ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false),
|
||||
agent_control,
|
||||
network_proxy: arc_swap::ArcSwapOption::from(None),
|
||||
network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
@@ -5203,6 +5205,7 @@ async fn make_session_with_config_and_rx(
|
||||
mcp_manager,
|
||||
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
|
||||
codex_extension_api::ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
AgentControl::default(),
|
||||
environment_manager,
|
||||
/*inherited_environments*/ None,
|
||||
@@ -5307,6 +5310,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
mcp_manager,
|
||||
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
|
||||
codex_extension_api::ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
agent_control,
|
||||
environment_manager,
|
||||
/*inherited_environments*/ None,
|
||||
@@ -7061,6 +7065,7 @@ where
|
||||
),
|
||||
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
|
||||
mcp_thread_init: codex_extension_api::ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false),
|
||||
agent_control,
|
||||
network_proxy: arc_swap::ArcSwapOption::from(None),
|
||||
network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
|
||||
@@ -732,6 +732,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
parent_trace: None,
|
||||
environment_selections: Vec::new(),
|
||||
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
analytics_events_client: None,
|
||||
thread_store,
|
||||
attestation_provider: None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use crate::SkillsService;
|
||||
use crate::agent::AgentControl;
|
||||
@@ -67,6 +68,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) extensions: Arc<ExtensionRegistry<crate::config::Config>>,
|
||||
pub(crate) session_extension_data: ExtensionData,
|
||||
pub(crate) thread_extension_data: ExtensionData,
|
||||
pub(crate) supports_openai_form_elicitation: AtomicBool,
|
||||
pub(crate) mcp_thread_init: ExtensionDataInit,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) network_proxy: ArcSwapOption<StartedNetworkProxy>,
|
||||
|
||||
@@ -112,9 +112,14 @@ pub async fn start_thread_with_user_shell_override(
|
||||
thread_manager: &ThreadManager,
|
||||
config: Config,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> codex_protocol::error::Result<crate::NewThread> {
|
||||
thread_manager
|
||||
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
|
||||
.start_thread_with_user_shell_override_for_tests(
|
||||
config,
|
||||
user_shell_override,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -124,6 +129,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> codex_protocol::error::Result<crate::NewThread> {
|
||||
thread_manager
|
||||
.resume_thread_from_rollout_with_user_shell_override_for_tests(
|
||||
@@ -131,6 +137,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
user_shell_override,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ pub struct StartThreadOptions {
|
||||
pub parent_trace: Option<W3cTraceContext>,
|
||||
pub environments: Vec<TurnEnvironmentSelection>,
|
||||
pub thread_extension_init: ExtensionDataInit,
|
||||
pub supports_openai_form_elicitation: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ResumeThreadWithHistoryOptions {
|
||||
@@ -604,6 +605,7 @@ impl ThreadManager {
|
||||
parent_trace: None,
|
||||
environments,
|
||||
thread_extension_init: ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
@@ -644,6 +646,7 @@ impl ThreadManager {
|
||||
options.parent_trace,
|
||||
options.environments,
|
||||
options.thread_extension_init,
|
||||
options.supports_openai_form_elicitation,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -692,6 +695,7 @@ impl ThreadManager {
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
|
||||
Box::pin(self.resume_thread_with_history(
|
||||
@@ -699,6 +703,7 @@ impl ThreadManager {
|
||||
initial_history,
|
||||
auth_manager,
|
||||
parent_trace,
|
||||
supports_openai_form_elicitation,
|
||||
))
|
||||
.await
|
||||
}
|
||||
@@ -710,6 +715,7 @@ impl ThreadManager {
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
let agent_control = self.agent_control_for_config(&config);
|
||||
let environments = default_thread_environment_selections(
|
||||
@@ -735,6 +741,7 @@ impl ThreadManager {
|
||||
parent_trace,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -744,6 +751,7 @@ impl ThreadManager {
|
||||
&self,
|
||||
config: Config,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
let agent_control = self.agent_control_for_config(&config);
|
||||
let environments = default_thread_environment_selections(
|
||||
@@ -763,6 +771,7 @@ impl ThreadManager {
|
||||
/*parent_trace*/ None,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation,
|
||||
/*user_shell_override*/ Some(user_shell_override),
|
||||
))
|
||||
.await
|
||||
@@ -774,6 +783,7 @@ impl ThreadManager {
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
let agent_control = self.agent_control_for_config(&config);
|
||||
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
|
||||
@@ -800,6 +810,7 @@ impl ThreadManager {
|
||||
/*parent_trace*/ None,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation,
|
||||
/*user_shell_override*/ Some(user_shell_override),
|
||||
))
|
||||
.await
|
||||
@@ -880,8 +891,15 @@ impl ThreadManager {
|
||||
{
|
||||
let snapshot = snapshot.into();
|
||||
let history = self.initial_history_from_rollout_path(path).await?;
|
||||
self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace)
|
||||
.await
|
||||
self.fork_thread_from_history(
|
||||
snapshot,
|
||||
config,
|
||||
history,
|
||||
thread_source,
|
||||
parent_trace,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn initial_history_from_rollout_path(
|
||||
@@ -910,6 +928,7 @@ impl ThreadManager {
|
||||
history: InitialHistory,
|
||||
thread_source: Option<ThreadSource>,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread>
|
||||
where
|
||||
S: Into<ForkSnapshot>,
|
||||
@@ -920,6 +939,7 @@ impl ThreadManager {
|
||||
history,
|
||||
thread_source,
|
||||
parent_trace,
|
||||
supports_openai_form_elicitation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -931,6 +951,7 @@ impl ThreadManager {
|
||||
history: InitialHistory,
|
||||
thread_source: Option<ThreadSource>,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
// `forked_from_id()` describes this history's existing lineage. When
|
||||
// forking a resumed thread, the child copies the resumed thread itself.
|
||||
@@ -970,6 +991,7 @@ impl ThreadManager {
|
||||
parent_trace,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
supports_openai_form_elicitation,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -1230,6 +1252,7 @@ impl ThreadManagerState {
|
||||
/*parent_trace*/ None,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -1267,6 +1290,7 @@ impl ThreadManagerState {
|
||||
/*parent_trace*/ None,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -1305,6 +1329,7 @@ impl ThreadManagerState {
|
||||
/*parent_trace*/ None,
|
||||
environments,
|
||||
/*thread_extension_init*/ ExtensionDataInit::default(),
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
/*user_shell_override*/ None,
|
||||
))
|
||||
.await
|
||||
@@ -1326,6 +1351,7 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
environments: Vec<TurnEnvironmentSelection>,
|
||||
thread_extension_init: ExtensionDataInit,
|
||||
supports_openai_form_elicitation: bool,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
@@ -1344,6 +1370,7 @@ impl ThreadManagerState {
|
||||
parent_trace,
|
||||
environments,
|
||||
thread_extension_init,
|
||||
supports_openai_form_elicitation,
|
||||
user_shell_override,
|
||||
))
|
||||
.await
|
||||
@@ -1367,6 +1394,7 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
environments: Vec<TurnEnvironmentSelection>,
|
||||
thread_extension_init: ExtensionDataInit,
|
||||
supports_openai_form_elicitation: bool,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
|
||||
@@ -1434,6 +1462,7 @@ impl ThreadManagerState {
|
||||
parent_trace,
|
||||
environment_selections: environments,
|
||||
thread_extension_init,
|
||||
supports_openai_form_elicitation,
|
||||
analytics_events_client: self.analytics_events_client.clone(),
|
||||
thread_store: Arc::clone(&self.thread_store),
|
||||
attestation_provider: self.attestation_provider.clone(),
|
||||
|
||||
@@ -325,6 +325,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await
|
||||
.expect("internal thread should start");
|
||||
@@ -463,6 +464,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: selected_root_init("selected-a", "env-a"),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await
|
||||
.expect("start first thread");
|
||||
@@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: selected_root_init("selected-b", "env-b"),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await
|
||||
.expect("start second thread");
|
||||
@@ -567,6 +570,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
parent_trace: None,
|
||||
environments: environments.clone(),
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await
|
||||
.expect("start source thread");
|
||||
@@ -593,6 +597,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
rollout_path.clone(),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume source thread");
|
||||
@@ -729,6 +734,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume active source thread");
|
||||
@@ -792,6 +798,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume stopped source thread");
|
||||
@@ -842,6 +849,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await
|
||||
.expect("start source thread");
|
||||
@@ -868,6 +876,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume source thread");
|
||||
@@ -947,6 +956,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
|
||||
}),
|
||||
auth_manager.clone(),
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("seed rollout path in store");
|
||||
@@ -963,6 +973,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
|
||||
rollout_path.clone(),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume from rollout path");
|
||||
@@ -1254,6 +1265,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
]),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from completed history");
|
||||
@@ -1368,6 +1380,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
]),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from explicit partial history");
|
||||
@@ -1458,6 +1471,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
]),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("create source thread from partial history");
|
||||
|
||||
@@ -2832,6 +2832,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() {
|
||||
})]),
|
||||
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("start thread");
|
||||
|
||||
@@ -15,6 +15,7 @@ use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::resolve_installation_id;
|
||||
@@ -39,6 +40,7 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -258,6 +260,7 @@ pub struct TestCodexBuilder {
|
||||
exec_server_url: Option<String>,
|
||||
extensions: Arc<ExtensionRegistry<Config>>,
|
||||
user_instructions_provider: Option<Arc<dyn UserInstructionsProvider>>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
}
|
||||
|
||||
impl TestCodexBuilder {
|
||||
@@ -354,6 +357,11 @@ impl TestCodexBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_openai_form_elicitation(mut self) -> Self {
|
||||
self.supports_openai_form_elicitation = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_windows_cmd_shell(self) -> Self {
|
||||
if cfg!(windows) {
|
||||
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
|
||||
@@ -574,6 +582,7 @@ impl TestCodexBuilder {
|
||||
path,
|
||||
auth_manager,
|
||||
user_shell_override,
|
||||
self.supports_openai_form_elicitation,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
@@ -585,6 +594,7 @@ impl TestCodexBuilder {
|
||||
path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
self.supports_openai_form_elicitation,
|
||||
))
|
||||
.await?
|
||||
}
|
||||
@@ -594,11 +604,29 @@ impl TestCodexBuilder {
|
||||
thread_manager.as_ref(),
|
||||
config.clone(),
|
||||
user_shell_override,
|
||||
self.supports_openai_form_elicitation,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
|
||||
(None, None) => {
|
||||
let environments = thread_manager.default_environment_selections(&config.cwd);
|
||||
Box::pin(
|
||||
thread_manager.start_thread_with_options(StartThreadOptions {
|
||||
config: config.clone(),
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
metrics_service_name: None,
|
||||
parent_trace: None,
|
||||
environments,
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: self.supports_openai_form_elicitation,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(TestCodex {
|
||||
@@ -1143,6 +1171,7 @@ pub fn test_codex() -> TestCodexBuilder {
|
||||
exec_server_url: None,
|
||||
extensions: empty_extension_registry(),
|
||||
user_instructions_provider: None,
|
||||
supports_openai_form_elicitation: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -448,6 +448,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(provider.load_count(), 2);
|
||||
@@ -664,6 +665,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
},
|
||||
],
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(provider.load_count(), 2);
|
||||
|
||||
@@ -830,6 +830,7 @@ async fn resume_conversation(
|
||||
path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
))
|
||||
.await
|
||||
.expect("resume conversation")
|
||||
|
||||
@@ -201,6 +201,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
|
||||
}),
|
||||
/*thread_source*/ None,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("fork from stored history");
|
||||
|
||||
@@ -2267,6 +2267,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
|
||||
InitialHistory::Forked(history),
|
||||
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await?;
|
||||
let codex = resumed_thread.thread;
|
||||
|
||||
@@ -110,6 +110,7 @@ async fn emits_warning_when_resumed_model_differs() {
|
||||
initial_history,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("resume conversation");
|
||||
|
||||
@@ -50,6 +50,7 @@ use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_wine_exec;
|
||||
use core_test_support::stdio_server_bin;
|
||||
@@ -339,13 +340,23 @@ async fn call_cwd_tool(
|
||||
fixture: &TestCodex,
|
||||
server_name: &str,
|
||||
call_id: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
call_structured_tool(server, fixture, server_name, "cwd", call_id).await
|
||||
}
|
||||
|
||||
async fn call_structured_tool(
|
||||
server: &MockServer,
|
||||
fixture: &TestCodex,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
call_id: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
let namespace = format!("mcp__{server_name}");
|
||||
mount_sse_once(
|
||||
server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -353,7 +364,7 @@ async fn call_cwd_tool(
|
||||
mount_sse_once(
|
||||
server,
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."),
|
||||
responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
@@ -361,7 +372,7 @@ async fn call_cwd_tool(
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.submit(read_only_user_turn(fixture, "call the rmcp cwd tool"))
|
||||
.submit(read_only_user_turn(fixture, "call the requested rmcp tool"))
|
||||
.await?;
|
||||
|
||||
wait_for_event(&fixture.codex, |ev| {
|
||||
@@ -378,7 +389,7 @@ async fn call_cwd_tool(
|
||||
let structured_content = end
|
||||
.result
|
||||
.as_ref()
|
||||
.expect("rmcp cwd tool should return success")
|
||||
.expect("rmcp tool should return success")
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.expect("structured content")
|
||||
@@ -388,6 +399,106 @@ async fn call_cwd_tool(
|
||||
Ok(structured_content)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> {
|
||||
assert_openai_form_capability_advertisement(/*expected*/ true).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> {
|
||||
assert_openai_form_capability_advertisement(/*expected*/ false).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> {
|
||||
skip_if_wine_exec!(
|
||||
Ok(()),
|
||||
"requires a Windows test_stdio_server in the Wine-exec environment"
|
||||
);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let server_name = "capabilities";
|
||||
let command = stdio_server_bin()?;
|
||||
let fixture = test_codex()
|
||||
.with_config(move |config| {
|
||||
insert_mcp_server(
|
||||
config,
|
||||
server_name,
|
||||
stdio_transport(command, /*env*/ None, Vec::new()),
|
||||
TestMcpServerOptions::default(),
|
||||
);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
let unsupported = call_structured_tool(
|
||||
&server,
|
||||
&fixture,
|
||||
server_name,
|
||||
"client_capabilities",
|
||||
"call-client-capabilities-unsupported",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
unsupported,
|
||||
json!({ "supportsOpenaiFormElicitation": false })
|
||||
);
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.set_openai_form_elicitation_support(/*supported*/ true)
|
||||
.await?;
|
||||
let supported = call_structured_tool(
|
||||
&server,
|
||||
&fixture,
|
||||
server_name,
|
||||
"client_capabilities",
|
||||
"call-client-capabilities-supported",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> {
|
||||
skip_if_wine_exec!(
|
||||
Ok(()),
|
||||
"requires a Windows test_stdio_server in the Wine-exec environment"
|
||||
);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let server_name = "capabilities";
|
||||
let command = stdio_server_bin()?;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
insert_mcp_server(
|
||||
config,
|
||||
server_name,
|
||||
stdio_transport(command, /*env*/ None, Vec::new()),
|
||||
TestMcpServerOptions::default(),
|
||||
);
|
||||
});
|
||||
if expected {
|
||||
builder = builder.with_openai_form_elicitation();
|
||||
}
|
||||
let fixture = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
let structured = call_structured_tool(
|
||||
&server,
|
||||
&fixture,
|
||||
server_name,
|
||||
"client_capabilities",
|
||||
"call-client-capabilities",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
structured,
|
||||
json!({ "supportsOpenaiFormElicitation": expected })
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) {
|
||||
let actual_cwd = structured
|
||||
.get("cwd")
|
||||
|
||||
@@ -754,6 +754,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
|
||||
InitialHistory::New,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("spawn conversation");
|
||||
@@ -93,6 +94,7 @@ async fn suppresses_warning_when_configured() {
|
||||
InitialHistory::New,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
/*supports_openai_form_elicitation*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("spawn conversation");
|
||||
|
||||
@@ -556,6 +556,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
client_name: "codex_exec".to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
};
|
||||
|
||||
@@ -313,6 +313,7 @@ impl MemoryStartupContext {
|
||||
parent_trace: None,
|
||||
environments,
|
||||
thread_extension_init: Default::default(),
|
||||
supports_openai_form_elicitation: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -342,6 +342,15 @@ pub enum ElicitationRequest {
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename = "openai/form")]
|
||||
#[ts(rename = "openai/form")]
|
||||
OpenAiForm {
|
||||
#[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")]
|
||||
@@ -355,7 +364,9 @@ pub enum ElicitationRequest {
|
||||
impl ElicitationRequest {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Form { message, .. } | Self::Url { message, .. } => message,
|
||||
Self::Form { message, .. }
|
||||
| Self::OpenAiForm { message, .. }
|
||||
| Self::Url { message, .. } => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use rmcp::ErrorData as McpError;
|
||||
@@ -11,6 +13,8 @@ use rmcp::ServiceExt;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::model::CallToolResult;
|
||||
use rmcp::model::InitializeRequestParams;
|
||||
use rmcp::model::InitializeResult;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListResourceTemplatesResult;
|
||||
use rmcp::model::ListResourcesResult;
|
||||
@@ -38,6 +42,7 @@ struct TestToolServer {
|
||||
tools: Arc<Vec<Tool>>,
|
||||
resources: Arc<Vec<Resource>>,
|
||||
resource_templates: Arc<Vec<ResourceTemplate>>,
|
||||
supports_openai_form_elicitation: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
const MEMO_URI: &str = "memo://codex/example-note";
|
||||
@@ -68,6 +73,7 @@ impl TestToolServer {
|
||||
let tools = vec![
|
||||
Self::echo_tool(),
|
||||
Self::echo_dash_tool(),
|
||||
Self::client_capabilities_tool(),
|
||||
Self::cwd_tool(),
|
||||
Self::sync_tool(),
|
||||
Self::sync_readonly_tool(),
|
||||
@@ -81,6 +87,7 @@ impl TestToolServer {
|
||||
tools: Arc::new(tools),
|
||||
resources: Arc::new(resources),
|
||||
resource_templates: Arc::new(resource_templates),
|
||||
supports_openai_form_elicitation: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +173,24 @@ impl TestToolServer {
|
||||
tool
|
||||
}
|
||||
|
||||
fn client_capabilities_tool() -> Tool {
|
||||
#[expect(clippy::expect_used)]
|
||||
let schema: JsonObject = serde_json::from_value(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}))
|
||||
.expect("client capabilities tool schema should deserialize");
|
||||
|
||||
let mut tool = Tool::new(
|
||||
Cow::Borrowed("client_capabilities"),
|
||||
Cow::Borrowed("Return capabilities advertised by the MCP client."),
|
||||
Arc::new(schema),
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
tool
|
||||
}
|
||||
|
||||
fn sync_tool() -> Tool {
|
||||
#[expect(clippy::expect_used)]
|
||||
let schema: JsonObject = serde_json::from_value(json!({
|
||||
@@ -396,6 +421,23 @@ struct ImageScenarioArgs {
|
||||
}
|
||||
|
||||
impl ServerHandler for TestToolServer {
|
||||
async fn initialize(
|
||||
&self,
|
||||
request: InitializeRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<InitializeResult, McpError> {
|
||||
self.supports_openai_form_elicitation.store(
|
||||
request
|
||||
.capabilities
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|extensions| extensions.contains_key("openai/form")),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
context.peer.set_peer_info(request);
|
||||
Ok(self.get_info())
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let mut capabilities = ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
@@ -481,6 +523,11 @@ impl ServerHandler for TestToolServer {
|
||||
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match request.name.as_ref() {
|
||||
"client_capabilities" => Ok(Self::structured_result(json!({
|
||||
"supportsOpenaiFormElicitation": self
|
||||
.supports_openai_form_elicitation
|
||||
.load(Ordering::Relaxed),
|
||||
}))),
|
||||
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
|
||||
context.meta.0,
|
||||
))),
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use rmcp::RoleClient;
|
||||
use rmcp::model::ClientInfo;
|
||||
use rmcp::model::ClientResult;
|
||||
use rmcp::model::CustomRequest;
|
||||
use rmcp::model::CustomResult;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::Meta;
|
||||
@@ -12,7 +13,9 @@ use rmcp::model::ServerRequest;
|
||||
use rmcp::service::NotificationContext;
|
||||
use rmcp::service::RequestContext;
|
||||
use rmcp::service::Service;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::logging_client_handler::LoggingClientHandler;
|
||||
@@ -22,10 +25,21 @@ use crate::rmcp_client::ElicitationResponse;
|
||||
use crate::rmcp_client::SendElicitation;
|
||||
|
||||
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
|
||||
const OPENAI_FORM_METHOD: &str = "openai/form";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OpenAiFormRequestParams {
|
||||
#[serde(rename = "_meta")]
|
||||
meta: Option<Value>,
|
||||
message: String,
|
||||
requested_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ElicitationClientService {
|
||||
handler: LoggingClientHandler,
|
||||
supports_openai_form: bool,
|
||||
send_elicitation: Arc<SendElicitation>,
|
||||
pause_state: ElicitationPauseState,
|
||||
}
|
||||
@@ -36,12 +50,18 @@ impl ElicitationClientService {
|
||||
send_elicitation: SendElicitation,
|
||||
pause_state: ElicitationPauseState,
|
||||
) -> Self {
|
||||
let supports_openai_form = client_info
|
||||
.capabilities
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD));
|
||||
let send_elicitation = Arc::new(send_elicitation);
|
||||
Self {
|
||||
handler: LoggingClientHandler::new(
|
||||
client_info,
|
||||
clone_send_elicitation(Arc::clone(&send_elicitation)),
|
||||
),
|
||||
supports_openai_form,
|
||||
send_elicitation,
|
||||
pause_state,
|
||||
}
|
||||
@@ -73,11 +93,23 @@ impl Service<RoleClient> for ElicitationClientService {
|
||||
) -> Result<ClientResult, rmcp::ErrorData> {
|
||||
match request {
|
||||
ServerRequest::CreateElicitationRequest(request) => {
|
||||
let response = self.create_elicitation(request.params, context).await?;
|
||||
let response = self
|
||||
.create_elicitation(Elicitation::Mcp(request.params), context)
|
||||
.await?;
|
||||
// RMCP's typed CreateElicitationResult does not model result-level `_meta`.
|
||||
let result = elicitation_response_result(response)?;
|
||||
Ok(ClientResult::CustomResult(result))
|
||||
}
|
||||
ServerRequest::CustomRequest(request)
|
||||
if request.method == OPENAI_FORM_METHOD && self.supports_openai_form =>
|
||||
{
|
||||
let response = self
|
||||
.create_elicitation(openai_form_elicitation(request)?, context)
|
||||
.await?;
|
||||
Ok(ClientResult::CustomResult(elicitation_response_result(
|
||||
response,
|
||||
)?))
|
||||
}
|
||||
request => {
|
||||
<LoggingClientHandler as Service<RoleClient>>::handle_request(
|
||||
&self.handler,
|
||||
@@ -107,6 +139,18 @@ impl Service<RoleClient> for ElicitationClientService {
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_form_elicitation(request: CustomRequest) -> Result<Elicitation, rmcp::ErrorData> {
|
||||
let params = request
|
||||
.params_as::<OpenAiFormRequestParams>()
|
||||
.map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))?
|
||||
.ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?;
|
||||
Ok(Elicitation::OpenAiForm {
|
||||
meta: params.meta,
|
||||
message: params.message,
|
||||
requested_schema: params.requested_schema,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation {
|
||||
// RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services.
|
||||
context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY);
|
||||
@@ -114,10 +158,20 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli
|
||||
return request;
|
||||
}
|
||||
|
||||
request
|
||||
.meta_mut()
|
||||
.get_or_insert_with(Meta::new)
|
||||
.extend(context_meta);
|
||||
match &mut request {
|
||||
Elicitation::Mcp(request) => request
|
||||
.meta_mut()
|
||||
.get_or_insert_with(Meta::new)
|
||||
.extend(context_meta),
|
||||
Elicitation::OpenAiForm { meta, .. } => {
|
||||
let meta = meta
|
||||
.get_or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut();
|
||||
if let Some(meta) = meta {
|
||||
meta.extend(context_meta.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
@@ -165,7 +219,7 @@ mod tests {
|
||||
#[test]
|
||||
fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() {
|
||||
let request = restore_context_meta(
|
||||
form_request(/*meta*/ None),
|
||||
Elicitation::Mcp(form_request(/*meta*/ None)),
|
||||
meta(json!({
|
||||
"progressToken": "progress-token",
|
||||
"persist": ["session", "always"],
|
||||
@@ -174,9 +228,54 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
form_request(Some(meta(json!({
|
||||
Elicitation::Mcp(form_request(Some(meta(json!({
|
||||
"persist": ["session", "always"],
|
||||
}))))
|
||||
})))))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_openai_form_custom_requests() {
|
||||
let elicitation = openai_form_elicitation(CustomRequest::new(
|
||||
OPENAI_FORM_METHOD,
|
||||
Some(json!({
|
||||
"message": "Select a template",
|
||||
"requestedSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
))
|
||||
.expect("valid openai/form request");
|
||||
|
||||
assert_eq!(
|
||||
elicitation,
|
||||
Elicitation::OpenAiForm {
|
||||
meta: None,
|
||||
message: "Select a template".to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "openai/imagePicker",
|
||||
"items": [{
|
||||
"id": "monthly-review",
|
||||
"title": "Monthly review",
|
||||
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::rmcp_client::Elicitation;
|
||||
use crate::rmcp_client::SendElicitation;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -40,7 +41,7 @@ impl ClientHandler for LoggingClientHandler {
|
||||
request: CreateElicitationRequestParams,
|
||||
context: RequestContext<RoleClient>,
|
||||
) -> Result<CreateElicitationResult, rmcp::ErrorData> {
|
||||
(self.send_elicitation)(context.id, request)
|
||||
(self.send_elicitation)(context.id, Elicitation::Mcp(request))
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
|
||||
|
||||
@@ -40,6 +40,7 @@ use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use rmcp::model::RequestId;
|
||||
use rmcp::model::RequestParamsMeta;
|
||||
use rmcp::model::ServerResult;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::service::RoleClient;
|
||||
@@ -251,7 +252,24 @@ fn remaining_operation_timeout(
|
||||
}
|
||||
}
|
||||
|
||||
pub type Elicitation = CreateElicitationRequestParams;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Elicitation {
|
||||
Mcp(CreateElicitationRequestParams),
|
||||
OpenAiForm {
|
||||
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),
|
||||
Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -292,7 +292,10 @@ impl App {
|
||||
message: message.clone(),
|
||||
},
|
||||
)),
|
||||
codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
|
||||
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm {
|
||||
..
|
||||
}
|
||||
| codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
thread_id,
|
||||
params.server_name.clone(),
|
||||
|
||||
@@ -376,7 +376,8 @@ impl ChatWidget {
|
||||
self.bottom_pane
|
||||
.push_approval_request(request, &self.config.features);
|
||||
}
|
||||
McpServerElicitationRequest::Url { .. } => {
|
||||
McpServerElicitationRequest::OpenAiForm { .. }
|
||||
| McpServerElicitationRequest::Url { .. } => {
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
thread_id,
|
||||
params.server_name,
|
||||
|
||||
@@ -399,6 +399,7 @@ async fn connect_remote_app_server(
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
@@ -562,6 +563,7 @@ where
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
|
||||
@@ -1055,6 +1055,7 @@ mod tests {
|
||||
client_name: "test".to_string(),
|
||||
client_version: "test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user