1use std::collections::HashMap;
25use std::sync::{Arc, Mutex};
26use std::time::Duration;
27
28use async_trait::async_trait;
29use futures::stream::StreamExt;
30use serde::{Deserialize, Serialize};
31use tokio::sync::broadcast;
32
33use crate::mcp_auth::{canonical_resource_indicator, OAuthClientAuthMode};
34use crate::mcp_oauth::{self, BeginAuthorization, PendingAuthorization, StoredMcpToken};
35
36const STATUS_CHANNEL_CAPACITY: usize = 256;
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum BulkAuthMode {
44 Missing,
47 Expired,
51 All,
53}
54
55#[derive(Clone, Debug, Default)]
58pub struct BulkAuthServer {
59 pub name: String,
60 pub server_url: String,
61 pub mode: Option<OAuthClientAuthMode>,
62 pub client_id: Option<String>,
63 pub client_secret: Option<String>,
64 pub static_secret_id: Option<String>,
65 pub scopes: Option<String>,
66}
67
68#[derive(Clone, Debug, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct PreparedFlow {
74 pub name: String,
75 pub server_url: String,
76 pub authorize_url: String,
77 pub state: String,
78 pub redirect_uri: String,
79}
80
81#[derive(Clone, Debug)]
83pub enum PrepareOutcome {
84 Pending(PreparedFlow),
86 Skipped {
88 name: String,
89 server_url: String,
90 reason: String,
91 },
92 Failed {
94 name: String,
95 server_url: String,
96 error: String,
97 },
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum McpAuthPhase {
104 Discovering,
106 AwaitingConsent,
108 Exchanging,
110 Connected,
112 Failed,
114 Skipped,
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub struct McpAuthStatus {
122 pub server: String,
124 pub server_url: String,
126 pub phase: McpAuthPhase,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub detail: Option<String>,
131}
132
133#[derive(Clone, Copy, Debug, Deserialize)]
137#[serde(default)]
138pub struct BulkAuthConfig {
139 pub concurrency: usize,
141 pub prepare_timeout_secs: u64,
143}
144
145impl Default for BulkAuthConfig {
146 fn default() -> Self {
147 Self {
148 concurrency: 8,
149 prepare_timeout_secs: 30,
150 }
151 }
152}
153
154#[derive(Debug, Default, Deserialize)]
155struct BulkAuthConfigFile {
156 #[serde(default)]
157 bulk_auth: BulkAuthConfig,
158}
159
160impl BulkAuthConfig {
161 pub fn load() -> Self {
165 if let Ok(path) = std::env::var("HARN_MCP_BULK_AUTH_CONFIG") {
166 if let Some(config) = Self::read(&path) {
167 return config;
168 }
169 }
170 if !cfg!(test) {
171 if let Some(home) = crate::user_dirs::home_dir() {
172 let path = home.join(".config").join("harn").join("mcp_bulk_auth.toml");
173 if let Some(config) = Self::read(&path.to_string_lossy()) {
174 return config;
175 }
176 }
177 }
178 Self::default()
179 }
180
181 fn read(path: &str) -> Option<Self> {
182 let content = std::fs::read_to_string(path).ok()?;
183 match toml::from_str::<BulkAuthConfigFile>(&content) {
184 Ok(file) => Some(file.bulk_auth),
185 Err(error) => {
186 eprintln!("[mcp_bulk_auth] TOML parse error in {path}: {error}");
187 None
188 }
189 }
190 }
191}
192
193#[async_trait]
196pub trait OAuthFlowEngine: Send + Sync {
197 async fn current_bearer(&self, server_url: &str) -> Result<Option<String>, String>;
200 async fn has_token(&self, server_url: &str) -> Result<bool, String>;
202 async fn begin(&self, request: BeginAuthorization) -> Result<PendingAuthorization, String>;
204 async fn complete(
206 &self,
207 state: &str,
208 code: &str,
209 issuer: Option<&str>,
210 ) -> Result<StoredMcpToken, String>;
211}
212
213#[derive(Clone, Debug, Default)]
216pub struct RealOAuthFlowEngine {
217 callback: mcp_oauth::AuthorizationCallback,
218}
219
220impl RealOAuthFlowEngine {
221 pub fn with_callback(callback: mcp_oauth::AuthorizationCallback) -> Self {
222 Self { callback }
223 }
224}
225
226#[async_trait]
227impl OAuthFlowEngine for RealOAuthFlowEngine {
228 async fn current_bearer(&self, server_url: &str) -> Result<Option<String>, String> {
229 mcp_oauth::resolve_bearer(server_url).await
230 }
231
232 async fn has_token(&self, server_url: &str) -> Result<bool, String> {
233 let discovery = mcp_oauth::discover(server_url).await?;
234 let resource =
235 canonical_resource_indicator(server_url).map_err(|error| error.to_string())?;
236 Ok(
237 mcp_oauth::load_token(&resource, &discovery.authorization_server_issuer, None)
238 .await?
239 .is_some(),
240 )
241 }
242
243 async fn begin(&self, request: BeginAuthorization) -> Result<PendingAuthorization, String> {
244 mcp_oauth::begin_authorization_with_callback(request, &self.callback).await
245 }
246
247 async fn complete(
248 &self,
249 state: &str,
250 code: &str,
251 issuer: Option<&str>,
252 ) -> Result<StoredMcpToken, String> {
253 mcp_oauth::complete_authorization(state, code, issuer).await
254 }
255}
256
257#[derive(Clone, Debug)]
260struct FlowMeta {
261 name: String,
262 server_url: String,
263}
264
265pub struct McpBulkAuth<E: OAuthFlowEngine = RealOAuthFlowEngine> {
269 engine: Arc<E>,
270 config: BulkAuthConfig,
271 status_tx: broadcast::Sender<McpAuthStatus>,
272 pending: Arc<Mutex<HashMap<String, FlowMeta>>>,
273}
274
275impl McpBulkAuth<RealOAuthFlowEngine> {
276 pub fn new() -> Self {
279 Self::with_engine(RealOAuthFlowEngine::default(), BulkAuthConfig::load())
280 }
281}
282
283impl Default for McpBulkAuth<RealOAuthFlowEngine> {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289impl<E: OAuthFlowEngine> McpBulkAuth<E> {
290 pub fn with_engine(engine: E, config: BulkAuthConfig) -> Self {
292 let (status_tx, _rx) = broadcast::channel(STATUS_CHANNEL_CAPACITY);
293 Self {
294 engine: Arc::new(engine),
295 config,
296 status_tx,
297 pending: Arc::new(Mutex::new(HashMap::new())),
298 }
299 }
300
301 pub fn subscribe(&self) -> broadcast::Receiver<McpAuthStatus> {
304 self.status_tx.subscribe()
305 }
306
307 pub async fn prepare(
312 &self,
313 servers: Vec<BulkAuthServer>,
314 mode: BulkAuthMode,
315 redirect_uri: &str,
316 ) -> Vec<PrepareOutcome> {
317 let concurrency = self.config.concurrency.max(1);
318 let timeout = Duration::from_secs(self.config.prepare_timeout_secs.max(1));
319 futures::stream::iter(servers.into_iter().map(|server| {
320 let engine = self.engine.clone();
321 let status_tx = self.status_tx.clone();
322 let pending = self.pending.clone();
323 let redirect_uri = redirect_uri.to_string();
324 async move {
325 prepare_one(
326 engine,
327 status_tx,
328 pending,
329 server,
330 mode,
331 redirect_uri,
332 timeout,
333 )
334 .await
335 }
336 }))
337 .buffer_unordered(concurrency)
338 .collect::<Vec<_>>()
339 .await
340 }
341
342 pub async fn complete(
346 &self,
347 state: &str,
348 code: &str,
349 issuer: Option<&str>,
350 ) -> Result<StoredMcpToken, String> {
351 let meta = self
352 .pending
353 .lock()
354 .unwrap_or_else(|poison| poison.into_inner())
355 .get(state)
356 .cloned();
357 let (name, server_url) = match meta {
358 Some(meta) => (meta.name, meta.server_url),
359 None => ("<unknown>".to_string(), String::new()),
360 };
361 emit(
362 &self.status_tx,
363 &name,
364 &server_url,
365 McpAuthPhase::Exchanging,
366 None,
367 );
368 match self.engine.complete(state, code, issuer).await {
369 Ok(token) => {
370 self.pending
371 .lock()
372 .unwrap_or_else(|poison| poison.into_inner())
373 .remove(state);
374 emit(
375 &self.status_tx,
376 &name,
377 &server_url,
378 McpAuthPhase::Connected,
379 None,
380 );
381 Ok(token)
382 }
383 Err(error) => {
384 emit(
385 &self.status_tx,
386 &name,
387 &server_url,
388 McpAuthPhase::Failed,
389 Some(error.clone()),
390 );
391 Err(error)
392 }
393 }
394 }
395
396 pub fn pending_count(&self) -> usize {
398 self.pending
399 .lock()
400 .unwrap_or_else(|poison| poison.into_inner())
401 .len()
402 }
403
404 pub fn knows_state(&self, state: &str) -> bool {
410 self.pending
411 .lock()
412 .unwrap_or_else(|poison| poison.into_inner())
413 .contains_key(state)
414 }
415}
416
417async fn prepare_one<E: OAuthFlowEngine>(
421 engine: Arc<E>,
422 status_tx: broadcast::Sender<McpAuthStatus>,
423 pending: Arc<Mutex<HashMap<String, FlowMeta>>>,
424 server: BulkAuthServer,
425 mode: BulkAuthMode,
426 redirect_uri: String,
427 timeout: Duration,
428) -> PrepareOutcome {
429 emit(
430 &status_tx,
431 &server.name,
432 &server.server_url,
433 McpAuthPhase::Discovering,
434 None,
435 );
436
437 match tokio::time::timeout(timeout, decide(&*engine, &server, mode)).await {
438 Ok(AuthDecision::Begin) => {}
439 Ok(AuthDecision::Skip(reason)) => {
440 emit(
441 &status_tx,
442 &server.name,
443 &server.server_url,
444 McpAuthPhase::Skipped,
445 Some(reason.to_string()),
446 );
447 return PrepareOutcome::Skipped {
448 name: server.name,
449 server_url: server.server_url,
450 reason: reason.to_string(),
451 };
452 }
453 Err(_) => {
454 return fail(
455 &status_tx,
456 server,
457 "timed out resolving authorization server",
458 );
459 }
460 }
461
462 let request = BeginAuthorization {
463 server_url: server.server_url.clone(),
464 redirect_uri: redirect_uri.clone(),
465 mode: server.mode,
466 client_id: server.client_id.clone(),
467 client_secret: server.client_secret.clone(),
468 static_secret_id: server.static_secret_id.clone(),
469 scopes: server.scopes.clone(),
470 };
471 match tokio::time::timeout(timeout, engine.begin(request)).await {
472 Ok(Ok(pending_auth)) => {
473 pending
474 .lock()
475 .unwrap_or_else(|poison| poison.into_inner())
476 .insert(
477 pending_auth.state.clone(),
478 FlowMeta {
479 name: server.name.clone(),
480 server_url: server.server_url.clone(),
481 },
482 );
483 emit(
484 &status_tx,
485 &server.name,
486 &server.server_url,
487 McpAuthPhase::AwaitingConsent,
488 None,
489 );
490 PrepareOutcome::Pending(PreparedFlow {
491 name: server.name,
492 server_url: server.server_url,
493 authorize_url: pending_auth.authorize_url,
494 state: pending_auth.state,
495 redirect_uri: pending_auth.redirect_uri,
496 })
497 }
498 Ok(Err(error)) => fail(&status_tx, server, &error),
499 Err(_) => fail(&status_tx, server, "timed out minting authorization URL"),
500 }
501}
502
503enum AuthDecision {
505 Begin,
506 Skip(&'static str),
507}
508
509async fn decide<E: OAuthFlowEngine>(
510 engine: &E,
511 server: &BulkAuthServer,
512 mode: BulkAuthMode,
513) -> AuthDecision {
514 match mode {
515 BulkAuthMode::All => AuthDecision::Begin,
516 BulkAuthMode::Missing => match engine.current_bearer(&server.server_url).await {
517 Ok(Some(_)) => AuthDecision::Skip("already connected"),
518 _ => AuthDecision::Begin,
521 },
522 BulkAuthMode::Expired => {
523 match engine.has_token(&server.server_url).await {
525 Ok(false) => return AuthDecision::Skip("no stored token"),
526 Ok(true) => {}
527 Err(_) => return AuthDecision::Skip("no stored token"),
528 }
529 match engine.current_bearer(&server.server_url).await {
530 Ok(Some(_)) => AuthDecision::Skip("token still valid"),
531 _ => AuthDecision::Begin,
532 }
533 }
534 }
535}
536
537fn fail(
538 status_tx: &broadcast::Sender<McpAuthStatus>,
539 server: BulkAuthServer,
540 error: &str,
541) -> PrepareOutcome {
542 emit(
543 status_tx,
544 &server.name,
545 &server.server_url,
546 McpAuthPhase::Failed,
547 Some(error.to_string()),
548 );
549 PrepareOutcome::Failed {
550 name: server.name,
551 server_url: server.server_url,
552 error: error.to_string(),
553 }
554}
555
556fn emit(
557 status_tx: &broadcast::Sender<McpAuthStatus>,
558 server: &str,
559 server_url: &str,
560 phase: McpAuthPhase,
561 detail: Option<String>,
562) {
563 let _ = status_tx.send(McpAuthStatus {
565 server: server.to_string(),
566 server_url: server_url.to_string(),
567 phase,
568 detail,
569 });
570}
571
572pub fn prepare_outcome_to_json(outcome: &PrepareOutcome) -> serde_json::Value {
579 match outcome {
580 PrepareOutcome::Pending(flow) => serde_json::json!({
581 "server": flow.name,
582 "server_url": flow.server_url,
583 "status": "reauth_required",
584 "authorize_url": flow.authorize_url,
585 "state": flow.state,
586 }),
587 PrepareOutcome::Skipped {
588 name,
589 server_url,
590 reason,
591 } => serde_json::json!({
592 "server": name,
593 "server_url": server_url,
594 "status": "skipped",
595 "reason": reason,
596 }),
597 PrepareOutcome::Failed {
598 name,
599 server_url,
600 error,
601 } => serde_json::json!({
602 "server": name,
603 "server_url": server_url,
604 "status": "failed",
605 "error": error,
606 }),
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use std::sync::atomic::{AtomicUsize, Ordering};
614
615 #[derive(Default)]
619 struct MockEngine {
620 valid: Vec<String>,
622 stored: Vec<String>,
624 begin_fails: Vec<String>,
626 begin_calls: AtomicUsize,
627 state_counter: AtomicUsize,
628 }
629
630 #[async_trait]
631 impl OAuthFlowEngine for MockEngine {
632 async fn current_bearer(&self, server_url: &str) -> Result<Option<String>, String> {
633 Ok(self
634 .valid
635 .iter()
636 .any(|u| u == server_url)
637 .then(|| "bearer".to_string()))
638 }
639 async fn has_token(&self, server_url: &str) -> Result<bool, String> {
640 Ok(self.stored.iter().any(|u| u == server_url))
641 }
642 async fn begin(&self, request: BeginAuthorization) -> Result<PendingAuthorization, String> {
643 self.begin_calls.fetch_add(1, Ordering::SeqCst);
644 if self.begin_fails.contains(&request.server_url) {
645 return Err("discovery exploded".to_string());
646 }
647 let n = self.state_counter.fetch_add(1, Ordering::SeqCst);
648 let state = format!("state-{n}");
649 Ok(PendingAuthorization {
650 authorize_url: format!("https://auth.example/authorize?state={state}"),
651 state,
652 redirect_uri: request.redirect_uri,
653 resource: request.server_url,
654 issuer: "https://auth.example".to_string(),
655 })
656 }
657 async fn complete(
658 &self,
659 state: &str,
660 _code: &str,
661 _issuer: Option<&str>,
662 ) -> Result<StoredMcpToken, String> {
663 if state == "bad-state" {
664 return Err("token exchange failed".to_string());
665 }
666 Ok(StoredMcpToken {
667 access_token: "access".to_string(),
668 refresh_token: None,
669 expires_at_unix: None,
670 token_endpoint: "https://auth.example/token".to_string(),
671 client_id: "client".to_string(),
672 client_secret: None,
673 token_endpoint_auth_method: "none".to_string(),
674 issuer: "https://auth.example".to_string(),
675 resource: "https://mcp.example/mcp".to_string(),
676 scopes: None,
677 token_response_extra: None,
678 })
679 }
680 }
681
682 fn server(name: &str, url: &str) -> BulkAuthServer {
683 BulkAuthServer {
684 name: name.to_string(),
685 server_url: url.to_string(),
686 ..Default::default()
687 }
688 }
689
690 fn driver(engine: MockEngine) -> McpBulkAuth<MockEngine> {
691 McpBulkAuth::with_engine(
693 engine,
694 BulkAuthConfig {
695 concurrency: 1,
696 prepare_timeout_secs: 5,
697 },
698 )
699 }
700
701 async fn drain(rx: &mut broadcast::Receiver<McpAuthStatus>) -> Vec<McpAuthStatus> {
702 let mut out = Vec::new();
703 while let Ok(status) = rx.try_recv() {
704 out.push(status);
705 }
706 out
707 }
708
709 fn phases(events: &[McpAuthStatus], server: &str) -> Vec<McpAuthPhase> {
710 events
711 .iter()
712 .filter(|e| e.server == server)
713 .map(|e| e.phase)
714 .collect()
715 }
716
717 #[tokio::test]
718 async fn prepares_all_servers_and_emits_phase_sequence() {
719 let driver = driver(MockEngine::default());
720 let mut rx = driver.subscribe();
721 let outcomes = driver
722 .prepare(
723 vec![
724 server("a", "https://a.example/mcp"),
725 server("b", "https://b.example/mcp"),
726 server("c", "https://c.example/mcp"),
727 ],
728 BulkAuthMode::All,
729 "http://127.0.0.1:9783/callback",
730 )
731 .await;
732
733 assert_eq!(outcomes.len(), 3);
734 assert!(outcomes
735 .iter()
736 .all(|o| matches!(o, PrepareOutcome::Pending(_))));
737 assert_eq!(driver.pending_count(), 3);
738
739 let events = drain(&mut rx).await;
740 for name in ["a", "b", "c"] {
741 assert_eq!(
742 phases(&events, name),
743 vec![McpAuthPhase::Discovering, McpAuthPhase::AwaitingConsent],
744 "server {name}"
745 );
746 }
747 }
748
749 #[tokio::test]
750 async fn missing_mode_skips_connected_servers() {
751 let engine = MockEngine {
752 valid: vec!["https://b.example/mcp".to_string()],
753 ..Default::default()
754 };
755 let driver = driver(engine);
756 let outcomes = driver
757 .prepare(
758 vec![
759 server("a", "https://a.example/mcp"),
760 server("b", "https://b.example/mcp"),
761 ],
762 BulkAuthMode::Missing,
763 "http://127.0.0.1:9783/callback",
764 )
765 .await;
766
767 let a = outcomes.iter().find(|o| outcome_name(o) == "a").unwrap();
768 let b = outcomes.iter().find(|o| outcome_name(o) == "b").unwrap();
769 assert!(matches!(a, PrepareOutcome::Pending(_)));
770 assert!(
771 matches!(b, PrepareOutcome::Skipped { reason, .. } if reason == "already connected")
772 );
773 }
774
775 #[tokio::test]
776 async fn expired_mode_only_reauths_stale_stored_tokens() {
777 let engine = MockEngine {
778 valid: vec!["https://fresh.example/mcp".to_string()],
781 stored: vec![
782 "https://stale.example/mcp".to_string(),
783 "https://fresh.example/mcp".to_string(),
784 ],
785 ..Default::default()
786 };
787 let driver = driver(engine);
788 let outcomes = driver
789 .prepare(
790 vec![
791 server("stale", "https://stale.example/mcp"),
792 server("fresh", "https://fresh.example/mcp"),
793 server("none", "https://none.example/mcp"),
794 ],
795 BulkAuthMode::Expired,
796 "http://127.0.0.1:9783/callback",
797 )
798 .await;
799
800 let stale = outcomes
801 .iter()
802 .find(|o| outcome_name(o) == "stale")
803 .unwrap();
804 let fresh = outcomes
805 .iter()
806 .find(|o| outcome_name(o) == "fresh")
807 .unwrap();
808 let none = outcomes.iter().find(|o| outcome_name(o) == "none").unwrap();
809 assert!(
810 matches!(stale, PrepareOutcome::Pending(_)),
811 "stale → re-auth"
812 );
813 assert!(
814 matches!(fresh, PrepareOutcome::Skipped { reason, .. } if reason == "token still valid")
815 );
816 assert!(
817 matches!(none, PrepareOutcome::Skipped { reason, .. } if reason == "no stored token")
818 );
819 }
820
821 #[tokio::test]
822 async fn reauth_expired_outcomes_as_json_drive_only_stale() {
823 let engine = MockEngine {
827 valid: vec!["https://fresh.example/mcp".to_string()],
828 stored: vec![
829 "https://stale1.example/mcp".to_string(),
830 "https://stale2.example/mcp".to_string(),
831 "https://fresh.example/mcp".to_string(),
832 ],
833 ..Default::default()
834 };
835 let driver = driver(engine);
836 let outcomes = driver
837 .prepare(
838 vec![
839 server("stale1", "https://stale1.example/mcp"),
840 server("stale2", "https://stale2.example/mcp"),
841 server("fresh", "https://fresh.example/mcp"),
842 ],
843 BulkAuthMode::Expired,
844 "http://127.0.0.1:9783/callback",
845 )
846 .await;
847
848 let json: Vec<serde_json::Value> = outcomes.iter().map(prepare_outcome_to_json).collect();
849 let by_server = |name: &str| {
850 json.iter()
851 .find(|value| value["server"] == name)
852 .cloned()
853 .unwrap()
854 };
855
856 let reauthed: Vec<_> = json
857 .iter()
858 .filter(|value| value["status"] == "reauth_required")
859 .collect();
860 assert_eq!(
861 reauthed.len(),
862 2,
863 "exactly the two stale servers are driven"
864 );
865
866 let stale1 = by_server("stale1");
867 assert_eq!(stale1["status"], "reauth_required");
868 assert!(
869 stale1["authorize_url"].as_str().is_some(),
870 "a re-auth outcome carries an authorize_url for the caller to open"
871 );
872 assert_eq!(by_server("stale2")["status"], "reauth_required");
873
874 let fresh = by_server("fresh");
875 assert_eq!(fresh["status"], "skipped");
876 assert_eq!(fresh["reason"], "token still valid");
877 }
878
879 #[tokio::test]
880 async fn one_servers_failure_is_isolated() {
881 let engine = MockEngine {
882 begin_fails: vec!["https://b.example/mcp".to_string()],
883 ..Default::default()
884 };
885 let driver = driver(engine);
886 let mut rx = driver.subscribe();
887 let outcomes = driver
888 .prepare(
889 vec![
890 server("a", "https://a.example/mcp"),
891 server("b", "https://b.example/mcp"),
892 server("c", "https://c.example/mcp"),
893 ],
894 BulkAuthMode::All,
895 "http://127.0.0.1:9783/callback",
896 )
897 .await;
898
899 let b = outcomes.iter().find(|o| outcome_name(o) == "b").unwrap();
900 assert!(matches!(b, PrepareOutcome::Failed { error, .. } if error.contains("discovery")));
901 assert_eq!(
903 outcomes
904 .iter()
905 .filter(|o| matches!(o, PrepareOutcome::Pending(_)))
906 .count(),
907 2
908 );
909 let events = drain(&mut rx).await;
910 assert_eq!(
911 phases(&events, "b"),
912 vec![McpAuthPhase::Discovering, McpAuthPhase::Failed]
913 );
914 }
915
916 #[tokio::test]
917 async fn complete_routes_by_state_and_streams_terminal_phase() {
918 let driver = driver(MockEngine::default());
919 let mut rx = driver.subscribe();
920 let outcomes = driver
921 .prepare(
922 vec![server("a", "https://a.example/mcp")],
923 BulkAuthMode::All,
924 "http://127.0.0.1:9783/callback",
925 )
926 .await;
927 let state = match &outcomes[0] {
928 PrepareOutcome::Pending(flow) => flow.state.clone(),
929 other => panic!("expected pending, got {other:?}"),
930 };
931 let _ = drain(&mut rx).await;
932
933 let token = driver.complete(&state, "auth-code", None).await.unwrap();
934 assert_eq!(token.access_token, "access");
935 assert_eq!(driver.pending_count(), 0, "completed flow is cleared");
936
937 let events = drain(&mut rx).await;
938 assert_eq!(
939 phases(&events, "a"),
940 vec![McpAuthPhase::Exchanging, McpAuthPhase::Connected]
941 );
942 }
943
944 #[tokio::test]
945 async fn complete_failure_emits_failed_and_keeps_pending() {
946 let driver = driver(MockEngine::default());
947 driver.pending.lock().unwrap().insert(
949 "bad-state".to_string(),
950 FlowMeta {
951 name: "a".to_string(),
952 server_url: "https://a.example/mcp".to_string(),
953 },
954 );
955 let mut rx = driver.subscribe();
956 let error = driver
957 .complete("bad-state", "code", None)
958 .await
959 .unwrap_err();
960 assert!(error.contains("token exchange failed"));
961 let events = drain(&mut rx).await;
962 assert_eq!(
963 phases(&events, "a"),
964 vec![McpAuthPhase::Exchanging, McpAuthPhase::Failed]
965 );
966 }
967
968 #[test]
969 fn status_serializes_snake_case() {
970 let json = serde_json::to_value(McpAuthStatus {
971 server: "Notion".to_string(),
972 server_url: "https://mcp.notion.com/mcp".to_string(),
973 phase: McpAuthPhase::AwaitingConsent,
974 detail: None,
975 })
976 .unwrap();
977 assert_eq!(json["server"], serde_json::json!("Notion"));
978 assert_eq!(json["phase"], serde_json::json!("awaiting_consent"));
979 assert!(json.get("detail").is_none(), "None detail is omitted");
980 }
981
982 #[test]
983 fn config_defaults_when_no_overlay() {
984 let config = BulkAuthConfig::load();
985 assert_eq!(config.concurrency, 8);
986 assert_eq!(config.prepare_timeout_secs, 30);
987 }
988
989 fn outcome_name(outcome: &PrepareOutcome) -> &str {
990 match outcome {
991 PrepareOutcome::Pending(flow) => &flow.name,
992 PrepareOutcome::Skipped { name, .. } => name,
993 PrepareOutcome::Failed { name, .. } => name,
994 }
995 }
996}