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
3 changes: 2 additions & 1 deletion codex-rs/app-server-protocol/schema/json/ClientRequest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/v2/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ pub struct McpServerOauthLoginParams {
pub name: String,
#[ts(optional = nullable)]
pub thread_id: Option<String>,
/// Registration strategy for this login only; omission preserves automatic DCR.
/// Registration strategy for this login only; omission selects automatic discovery.
#[ts(optional = nullable)]
pub client_registration: Option<McpServerOauthClientRegistration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand All @@ -213,6 +213,7 @@ pub struct McpServerOauthLoginParams {
pub enum McpServerOauthClientRegistration {
#[default]
Auto,
Cimd,
Dcr,
}

Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ Example with notification opt-out:
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth. For remote installs, clients may include an optional `installAttemptId`; app-server forwards it unchanged as `install_attempt_id` in the backend POST body, while omission preserves the legacy empty-body request (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, optionally pass `clientRegistration` (`auto` or `dcr`) to select registration for this login only, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. Omitting `clientRegistration` preserves automatic DCR; the override is never persisted in server configuration.
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, optionally pass `clientRegistration` (`auto`, `cimd`, or `dcr`) to override client registration for this login only, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. Omitting `clientRegistration` automatically discovers the authorization server's supported registration methods; the override is never persisted in server configuration.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, owning `pluginId` (`null` for servers not contributed by a plugin), plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. An `unknown` auth status means OAuth support could not be determined; `unsupported` means OAuth is known not to be supported.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ impl McpRequestProcessor {
} = params;
let client_registration = match client_registration.unwrap_or_default() {
McpServerOauthClientRegistration::Auto => McpOAuthClientRegistration::Auto,
McpServerOauthClientRegistration::Cimd => McpOAuthClientRegistration::Cimd,
McpServerOauthClientRegistration::Dcr => McpOAuthClientRegistration::Dcr,
};

Expand Down
239 changes: 239 additions & 0 deletions codex-rs/app-server/tests/suite/v2/mcp_server_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@ use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;

use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use axum::Json;
use axum::Router;
use axum::body::Bytes;
use axum::http::HeaderMap;
use axum::routing::get;
use axum::routing::post;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
Expand All @@ -39,6 +48,7 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tokio::time::timeout;
Expand Down Expand Up @@ -80,6 +90,235 @@ fn assert_dynamic_status(response: &ListMcpServerStatusResponse, process_label:
);
}

#[tokio::test]
async fn oauth_login_automatically_selects_callback_specific_cimd_without_metadata_issuer()
-> Result<()> {
let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let base_url = format!("http://{}", listener.local_addr()?);
let metadata = json!({
"authorization_endpoint": format!("{base_url}/authorize"),
"token_endpoint": format!("{base_url}/token"),
"registration_endpoint": format!("{base_url}/register"),
"client_id_metadata_document_supported": true,
"token_endpoint_auth_methods_supported": ["none"],
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
});
let registrations = Arc::new(AtomicUsize::new(0));
let registration_count = Arc::clone(&registrations);
let token_count = Arc::new(AtomicUsize::new(0));
let (token_request_tx, mut token_request_rx) = mpsc::unbounded_channel();
let (mcp_authorization_tx, mut mcp_authorization_rx) = mpsc::unbounded_channel();
let tool_name = Arc::new("cimd".to_string());
let mcp_service = StreamableHttpService::new(
move || {
Ok(McpStatusServer {
tool_name: Arc::clone(&tool_name),
})
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let mcp_router =
Router::new()
.nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn(
move |request: axum::extract::Request, next: axum::middleware::Next| {
let mcp_authorization_tx = mcp_authorization_tx.clone();
async move {
if let Some(authorization) = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
let _ = mcp_authorization_tx.send(authorization.to_string());
}
next.run(request).await
}
},
));
let oauth_server = Router::new()
.route(
"/.well-known/oauth-authorization-server/mcp",
get(move || {
let metadata = metadata.clone();
async move { Json(metadata) }
}),
)
.route(
"/register",
post(move || {
let registrations = Arc::clone(&registration_count);
async move {
registrations.fetch_add(1, Ordering::SeqCst);
Json(json!({"client_id": "unexpected-dcr-client"}))
}
}),
)
.route(
"/token",
post(move |headers: HeaderMap, body: Bytes| {
let token_request_tx = token_request_tx.clone();
let token_count = Arc::clone(&token_count);
async move {
let _ = token_request_tx.send((
String::from_utf8_lossy(&body).into_owned(),
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string),
));
if token_count.fetch_add(1, Ordering::SeqCst) == 0 {
Json(json!({
"access_token": "expired-cimd-access-token",
"token_type": "Bearer",
"expires_in": 0,
"refresh_token": "test-refresh-token",
}))
} else {
Json(json!({
"access_token": "refreshed-cimd-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "test-refresh-token",
}))
}
}
}),
)
.merge(mcp_router);
let oauth_server_handle = tokio::spawn(async move {
let _ = axum::serve(listener, oauth_server).await;
});

let codex_home = TempDir::new()?;
mock_responses_config(&responses_server.uri())
.with_extra_config(&format!(
"mcp_oauth_credentials_store = \"file\"\n[mcp_servers.cimd]\nurl = \"{base_url}/mcp\""
))
.write(codex_home.path())?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;

let request_id = app_server
.send_raw_request(
"mcpServer/oauth/login",
Some(json!({"name": "cimd", "timeoutSecs": 10})),
)
.await?;
let response: McpServerOauthLoginResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
let authorization_url = reqwest::Url::parse(&response.authorization_url)?;
let parameters = authorization_url
.query_pairs()
.into_owned()
.collect::<BTreeMap<String, String>>();
let redirect_uri = parameters["redirect_uri"].clone();
let mut callback_url = reqwest::Url::parse(&redirect_uri)?;
let callback_id = callback_url
.path()
.strip_prefix("/callback/")
.expect("issuerless CIMD should use a resource-specific callback");
let client_id = format!("https://chatgpt.com/oauth/codex/{callback_id}/client.json");
assert_eq!(parameters.get("client_id"), Some(&client_id));
assert_eq!(
parameters.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert_eq!(registrations.load(Ordering::SeqCst), 0);

callback_url
.query_pairs_mut()
.append_pair("code", "cimd-authorization-code")
.append_pair("state", &parameters["state"]);
reqwest::Client::builder()
.no_proxy()
.build()?
.get(callback_url)
.send()
.await?
.error_for_status()?;
let (token_request, token_authorization) =
timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv())
.await?
.expect("CIMD authorization should exchange its authorization code");
let token_parameters = url::form_urlencoded::parse(token_request.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>();
assert_eq!(token_parameters.get("client_id"), Some(&client_id));
assert!(token_parameters.contains_key("code_verifier"));
assert_eq!(token_authorization, None);

let completed: McpServerOauthLoginCompletedNotification = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_notification("mcpServer/oauthLogin/completed"),
)
.await??;
assert_eq!(
completed,
McpServerOauthLoginCompletedNotification {
name: "cimd".to_string(),
thread_id: None,
success: true,
error: None,
}
);
assert_eq!(registrations.load(Ordering::SeqCst), 0);

let request_id = app_server
.send_raw_request("config/mcpServer/reload", /*params*/ None)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ListMcpServerStatusResponse = app_server
.request(|request_id| ClientRequest::McpServerStatusList {
request_id,
params: ListMcpServerStatusParams {
cursor: None,
limit: None,
detail: Some(McpServerStatusDetail::Full),
thread_id: None,
},
})
.await?;
let (refresh_request, refresh_authorization) =
timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv())
.await?
.expect("expired CIMD token should be refreshed");
let refresh_parameters = url::form_urlencoded::parse(refresh_request.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>();
assert_eq!(
refresh_parameters.get("grant_type").map(String::as_str),
Some("refresh_token")
);
assert_eq!(
refresh_parameters.get("refresh_token").map(String::as_str),
Some("test-refresh-token")
);
assert_eq!(refresh_parameters.get("client_id"), Some(&client_id));
assert!(!refresh_parameters.contains_key("client_secret"));
assert_eq!(refresh_authorization, None);
assert_eq!(
timeout(DEFAULT_READ_TIMEOUT, mcp_authorization_rx.recv())
.await?
.expect("MCP startup should use the refreshed token"),
"Bearer refreshed-cimd-access-token"
);
assert_eq!(registrations.load(Ordering::SeqCst), 0);

oauth_server_handle.abort();
let _ = oauth_server_handle.await;
Ok(())
}

#[tokio::test]
async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
Expand Down
Loading
Loading