Compare commits

...

1 Commits

Author SHA1 Message Date
Steven Lee
12d375dc8e Support OAuth client metadata URLs for MCP login 2026-05-13 15:19:42 -07:00
13 changed files with 353 additions and 5 deletions

View File

@@ -1560,6 +1560,12 @@
},
"McpServerOauthLoginParams": {
"properties": {
"clientMetadataUrlBase": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},

View File

@@ -10742,6 +10742,12 @@
"McpServerOauthLoginParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"clientMetadataUrlBase": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},

View File

@@ -7291,6 +7291,12 @@
"McpServerOauthLoginParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"clientMetadataUrlBase": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},

View File

@@ -1,6 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"clientMetadataUrlBase": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},

View File

@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type McpServerOauthLoginParams = { name: string, scopes?: Array<string> | null, timeoutSecs?: bigint | null, };
export type McpServerOauthLoginParams = { name: string, scopes?: Array<string> | null, clientMetadataUrlBase?: string | null, timeoutSecs?: bigint | null, };

View File

@@ -1726,6 +1726,7 @@ mod tests {
params: v2::McpServerOauthLoginParams {
name: "server-a".to_string(),
scopes: None,
client_metadata_url_base: None,
timeout_secs: None,
},
};

View File

@@ -187,6 +187,9 @@ pub struct McpServerOauthLoginParams {
pub scopes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub client_metadata_url_base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub timeout_secs: Option<i64>,
}

View File

@@ -206,7 +206,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 (**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; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. Clients may optionally pass `clientMetadataUrlBase`; the app server appends its deterministic callback id and `client.json`, then sends the resulting URL as the OAuth client id metadata document.
- `tool/requestUserInput` — prompt the user with 13 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 and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`.

View File

@@ -117,6 +117,7 @@ impl McpRequestProcessor {
let McpServerOauthLoginParams {
name,
scopes,
client_metadata_url_base,
timeout_secs,
} = params;
@@ -161,6 +162,7 @@ impl McpRequestProcessor {
env_http_headers,
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
client_metadata_url_base.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),

View File

@@ -1350,6 +1350,7 @@ impl PluginRequestProcessor {
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
/*client_metadata_url_base*/ None,
callback_port,
callback_url.as_deref(),
)
@@ -1365,6 +1366,7 @@ impl PluginRequestProcessor {
oauth_config.env_http_headers,
&[],
server.oauth_resource.as_deref(),
/*client_metadata_url_base*/ None,
callback_port,
callback_url.as_deref(),
)

View File

@@ -211,6 +211,7 @@ async fn perform_oauth_login_retry_without_scopes(
env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_resource,
/*client_metadata_url_base*/ None,
callback_port,
callback_url,
)
@@ -227,6 +228,7 @@ async fn perform_oauth_login_retry_without_scopes(
env_http_headers,
&[],
oauth_resource,
/*client_metadata_url_base*/ None,
callback_port,
callback_url,
)

View File

@@ -159,6 +159,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server_config.oauth_resource.as_deref(),
/*client_metadata_url_base*/ None,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -174,6 +175,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.env_http_headers,
&[],
server_config.oauth_resource.as_deref(),
/*client_metadata_url_base*/ None,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)

View File

@@ -82,6 +82,7 @@ pub async fn perform_oauth_login(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
client_metadata_url_base: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -93,6 +94,7 @@ pub async fn perform_oauth_login(
env_http_headers,
scopes,
oauth_resource,
client_metadata_url_base,
callback_port,
callback_url,
/*emit_browser_url*/ true,
@@ -109,6 +111,7 @@ pub async fn perform_oauth_login_silent(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
client_metadata_url_base: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -120,6 +123,7 @@ pub async fn perform_oauth_login_silent(
env_http_headers,
scopes,
oauth_resource,
client_metadata_url_base,
callback_port,
callback_url,
/*emit_browser_url*/ false,
@@ -136,6 +140,7 @@ async fn perform_oauth_login_with_browser_output(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
client_metadata_url_base: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
emit_browser_url: bool,
@@ -151,6 +156,7 @@ async fn perform_oauth_login_with_browser_output(
headers,
scopes,
oauth_resource,
client_metadata_url_base,
/*launch_browser*/ true,
callback_port,
callback_url,
@@ -170,6 +176,7 @@ pub async fn perform_oauth_login_return_url(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
client_metadata_url_base: Option<&str>,
timeout_secs: Option<i64>,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -185,6 +192,7 @@ pub async fn perform_oauth_login_return_url(
headers,
scopes,
oauth_resource,
client_metadata_url_base,
/*launch_browser*/ false,
callback_port,
callback_url,
@@ -407,6 +415,35 @@ fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) ->
Ok(parsed.to_string())
}
fn build_callback_scoped_client_metadata_url(
client_metadata_url_base: &str,
callback_id: &str,
redirect_uri: &str,
) -> Result<String> {
let mut parsed = Url::parse(client_metadata_url_base).with_context(|| {
format!("invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`")
})?;
if parsed.query().is_some() {
bail!(
"invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`: query is not allowed"
);
}
if parsed.fragment().is_some() {
bail!(
"invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`: fragment is not allowed"
);
}
let base_path = parsed.path().trim_end_matches('/');
parsed.set_path(&format!("{base_path}/{callback_id}/client.json"));
Ok(set_query_param(
parsed.as_str(),
"redirect_uri",
redirect_uri,
))
}
fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result<String> {
let parsed = Url::parse(redirect_uri)
.with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?;
@@ -437,6 +474,7 @@ impl OauthLoginFlow {
headers: OauthHeaders,
scopes: &[String],
oauth_resource: Option<&str>,
client_metadata_url_base: Option<&str>,
launch_browser: bool,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -459,6 +497,11 @@ impl OauthLoginFlow {
let redirect_uri = resolve_redirect_uri(&server, callback_url)?;
let callback_id = callback_id_from_server_url(server_url)?;
let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?;
let client_metadata_url = client_metadata_url_base
.map(|url_base| {
build_callback_scoped_client_metadata_url(url_base, &callback_id, &redirect_uri)
})
.transpose()?;
let callback_path = callback_path_from_redirect_uri(&redirect_uri)?;
let (tx, rx) = oneshot::channel();
@@ -473,9 +516,20 @@ impl OauthLoginFlow {
let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?;
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
oauth_state
.start_authorization(&scope_refs, &redirect_uri, Some("Codex"))
.await?;
if let Some(client_metadata_url) = client_metadata_url.as_deref() {
oauth_state
.start_authorization_with_metadata_url(
&scope_refs,
&redirect_uri,
Some("Codex"),
Some(client_metadata_url),
)
.await?;
} else {
oauth_state
.start_authorization(&scope_refs, &redirect_uri, Some("Codex"))
.await?;
}
let auth_url = append_query_param(
&oauth_state.get_authorization_url().await?,
"resource",
@@ -602,17 +656,228 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
format!("{url}{separator}{key}={encoded}")
}
fn set_query_param(url: &str, key: &str, value: &str) -> String {
if let Ok(mut parsed) = Url::parse(url) {
let existing_pairs: Vec<(String, String)> = parsed
.query_pairs()
.filter(|(existing_key, _)| existing_key != key)
.map(|(existing_key, existing_value)| {
(existing_key.into_owned(), existing_value.into_owned())
})
.collect();
{
let mut query = parsed.query_pairs_mut();
query.clear();
for (existing_key, existing_value) in existing_pairs {
query.append_pair(&existing_key, &existing_value);
}
query.append_pair(key, value);
}
return parsed.to_string();
}
append_query_param(url, key, Some(value))
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use reqwest::Url;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tiny_http::Header;
use tiny_http::Method;
use tiny_http::Response;
use tiny_http::Server;
use super::CallbackOutcome;
use super::OAuthProviderError;
use super::OauthHeaders;
use super::OauthLoginFlow;
use super::append_callback_id_to_redirect_uri;
use super::append_query_param;
use super::build_callback_scoped_client_metadata_url;
use super::callback_id_from_server_url;
use super::callback_path_from_redirect_uri;
use super::parse_oauth_callback;
use super::set_query_param;
use codex_config::types::OAuthCredentialsStoreMode;
struct TestOAuthServer {
server: Arc<Server>,
base_url: String,
registration_hits: Arc<AtomicUsize>,
}
impl TestOAuthServer {
fn new(supports_metadata_url: bool) -> Self {
let server = Arc::new(Server::http("127.0.0.1:0").expect("bind test OAuth server"));
let base_url = local_server_base_url(&server);
let registration_hits = Arc::new(AtomicUsize::new(0));
let server_for_thread = Arc::clone(&server);
let hits_for_thread = Arc::clone(&registration_hits);
let base_url_for_thread = base_url.clone();
std::thread::spawn(move || {
while let Ok(request) = server_for_thread.recv() {
let path = request
.url()
.split_once('?')
.map(|(path, _)| path)
.unwrap_or_else(|| request.url());
match (request.method(), path) {
(&Method::Get, "/.well-known/oauth-authorization-server") => {
let body = serde_json::json!({
"authorization_endpoint": format!("{base_url_for_thread}/authorize"),
"token_endpoint": format!("{base_url_for_thread}/token"),
"registration_endpoint": format!("{base_url_for_thread}/register"),
"response_types_supported": ["code"],
"client_id_metadata_document_supported": supports_metadata_url,
});
let _ = request.respond(json_response(body));
}
(&Method::Post, "/register") => {
hits_for_thread.fetch_add(1, Ordering::SeqCst);
let body = serde_json::json!({
"client_id": "dynamic-client",
"client_secret": null,
"client_name": "Codex",
"redirect_uris": ["http://127.0.0.1/callback"],
});
let _ = request.respond(json_response(body));
}
_ => {
let _ = request
.respond(Response::from_string("not found").with_status_code(404));
}
}
}
});
Self {
server,
base_url,
registration_hits,
}
}
fn base_url(&self) -> &str {
&self.base_url
}
fn registration_hits(&self) -> usize {
self.registration_hits.load(Ordering::SeqCst)
}
}
impl Drop for TestOAuthServer {
fn drop(&mut self) {
self.server.unblock();
}
}
fn local_server_base_url(server: &Server) -> String {
match server.server_addr() {
tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => {
format!("http://{}:{}", addr.ip(), addr.port())
}
tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => {
format!("http://[{}]:{}", addr.ip(), addr.port())
}
#[cfg(not(target_os = "windows"))]
_ => panic!("unexpected test server address"),
}
}
fn json_response(body: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body.to_string()).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
.expect("valid content-type header"),
)
}
async fn start_test_oauth_flow(
oauth_server: &TestOAuthServer,
client_metadata_url_base: Option<&str>,
) -> String {
let flow = OauthLoginFlow::new(
"test-server",
oauth_server.base_url(),
OAuthCredentialsStoreMode::File,
OauthHeaders {
http_headers: None,
env_http_headers: None,
},
&[],
/*oauth_resource*/ None,
client_metadata_url_base,
/*launch_browser*/ false,
/*callback_port*/ None,
/*callback_url*/ None,
Some(1),
)
.await
.expect("start OAuth flow");
flow.authorization_url()
}
fn authorization_url_client_id(authorization_url: &str) -> String {
Url::parse(authorization_url)
.expect("valid authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "client_id").then(|| value.into_owned()))
.expect("authorization URL includes client_id")
}
fn client_id_redirect_uri(authorization_url: &str) -> String {
let client_id = authorization_url_client_id(authorization_url);
Url::parse(&client_id)
.expect("client_id should be a URL")
.query_pairs()
.find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned()))
.expect("client_id includes redirect_uri")
}
#[tokio::test]
async fn authorization_uses_callback_scoped_client_metadata_url_as_client_id_when_supplied() {
let oauth_server = TestOAuthServer::new(/*supports_metadata_url*/ true);
let client_metadata_url_base = "https://chatgpt.com/codex/local-mcp/oauth";
let authorization_url =
start_test_oauth_flow(&oauth_server, Some(client_metadata_url_base)).await;
let client_id = authorization_url_client_id(&authorization_url);
let client_id_url = Url::parse(&client_id).expect("client ID should parse as URL");
let callback_id =
callback_id_from_server_url(oauth_server.base_url()).expect("server URL should parse");
assert_eq!(
client_id_url.path(),
format!("/codex/local-mcp/oauth/{callback_id}/client.json")
);
assert_eq!(
Url::parse(&client_id_redirect_uri(&authorization_url))
.expect("redirect URI should parse")
.path(),
format!("/callback/{callback_id}")
);
assert_eq!(oauth_server.registration_hits(), 0);
}
#[tokio::test]
async fn authorization_omits_client_metadata_url_by_default() {
let oauth_server = TestOAuthServer::new(/*supports_metadata_url*/ true);
let authorization_url =
start_test_oauth_flow(&oauth_server, /*client_metadata_url_base*/ None).await;
assert_eq!(
authorization_url_client_id(&authorization_url),
"dynamic-client"
);
assert_eq!(oauth_server.registration_hits(), 1);
}
#[test]
fn parse_oauth_callback_accepts_default_path() {
@@ -717,6 +982,39 @@ mod tests {
);
}
#[test]
fn client_metadata_url_base_is_scoped_to_callback_id() {
let client_metadata_url = build_callback_scoped_client_metadata_url(
"https://chatgpt.com/codex/local-mcp/oauth/",
"abc123",
"http://127.0.0.1:4321/callback/abc123",
)
.expect("client metadata URL should build");
assert_eq!(
client_metadata_url,
"https://chatgpt.com/codex/local-mcp/oauth/abc123/client.json?redirect_uri=http%3A%2F%2F127.0.0.1%3A4321%2Fcallback%2Fabc123"
);
}
#[test]
fn client_metadata_url_base_rejects_query_or_fragment() {
for url in [
"https://chatgpt.com/codex/local-mcp/oauth?variant=one",
"https://chatgpt.com/codex/local-mcp/oauth#fragment",
] {
assert!(
build_callback_scoped_client_metadata_url(
url,
"abc123",
"http://127.0.0.1:4321/callback/abc123",
)
.is_err(),
"expected `{url}` to be rejected"
);
}
}
#[test]
fn append_query_param_adds_resource_to_absolute_url() {
let url = append_query_param(
@@ -748,4 +1046,18 @@ mod tests {
assert_eq!(url, "not a url?resource=api%2Fresource");
}
#[test]
fn set_query_param_replaces_existing_value() {
let url = set_query_param(
"https://example.com/client.json?foo=bar&redirect_uri=old",
"redirect_uri",
"http://127.0.0.1:4321/callback/abc123",
);
assert_eq!(
url,
"https://example.com/client.json?foo=bar&redirect_uri=http%3A%2F%2F127.0.0.1%3A4321%2Fcallback%2Fabc123"
);
}
}