Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions codex-rs/codex-mcp/src/mcp_connection_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,19 +1315,15 @@ impl From<anyhow::Error> for StartupOutcomeError {
}
}

fn elicitation_capability_for_server(server_name: &str) -> Option<ElicitationCapability> {
if server_name == CODEX_APPS_MCP_SERVER_NAME {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this is the exact purpose of this PR which is to expand elicitation support to custom MCPs

// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
})
} else {
None
}
fn elicitation_capability_for_server(_server_name: &str) -> Option<ElicitationCapability> {
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
})
}

async fn start_server_task(
Expand Down
26 changes: 13 additions & 13 deletions codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,19 +507,19 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
}

#[test]
fn elicitation_capability_enabled_only_for_codex_apps() {
let codex_apps_capability = elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME);
assert!(matches!(
codex_apps_capability,
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None
}),
url: None,
})
));

assert!(elicitation_capability_for_server("custom_mcp").is_none());
fn elicitation_capability_enabled_for_custom_servers() {
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
let capability = elicitation_capability_for_server(server_name);
assert!(matches!(
capability,
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None
}),
url: None,
})
));
}
}

#[test]
Expand Down
213 changes: 213 additions & 0 deletions codex-rs/rmcp-client/src/elicitation_client_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use std::sync::Arc;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a thin RMCP Service wrapper so Codex can round-trip elicitation request/response _meta while keeping the existing logging/notification handler behavior.
This wrapper will be significantly smaller once we add _meta to CreateElicitationResult in rmcp in modelcontextprotocol/rust-sdk#792


use rmcp::RoleClient;
use rmcp::model::ClientInfo;
use rmcp::model::ClientResult;
use rmcp::model::CustomResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use rmcp::model::RequestParamsMeta;
use rmcp::model::ServerNotification;
use rmcp::model::ServerRequest;
use rmcp::service::NotificationContext;
use rmcp::service::RequestContext;
use rmcp::service::Service;
use serde::Serialize;
use serde_json::Value;

use crate::logging_client_handler::LoggingClientHandler;
use crate::rmcp_client::Elicitation;
use crate::rmcp_client::ElicitationResponse;
use crate::rmcp_client::SendElicitation;

const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";

#[derive(Clone)]
pub(crate) struct ElicitationClientService {
handler: LoggingClientHandler,
send_elicitation: Arc<SendElicitation>,
}

impl ElicitationClientService {
pub(crate) fn new(client_info: ClientInfo, send_elicitation: SendElicitation) -> Self {
let send_elicitation = Arc::new(send_elicitation);
Self {
handler: LoggingClientHandler::new(
client_info,
clone_send_elicitation(Arc::clone(&send_elicitation)),
),
send_elicitation,
}
}

async fn create_elicitation(
&self,
request: Elicitation,
context: RequestContext<RoleClient>,
) -> Result<ElicitationResponse, rmcp::ErrorData> {
let RequestContext { id, meta, .. } = context;
let request = restore_context_meta(request, meta);
(self.send_elicitation)(id, request)
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
}
}

fn clone_send_elicitation(send_elicitation: Arc<SendElicitation>) -> SendElicitation {
Box::new(move |request_id, request| send_elicitation(request_id, request))
}

impl Service<RoleClient> for ElicitationClientService {
async fn handle_request(
&self,
request: ServerRequest,
context: RequestContext<RoleClient>,
) -> Result<ClientResult, rmcp::ErrorData> {
match request {
ServerRequest::CreateElicitationRequest(request) => {
let response = self.create_elicitation(request.params, context).await?;
// RMCP's typed CreateElicitationResult does not model result-level `_meta`.
let result = elicitation_response_result(response)?;
Ok(ClientResult::CustomResult(result))
}
request => {
<LoggingClientHandler as Service<RoleClient>>::handle_request(
&self.handler,
request,
context,
)
.await
}
}
}

async fn handle_notification(
&self,
notification: ServerNotification,
context: NotificationContext<RoleClient>,
) -> Result<(), rmcp::ErrorData> {
<LoggingClientHandler as Service<RoleClient>>::handle_notification(
&self.handler,
notification,
context,
)
.await
}

fn get_info(&self) -> ClientInfo {
<LoggingClientHandler as Service<RoleClient>>::get_info(&self.handler)
}
}

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);
if context_meta.is_empty() {
return request;
}

request
.meta_mut()
.get_or_insert_with(Meta::new)
.extend(context_meta);
request
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateElicitationResultWithMeta {
action: ElicitationAction,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Value>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
}

fn elicitation_response_result(
response: ElicitationResponse,
) -> Result<CustomResult, rmcp::ErrorData> {
let ElicitationResponse {
action,
content,
meta,
} = response;
let result = CreateElicitationResultWithMeta {
action,
content,
meta,
};

serde_json::to_value(result)
.map(CustomResult)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use rmcp::model::BooleanSchema;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationSchema;
use rmcp::model::PrimitiveSchema;
use serde_json::Value;
use serde_json::json;

use super::*;

#[test]
fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() {
let request = restore_context_meta(
form_request(/*meta*/ None),
meta(json!({
"progressToken": "progress-token",
"persist": ["session", "always"],
})),
);

assert_eq!(
request,
form_request(Some(meta(json!({
"persist": ["session", "always"],
}))))
);
}

#[test]
fn elicitation_response_result_serializes_response_meta() {
let result = rmcp::model::ClientResult::CustomResult(
elicitation_response_result(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({ "confirmed": true })),
meta: Some(json!({ "persist": "always" })),
})
.expect("elicitation response should serialize"),
);

assert_eq!(
serde_json::to_value(result).expect("client result should serialize"),
json!({
"action": "accept",
"content": { "confirmed": true },
"_meta": { "persist": "always" },
})
);
}

fn form_request(meta: Option<Meta>) -> CreateElicitationRequestParams {
CreateElicitationRequestParams::FormElicitationParams {
meta,
message: "Confirm?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.expect("schema should build"),
}
}

fn meta(value: Value) -> Meta {
let Value::Object(map) = value else {
panic!("meta must be an object");
};
Meta(map)
}
}
1 change: 1 addition & 0 deletions codex-rs/rmcp-client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod auth_status;
mod elicitation_client_service;
mod logging_client_handler;
mod oauth;
mod perform_oauth_login;
Expand Down
Loading
Loading