Skip to main content

harn_vm/mcp_oauth/
redirect.rs

1use std::net::TcpListener;
2use std::sync::{Arc, Mutex};
3
4use base64::Engine;
5use serde::Serialize;
6use sha2::{Digest, Sha256};
7
8use crate::mcp_auth::{OAuthAuthorizationServerMetadata, OAuthClientAuthMode};
9
10/// Inputs to an interactive MCP authorization.
11#[derive(Clone, Debug, Default)]
12pub struct BeginAuthorization {
13    pub server_url: String,
14    pub redirect_uri: String,
15    pub mode: Option<OAuthClientAuthMode>,
16    pub client_id: Option<String>,
17    pub client_secret: Option<String>,
18    pub static_secret_id: Option<String>,
19    pub scopes: Option<String>,
20}
21
22/// A legacy token that a thin client found in an older surface-specific store.
23#[derive(Clone, Debug)]
24pub struct ImportStoredToken {
25    pub server_url: String,
26    pub access_token: String,
27    pub refresh_token: Option<String>,
28    pub expires_at_unix: Option<i64>,
29    pub token_endpoint: Option<String>,
30    pub client_id: String,
31    pub client_secret: Option<String>,
32    pub token_endpoint_auth_method: Option<String>,
33    pub scopes: Option<String>,
34}
35
36/// The browser-facing result of beginning an authorization.
37#[derive(Clone, Debug, Serialize)]
38pub struct PendingAuthorization {
39    pub authorize_url: String,
40    pub state: String,
41    pub redirect_uri: String,
42    /// Canonical RFC 8707 resource indicator for the server.
43    pub resource: String,
44    /// Authorization server issuer resolved during discovery.
45    pub issuer: String,
46}
47
48/// Callback capture available to an MCP OAuth surface.
49///
50/// Protocol adapters normally provide an exact URI that their host captures.
51/// The CLI provides a shared loopback listener, which lets the Harn-owned
52/// redirect policy try the requested port and, when the selected registration
53/// mode permits it, fall back to an operating-system-assigned port.
54#[derive(Clone, Debug, Default)]
55pub enum AuthorizationCallback {
56    #[default]
57    Exact,
58    Loopback(Arc<LoopbackCallback>),
59}
60
61/// A lazily acquired loopback callback shared by one or many authorization
62/// flows. Bulk login uses one instance so every flow receives the same
63/// effective redirect URI and callbacks can be demultiplexed by OAuth state.
64#[derive(Debug)]
65pub struct LoopbackCallback {
66    requested_redirect_uri: String,
67    acquired: Mutex<Option<AcquiredLoopbackCallback>>,
68}
69
70#[derive(Debug)]
71struct AcquiredLoopbackCallback {
72    listener: TcpListener,
73    redirect_uri: String,
74}
75
76impl LoopbackCallback {
77    pub fn new(requested_redirect_uri: impl Into<String>) -> Self {
78        Self {
79            requested_redirect_uri: requested_redirect_uri.into(),
80            acquired: Mutex::new(None),
81        }
82    }
83
84    /// Take the listener after authorization preparation has selected and
85    /// acquired the effective redirect URI.
86    pub fn take_listener(&self) -> Result<(TcpListener, String), String> {
87        let acquired = self
88            .acquired
89            .lock()
90            .unwrap_or_else(|poison| poison.into_inner())
91            .take()
92            .ok_or_else(|| {
93                "MCP OAuth loopback callback was not acquired before listener handoff".to_string()
94            })?;
95        Ok((acquired.listener, acquired.redirect_uri))
96    }
97
98    pub(super) fn acquire(&self, policy: &OAuthRedirectPolicy) -> Result<String, String> {
99        let mut acquired = self
100            .acquired
101            .lock()
102            .unwrap_or_else(|poison| poison.into_inner());
103        if let Some(existing) = acquired.as_ref() {
104            policy.validate_effective_redirect(&existing.redirect_uri)?;
105            return Ok(existing.redirect_uri.clone());
106        }
107        if self.requested_redirect_uri != policy.requested_redirect_uri {
108            return Err(format!(
109                "MCP OAuth callback requested `{}` but redirect policy selected `{}` for client mode `{}`",
110                self.requested_redirect_uri,
111                policy.requested_redirect_uri,
112                policy.client_mode.as_str()
113            ));
114        }
115        let callback = acquire_loopback_callback(policy)?;
116        let redirect_uri = callback.redirect_uri.clone();
117        *acquired = Some(callback);
118        Ok(redirect_uri)
119    }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub(super) enum CallbackCapabilities {
124    Exact,
125    LoopbackWithEphemeralPort,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub(super) struct OAuthRedirectPolicy {
130    pub(super) requested_redirect_uri: String,
131    pub(super) client_mode: OAuthClientAuthMode,
132    pub(super) allow_ephemeral_port: bool,
133}
134
135impl OAuthRedirectPolicy {
136    fn validate_effective_redirect(&self, effective: &str) -> Result<(), String> {
137        if effective == self.requested_redirect_uri {
138            return Ok(());
139        }
140        let requested = validate_loopback_redirect_uri(&self.requested_redirect_uri)?;
141        let effective_url = validate_loopback_redirect_uri(effective)?;
142        let same_except_port = requested.scheme() == effective_url.scheme()
143            && requested.host_str() == effective_url.host_str()
144            && requested.path() == effective_url.path()
145            && requested.query() == effective_url.query();
146        if self.allow_ephemeral_port && same_except_port {
147            return Ok(());
148        }
149        Err(redirect_compatibility_error(
150            &self.requested_redirect_uri,
151            self.client_mode,
152            "the callback surface acquired a different redirect URI",
153            self.allow_ephemeral_port,
154        ))
155    }
156}
157
158/// Select the one redirect policy used by CLI and protocol surfaces.
159///
160/// DCR deliberately registers the URI acquired for each authorization, so a
161/// changed loopback port is safe. CIMD-native clients may vary only the
162/// loopback port under RFC 8252. A BYO client is treated as an exact-match
163/// preregistration unless the caller explicitly requests port zero.
164pub(super) fn select_redirect_policy(
165    metadata: &OAuthAuthorizationServerMetadata,
166    client_mode: OAuthClientAuthMode,
167    requested_redirect_uri: &str,
168    capabilities: CallbackCapabilities,
169) -> Result<OAuthRedirectPolicy, String> {
170    if capabilities == CallbackCapabilities::Exact {
171        return Ok(OAuthRedirectPolicy {
172            requested_redirect_uri: requested_redirect_uri.to_string(),
173            client_mode,
174            allow_ephemeral_port: false,
175        });
176    }
177
178    let parsed = validate_loopback_redirect_uri(requested_redirect_uri).map_err(|error| {
179        redirect_compatibility_error(requested_redirect_uri, client_mode, &error, false)
180    })?;
181    let explicitly_ephemeral = parsed.port() == Some(0);
182    let mode_allows_ephemeral = match client_mode {
183        OAuthClientAuthMode::Dcr => metadata.registration_endpoint.is_some(),
184        OAuthClientAuthMode::Cimd => metadata.client_id_metadata_document_supported,
185        OAuthClientAuthMode::Byo => explicitly_ephemeral,
186        OAuthClientAuthMode::Static => false,
187    };
188    Ok(OAuthRedirectPolicy {
189        requested_redirect_uri: requested_redirect_uri.to_string(),
190        client_mode,
191        allow_ephemeral_port: mode_allows_ephemeral,
192    })
193}
194
195fn validate_loopback_redirect_uri(redirect_uri: &str) -> Result<url::Url, String> {
196    let parsed = url::Url::parse(redirect_uri)
197        .map_err(|error| format!("redirect URI is invalid: {error}"))?;
198    if parsed.scheme() != "http" {
199        return Err("loopback callbacks require the `http` scheme".to_string());
200    }
201    if !parsed.username().is_empty()
202        || parsed.password().is_some()
203        || parsed.fragment().is_some()
204        || parsed.query().is_some()
205    {
206        return Err(
207            "loopback redirect URI must not contain credentials, a query, or a fragment"
208                .to_string(),
209        );
210    }
211    let host = parsed
212        .host_str()
213        .ok_or_else(|| "loopback redirect URI must include a host".to_string())?;
214    let is_loopback = host.eq_ignore_ascii_case("localhost")
215        || host
216            .parse::<std::net::IpAddr>()
217            .is_ok_and(|address| address.is_loopback());
218    if !is_loopback {
219        return Err(format!(
220            "loopback redirect URI host `{host}` is not a loopback address"
221        ));
222    }
223    Ok(parsed)
224}
225
226fn acquire_loopback_callback(
227    policy: &OAuthRedirectPolicy,
228) -> Result<AcquiredLoopbackCallback, String> {
229    let requested = validate_loopback_redirect_uri(&policy.requested_redirect_uri)?;
230    match bind_loopback_uri(requested.clone()) {
231        Ok(callback) => Ok(callback),
232        Err(requested_error) if policy.allow_ephemeral_port && requested.port() != Some(0) => {
233            let mut fallback = requested;
234            fallback.set_port(Some(0)).map_err(|()| {
235                redirect_compatibility_error(
236                    &policy.requested_redirect_uri,
237                    policy.client_mode,
238                    "could not construct an ephemeral loopback redirect URI",
239                    true,
240                )
241            })?;
242            bind_loopback_uri(fallback).map_err(|fallback_error| {
243                redirect_compatibility_error(
244                    &policy.requested_redirect_uri,
245                    policy.client_mode,
246                    &format!(
247                        "requested port bind failed ({requested_error}); operating-system port fallback also failed ({fallback_error})"
248                    ),
249                    true,
250                )
251            })
252        }
253        Err(error) => Err(redirect_compatibility_error(
254            &policy.requested_redirect_uri,
255            policy.client_mode,
256            &format!("callback listener bind failed: {error}"),
257            policy.allow_ephemeral_port,
258        )),
259    }
260}
261
262fn bind_loopback_uri(mut redirect_uri: url::Url) -> Result<AcquiredLoopbackCallback, String> {
263    let host = redirect_uri
264        .host_str()
265        .ok_or_else(|| "redirect URI must include a host".to_string())?;
266    let port = redirect_uri
267        .port_or_known_default()
268        .ok_or_else(|| "redirect URI must resolve to a callback port".to_string())?;
269    let listener = TcpListener::bind((host, port)).map_err(|error| error.to_string())?;
270    listener
271        .set_nonblocking(false)
272        .map_err(|error| format!("failed to configure callback listener: {error}"))?;
273    let actual_port = listener
274        .local_addr()
275        .map_err(|error| format!("failed to read callback listener address: {error}"))?
276        .port();
277    redirect_uri
278        .set_port(Some(actual_port))
279        .map_err(|()| "failed to record callback listener port".to_string())?;
280    Ok(AcquiredLoopbackCallback {
281        listener,
282        redirect_uri: redirect_uri.to_string(),
283    })
284}
285
286pub(super) fn redirect_compatibility_error(
287    redirect_uri: &str,
288    client_mode: OAuthClientAuthMode,
289    detail: &str,
290    ephemeral_permitted: bool,
291) -> String {
292    redirect_compatibility_error_for_mode(
293        redirect_uri,
294        client_mode.as_str(),
295        detail,
296        ephemeral_permitted,
297    )
298}
299
300pub(super) fn redirect_compatibility_error_for_mode(
301    redirect_uri: &str,
302    client_mode: &str,
303    detail: &str,
304    ephemeral_permitted: bool,
305) -> String {
306    let restriction = if ephemeral_permitted {
307        "the authorization server or local callback environment may restrict arbitrary loopback ports"
308    } else {
309        "this client mode may require the exact preregistered redirect URI and port"
310    };
311    format!(
312        "MCP OAuth redirect URI `{redirect_uri}` is unavailable for client mode `{client_mode}`: {detail}; likely compatibility restriction: {restriction}"
313    )
314}
315
316pub(super) fn generate_pkce_pair() -> (String, String) {
317    let verifier = random_hex(32);
318    let digest = Sha256::digest(verifier.as_bytes());
319    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
320    (verifier, challenge)
321}
322
323pub(super) fn random_hex(bytes: usize) -> String {
324    (0..bytes)
325        .map(|_| format!("{:02x}", rand::random::<u8>()))
326        .collect()
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::mcp_auth::OAuthClientAuthSelection;
333    use tokio::sync::Mutex as AsyncMutex;
334
335    async fn read_http_request(stream: &mut tokio::net::TcpStream) -> (String, Vec<u8>) {
336        let mut request = Vec::new();
337        let mut buffer = [0_u8; 4096];
338        loop {
339            let read = tokio::io::AsyncReadExt::read(stream, &mut buffer)
340                .await
341                .unwrap();
342            if read == 0 {
343                break;
344            }
345            request.extend_from_slice(&buffer[..read]);
346            if let Some(headers_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
347                let headers = String::from_utf8_lossy(&request[..headers_end]);
348                let content_length = headers
349                    .lines()
350                    .find_map(|line| {
351                        line.to_ascii_lowercase()
352                            .strip_prefix("content-length:")
353                            .and_then(|value| value.trim().parse::<usize>().ok())
354                    })
355                    .unwrap_or_default();
356                if request.len() >= headers_end + 4 + content_length {
357                    let target = headers
358                        .lines()
359                        .next()
360                        .and_then(|line| line.split_whitespace().nth(1))
361                        .unwrap()
362                        .to_string();
363                    return (target, request[headers_end + 4..].to_vec());
364                }
365            }
366        }
367        panic!("incomplete HTTP request");
368    }
369
370    async fn write_http_response(
371        stream: &mut tokio::net::TcpStream,
372        status: &str,
373        headers: &[(&str, String)],
374        body: &str,
375    ) {
376        let mut response = format!(
377            "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
378            body.len()
379        );
380        for (name, value) in headers {
381            response.push_str(&format!("{name}: {value}\r\n"));
382        }
383        response.push_str("\r\n");
384        response.push_str(body);
385        tokio::io::AsyncWriteExt::write_all(stream, response.as_bytes())
386            .await
387            .unwrap();
388    }
389
390    #[test]
391    fn pkce_challenge_is_s256_of_verifier() {
392        let (verifier, challenge) = generate_pkce_pair();
393        let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD
394            .encode(Sha256::digest(verifier.as_bytes()));
395        assert_eq!(challenge, expected);
396        assert_eq!(verifier.len(), 64);
397    }
398
399    fn test_metadata() -> OAuthAuthorizationServerMetadata {
400        OAuthAuthorizationServerMetadata {
401            issuer: "https://auth.example".to_string(),
402            authorization_endpoint: "https://auth.example/authorize".to_string(),
403            token_endpoint: "https://auth.example/token".to_string(),
404            registration_endpoint: None,
405            token_endpoint_auth_methods_supported: vec!["none".to_string()],
406            code_challenge_methods_supported: vec!["S256".to_string()],
407            scopes_supported: Vec::new(),
408            client_id_metadata_document_supported: false,
409            authorization_response_iss_parameter_supported: false,
410            extra: Default::default(),
411        }
412    }
413
414    #[test]
415    fn exact_match_preregistered_redirect_does_not_change_ports() {
416        let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
417        let redirect_uri = format!(
418            "http://127.0.0.1:{}/oauth/callback",
419            blocker.local_addr().unwrap().port()
420        );
421        let policy = select_redirect_policy(
422            &test_metadata(),
423            OAuthClientAuthMode::Byo,
424            &redirect_uri,
425            CallbackCapabilities::LoopbackWithEphemeralPort,
426        )
427        .unwrap();
428        assert!(!policy.allow_ephemeral_port);
429
430        let error = acquire_loopback_callback(&policy).unwrap_err();
431        assert!(error.contains(&redirect_uri), "{error}");
432        assert!(error.contains("client mode `byo`"), "{error}");
433        assert!(error.contains("exact preregistered"), "{error}");
434    }
435
436    #[test]
437    fn dynamic_registration_falls_back_after_fixed_port_conflict() {
438        let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
439        let blocked_port = blocker.local_addr().unwrap().port();
440        let redirect_uri = format!("http://127.0.0.1:{blocked_port}/oauth/callback");
441        let mut metadata = test_metadata();
442        metadata.registration_endpoint = Some("https://auth.example/register".to_string());
443        let policy = select_redirect_policy(
444            &metadata,
445            OAuthClientAuthMode::Dcr,
446            &redirect_uri,
447            CallbackCapabilities::LoopbackWithEphemeralPort,
448        )
449        .unwrap();
450
451        let acquired = acquire_loopback_callback(&policy).unwrap();
452        let effective = url::Url::parse(&acquired.redirect_uri).unwrap();
453        assert_ne!(effective.port(), Some(blocked_port));
454        assert_ne!(effective.port(), Some(0));
455        assert_eq!(effective.path(), "/oauth/callback");
456    }
457
458    #[test]
459    fn cimd_loopback_policy_accepts_operating_system_port() {
460        let mut metadata = test_metadata();
461        metadata.client_id_metadata_document_supported = true;
462        let policy = select_redirect_policy(
463            &metadata,
464            OAuthClientAuthMode::Cimd,
465            "http://127.0.0.1:0/oauth/callback",
466            CallbackCapabilities::LoopbackWithEphemeralPort,
467        )
468        .unwrap();
469        let acquired = acquire_loopback_callback(&policy).unwrap();
470        let effective = url::Url::parse(&acquired.redirect_uri).unwrap();
471        assert_ne!(effective.port(), Some(0));
472        policy
473            .validate_effective_redirect(&acquired.redirect_uri)
474            .unwrap();
475    }
476
477    #[test]
478    fn protocol_host_exact_redirect_is_preserved() {
479        let policy = select_redirect_policy(
480            &test_metadata(),
481            OAuthClientAuthMode::Byo,
482            "burin-labs://oauth/callback",
483            CallbackCapabilities::Exact,
484        )
485        .unwrap();
486        assert_eq!(policy.requested_redirect_uri, "burin-labs://oauth/callback");
487        assert!(!policy.allow_ephemeral_port);
488    }
489
490    #[tokio::test]
491    async fn strict_server_observes_acquired_redirect_and_oauth_bindings() {
492        let authorization_server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
493        let issuer = format!("http://{}", authorization_server.local_addr().unwrap());
494        let registered_redirect = Arc::new(AsyncMutex::new(None::<String>));
495        let observed_redirect = registered_redirect.clone();
496        let server_issuer = issuer.clone();
497        let server = tokio::spawn(async move {
498            for _ in 0..4 {
499                let (mut stream, _) = authorization_server.accept().await.unwrap();
500                let (target, body) = read_http_request(&mut stream).await;
501                match target.as_str() {
502                    "/mcp" => {
503                        write_http_response(
504                            &mut stream,
505                            "401 Unauthorized",
506                            &[(
507                                "WWW-Authenticate",
508                                format!(
509                                    "Bearer resource_metadata=\"{server_issuer}/.well-known/oauth-protected-resource/mcp\""
510                                ),
511                            )],
512                            "",
513                        )
514                        .await;
515                    }
516                    "/.well-known/oauth-protected-resource/mcp" => {
517                        let response = serde_json::json!({
518                            "resource": format!("{server_issuer}/mcp"),
519                            "authorization_servers": [&server_issuer],
520                            "scopes_supported": ["mcp.read"]
521                        })
522                        .to_string();
523                        write_http_response(
524                            &mut stream,
525                            "200 OK",
526                            &[("Content-Type", "application/json".to_string())],
527                            &response,
528                        )
529                        .await;
530                    }
531                    "/.well-known/oauth-authorization-server" => {
532                        let response = serde_json::json!({
533                            "issuer": &server_issuer,
534                            "authorization_endpoint": format!("{server_issuer}/authorize"),
535                            "token_endpoint": format!("{server_issuer}/token"),
536                            "registration_endpoint": format!("{server_issuer}/register"),
537                            "token_endpoint_auth_methods_supported": ["none"],
538                            "code_challenge_methods_supported": ["S256"]
539                        })
540                        .to_string();
541                        write_http_response(
542                            &mut stream,
543                            "200 OK",
544                            &[("Content-Type", "application/json".to_string())],
545                            &response,
546                        )
547                        .await;
548                    }
549                    "/register" => {
550                        let registration: serde_json::Value =
551                            serde_json::from_slice(&body).unwrap();
552                        *observed_redirect.lock().await = registration["redirect_uris"][0]
553                            .as_str()
554                            .map(str::to_string);
555                        write_http_response(
556                            &mut stream,
557                            "201 Created",
558                            &[("Content-Type", "application/json".to_string())],
559                            r#"{"client_id":"strict-client","token_endpoint_auth_method":"none"}"#,
560                        )
561                        .await;
562                    }
563                    _ => panic!("unexpected strict-server request target: {target}"),
564                }
565            }
566        });
567
568        let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
569        let requested_redirect = format!(
570            "http://127.0.0.1:{}/oauth/callback",
571            blocker.local_addr().unwrap().port()
572        );
573        let callback = Arc::new(LoopbackCallback::new(&requested_redirect));
574        let pending = super::super::begin_authorization_with_callback(
575            BeginAuthorization {
576                server_url: format!("{issuer}/mcp"),
577                redirect_uri: requested_redirect.clone(),
578                mode: Some(OAuthClientAuthMode::Dcr),
579                ..BeginAuthorization::default()
580            },
581            &AuthorizationCallback::Loopback(callback.clone()),
582        )
583        .await
584        .unwrap();
585        let (_listener, effective_redirect) = callback.take_listener().unwrap();
586        server.await.unwrap();
587
588        assert_ne!(effective_redirect, requested_redirect);
589        assert_eq!(pending.redirect_uri, effective_redirect);
590        assert_eq!(
591            registered_redirect.lock().await.as_deref(),
592            Some(effective_redirect.as_str())
593        );
594        assert_eq!(pending.resource, format!("{issuer}/mcp"));
595        assert_eq!(pending.issuer, issuer);
596        let authorize_url = url::Url::parse(&pending.authorize_url).unwrap();
597        let query = authorize_url
598            .query_pairs()
599            .collect::<std::collections::HashMap<_, _>>();
600        assert_eq!(
601            query.get("redirect_uri").map(|value| value.as_ref()),
602            Some(effective_redirect.as_str())
603        );
604        assert_eq!(
605            query.get("resource").map(|value| value.as_ref()),
606            Some(pending.resource.as_str())
607        );
608        assert_eq!(
609            query.get("state").map(|value| value.as_ref()),
610            Some(pending.state.as_str())
611        );
612        assert_eq!(
613            query
614                .get("code_challenge_method")
615                .map(|value| value.as_ref()),
616            Some("S256")
617        );
618        assert!(query.contains_key("code_challenge"));
619    }
620
621    #[tokio::test]
622    async fn dynamic_registration_reregisters_effective_redirect_after_port_change() {
623        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
624        let endpoint = format!("http://{}/register", listener.local_addr().unwrap());
625        let registered = Arc::new(AsyncMutex::new(Vec::<String>::new()));
626        let server_registered = registered.clone();
627        let server = tokio::spawn(async move {
628            for index in 0..2 {
629                let (mut stream, _) = listener.accept().await.unwrap();
630                let mut request = Vec::new();
631                let mut buffer = [0_u8; 4096];
632                loop {
633                    let read = tokio::io::AsyncReadExt::read(&mut stream, &mut buffer)
634                        .await
635                        .unwrap();
636                    if read == 0 {
637                        break;
638                    }
639                    request.extend_from_slice(&buffer[..read]);
640                    if let Some(headers_end) =
641                        request.windows(4).position(|window| window == b"\r\n\r\n")
642                    {
643                        let headers = String::from_utf8_lossy(&request[..headers_end]);
644                        let content_length = headers
645                            .lines()
646                            .find_map(|line| {
647                                line.to_ascii_lowercase()
648                                    .strip_prefix("content-length:")
649                                    .and_then(|value| value.trim().parse::<usize>().ok())
650                            })
651                            .unwrap_or_default();
652                        if request.len() >= headers_end + 4 + content_length {
653                            break;
654                        }
655                    }
656                }
657                let body_start = request
658                    .windows(4)
659                    .position(|window| window == b"\r\n\r\n")
660                    .unwrap()
661                    + 4;
662                let body: serde_json::Value =
663                    serde_json::from_slice(&request[body_start..]).unwrap();
664                server_registered
665                    .lock()
666                    .await
667                    .push(body["redirect_uris"][0].as_str().unwrap().to_string());
668                let response_body = format!(
669                    r#"{{"client_id":"client-{index}","token_endpoint_auth_method":"none"}}"#
670                );
671                let response = format!(
672                    "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
673                    response_body.len(),
674                    response_body
675                );
676                tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
677                    .await
678                    .unwrap();
679            }
680        });
681
682        let mut metadata = test_metadata();
683        metadata.registration_endpoint = Some(endpoint);
684        for redirect_uri in [
685            "http://127.0.0.1:49152/oauth/callback",
686            "http://127.0.0.1:49153/oauth/callback",
687        ] {
688            super::super::resolve_selected_client(
689                &metadata,
690                OAuthClientAuthSelection {
691                    mode: OAuthClientAuthMode::Dcr,
692                    client_id: None,
693                },
694                None,
695                redirect_uri,
696                Some("mcp.read"),
697            )
698            .await
699            .unwrap();
700        }
701        server.await.unwrap();
702        assert_eq!(
703            *registered.lock().await,
704            vec![
705                "http://127.0.0.1:49152/oauth/callback",
706                "http://127.0.0.1:49153/oauth/callback"
707            ]
708        );
709    }
710}