Codex Handbook
app-server/tests/suite/v2/mcp_server_elicitation.rs 489 lines
use std::borrow::Cow;use std::sync::Arc;use anyhow::Result;use app_test_support::ChatGptAuthFixture;use app_test_support::TestAppServer;use app_test_support::to_response;use app_test_support::write_chatgpt_auth;use axum::Json;use axum::Router;use axum::extract::State;use axum::http::HeaderMap;use axum::http::StatusCode;use axum::http::Uri;use axum::http::header::AUTHORIZATION;use axum::routing::get;use codex_app_server_protocol::JSONRPCMessage;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::McpElicitationSchema;use codex_app_server_protocol::McpServerElicitationAction;use codex_app_server_protocol::McpServerElicitationRequest;use codex_app_server_protocol::McpServerElicitationRequestParams;use codex_app_server_protocol::McpServerElicitationRequestResponse;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::ServerRequest;use codex_app_server_protocol::ServerRequestResolvedNotification;use codex_app_server_protocol::ThreadStartParams;use codex_app_server_protocol::ThreadStartResponse;use codex_app_server_protocol::TurnCompletedNotification;use codex_app_server_protocol::TurnStartParams;use codex_app_server_protocol::TurnStartResponse;use codex_app_server_protocol::TurnStatus;use codex_app_server_protocol::UserInput as V2UserInput;use codex_config::types::AuthCredentialsStoreMode;use core_test_support::assert_regex_match;use core_test_support::responses;use pretty_assertions::assert_eq;use rmcp::handler::server::ServerHandler;use rmcp::model::BooleanSchema;use rmcp::model::CallToolRequestParams;use rmcp::model::CallToolResult;use rmcp::model::Content;use rmcp::model::CreateElicitationRequestParams;use rmcp::model::ElicitationAction;use rmcp::model::ElicitationSchema;use rmcp::model::JsonObject;use rmcp::model::ListToolsResult;use rmcp::model::Meta;use rmcp::model::PrimitiveSchema;use rmcp::model::ServerCapabilities;use rmcp::model::ServerInfo;use rmcp::model::Tool;use rmcp::model::ToolAnnotations;use rmcp::service::RequestContext;use rmcp::service::RoleServer;use rmcp::transport::StreamableHttpServerConfig;use rmcp::transport::StreamableHttpService;use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;use serde_json::Value;use serde_json::json;use tempfile::TempDir;use tokio::net::TcpListener;use tokio::task::JoinHandle;use tokio::time::timeout;const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);const CONNECTOR_ID: &str = "calendar";const CONNECTOR_NAME: &str = "Calendar";const TOOL_NAMESPACE: &str = "mcp__codex_apps__calendar";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?";#[tokio::test(flavor = "multi_thread", worker_threads = 4)]async fn mcp_server_elicitation_round_trip() -> Result<()> {    let responses_server = responses::start_mock_server().await;    let tool_call_arguments = serde_json::to_string(&json!({}))?;    let response_mock = responses::mount_sse_sequence(        &responses_server,        vec![            responses::sse(vec![                responses::ev_response_created("resp-0"),                responses::ev_assistant_message("msg-0", "Warmup"),                responses::ev_completed("resp-0"),            ]),            responses::sse(vec![                responses::ev_response_created("resp-1"),                responses::ev_function_call_with_namespace(                    TOOL_CALL_ID,                    TOOL_NAMESPACE,                    CALLABLE_TOOL_NAME,                    &tool_call_arguments,                ),                responses::ev_completed("resp-1"),            ]),            responses::sse(vec![                responses::ev_response_created("resp-2"),                responses::ev_assistant_message("msg-1", "Done"),                responses::ev_completed("resp-2"),            ]),        ],    )    .await;    let (apps_server_url, apps_server_handle) = start_apps_server().await?;    let codex_home = TempDir::new()?;    write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?;    write_chatgpt_auth(        codex_home.path(),        ChatGptAuthFixture::new("chatgpt-token")            .account_id("account-123")            .chatgpt_user_id("user-123")            .chatgpt_account_id("account-123"),        AuthCredentialsStoreMode::File,    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let thread_start_id = mcp        .send_thread_start_request(ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let thread_start_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),    )    .await??;    let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;    let warmup_turn_start_id = mcp        .send_turn_start_request(TurnStartParams {            thread_id: thread.id.clone(),            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![V2UserInput::Text {                text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(),                text_elements: Vec::new(),            }],            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let turn_start_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),    )    .await??;    let TurnStartResponse { turn } = to_response(turn_start_resp)?;    let server_req = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_request_message(),    )    .await??;    let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else {        panic!("expected McpServerElicitationRequest request, got: {server_req:?}");    };    let requested_schema: 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"        }])    );    apps_server_handle.abort();    let _ = apps_server_handle.await;    Ok(())}#[derive(Clone)]struct AppsServerState {    expected_bearer: String,    expected_account_id: String,}#[derive(Clone, Default)]struct ElicitationAppsMcpServer;impl ServerHandler for ElicitationAppsMcpServer {    fn get_info(&self) -> ServerInfo {        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())            .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18)    }    async fn list_tools(        &self,        _request: Option<rmcp::model::PaginatedRequestParams>,        _context: RequestContext<RoleServer>,    ) -> Result<ListToolsResult, rmcp::ErrorData> {        let input_schema: JsonObject = serde_json::from_value(json!({            "type": "object",            "additionalProperties": false        }))        .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;        let mut tool = Tool::new(            Cow::Borrowed(TOOL_NAME),            Cow::Borrowed("Confirm a calendar action."),            Arc::new(input_schema),        );        tool.annotations = Some(ToolAnnotations::new().read_only(true));        let mut meta = Meta::new();        meta.0            .insert("connector_id".to_string(), json!(CONNECTOR_ID));        meta.0            .insert("connector_name".to_string(), json!(CONNECTOR_NAME));        tool.meta = Some(meta);        Ok(ListToolsResult {            tools: vec![tool],            next_cursor: None,            meta: None,        })    }    async fn call_tool(        &self,        _request: CallToolRequestParams,        context: RequestContext<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 => {                assert_eq!(                    result.content,                    Some(json!({                        "confirmed": true,                    }))                );                "accepted"            }            ElicitationAction::Decline => "declined",            ElicitationAction::Cancel => "cancelled",        };        Ok(CallToolResult::success(vec![Content::text(output)]))    }}async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {    let state = Arc::new(AppsServerState {        expected_bearer: "Bearer chatgpt-token".to_string(),        expected_account_id: "account-123".to_string(),    });    let listener = TcpListener::bind("127.0.0.1:0").await?;    let addr = listener.local_addr()?;    let mcp_service = StreamableHttpService::new(        move || Ok(ElicitationAppsMcpServer),        Arc::new(LocalSessionManager::default()),        StreamableHttpServerConfig::default(),    );    let router = Router::new()        .route("/connectors/directory/list", get(list_directory_connectors))        .route(            "/connectors/directory/list_workspace",            get(list_directory_connectors),        )        .with_state(state)        .nest_service("/api/codex/ps/mcp", mcp_service);    let handle = tokio::spawn(async move {        let _ = axum::serve(listener, router).await;    });    Ok((format!("http://{addr}"), handle))}async fn list_directory_connectors(    State(state): State<Arc<AppsServerState>>,    headers: HeaderMap,    uri: Uri,) -> Result<Json<serde_json::Value>, StatusCode> {    let bearer_ok = headers        .get(AUTHORIZATION)        .and_then(|value| value.to_str().ok())        .is_some_and(|value| value == state.expected_bearer);    let account_ok = headers        .get("chatgpt-account-id")        .and_then(|value| value.to_str().ok())        .is_some_and(|value| value == state.expected_account_id);    let external_logos_ok = uri        .query()        .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));    if !bearer_ok || !account_ok {        Err(StatusCode::UNAUTHORIZED)    } else if !external_logos_ok {        Err(StatusCode::BAD_REQUEST)    } else {        Ok(Json(json!({            "apps": [{                "id": CONNECTOR_ID,                "name": CONNECTOR_NAME,                "description": "Calendar connector",                "logo_url": null,                "logo_url_dark": null,                "distribution_channel": null,                "branding": null,                "app_metadata": null,                "labels": null,                "install_url": null,                "is_accessible": false,                "is_enabled": true            }],            "next_token": null        })))    }}fn write_config_toml(    codex_home: &std::path::Path,    responses_server_uri: &str,    apps_server_url: &str,) -> std::io::Result<()> {    std::fs::write(        codex_home.join("config.toml"),        format!(            r#"model = "mock-model"approval_policy = "untrusted"sandbox_mode = "read-only"model_provider = "mock_provider"chatgpt_base_url = "{apps_server_url}"mcp_oauth_credentials_store = "file"[features]apps = true[model_providers.mock_provider]name = "Mock provider for test"base_url = "{responses_server_uri}/v1"wire_api = "responses"request_max_retries = 0stream_max_retries = 0"#        ),    )}