Skip to main content

harn_vm/
mcp_bulk_auth.rs

1//! Bulk MCP OAuth driver (harn#3355) — the keystone of the bulk-login program
2//! (harn#3354).
3//!
4//! Authenticating many OAuth-backed MCP servers today is one-at-a-time by
5//! construction. This driver orchestrates **all** pending flows at once on top
6//! of the unchanged per-server engine in [`crate::mcp_oauth`], and publishes a
7//! **per-server status event stream** so every surface (CLI `mcp login --all`,
8//! ACP `mcp/authorize_batch`, the burin "Connect all" GUI) renders incremental
9//! progress from one source. No protocol changes — bulk auth is N independent,
10//! spec-compliant flows that share one driver and one loopback listener
11//! (demuxed by the OAuth `state`).
12//!
13//! Division of labour: the driver owns flow orchestration + status; the
14//! **surface** owns IO (opening browsers, the loopback listener, terminal/GUI
15//! rendering). [`McpBulkAuth::prepare`] returns the authorize URLs to open
16//! (keyed by `state`); the surface opens them (serialized, to avoid a popup
17//! storm) and feeds each captured `{ state, code }` back via
18//! [`McpBulkAuth::complete`].
19//!
20//! The OAuth operations are abstracted behind [`OAuthFlowEngine`] so the driver
21//! is unit-testable end-to-end against a mock — no network, no browser. The
22//! real engine ([`RealOAuthFlowEngine`]) simply delegates to `mcp_oauth`.
23
24use 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
36/// Broadcast backlog for the status stream. Comfortably exceeds the number of
37/// servers a workspace realistically declares; a slow subscriber that lags
38/// past this loses old events (it can re-query `mcp status`), never the driver.
39const STATUS_CHANNEL_CAPACITY: usize = 256;
40
41/// Which servers a bulk pass should authenticate.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum BulkAuthMode {
44    /// First-auth: begin a flow only for servers without a currently-valid
45    /// bearer; skip already-connected ones. (`mcp login --all`.)
46    Missing,
47    /// Re-auth: begin a flow only for servers that *have* a stored token which
48    /// is no longer valid (expired/revoked); skip valid tokens and servers with
49    /// no token at all. (`mcp.reauth_expired()` / harn#3358.)
50    Expired,
51    /// Force a fresh flow for every server regardless of current state.
52    All,
53}
54
55/// One server to (maybe) authenticate. Mirrors the per-server inputs the
56/// engine's [`BeginAuthorization`] needs, plus a display `name` for status.
57#[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/// A flow that needs the surface to open a browser. The surface opens
69/// `authorize_url`, captures the redirect, and calls [`McpBulkAuth::complete`]
70/// with the `state` echoed back.
71#[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/// The result of preparing one server.
82#[derive(Clone, Debug)]
83pub enum PrepareOutcome {
84    /// Needs a browser consent; open `authorize_url`.
85    Pending(PreparedFlow),
86    /// Nothing to do (already connected, or nothing to re-auth).
87    Skipped {
88        name: String,
89        server_url: String,
90        reason: String,
91    },
92    /// Could not begin a flow (discovery/registration/timeout failure).
93    Failed {
94        name: String,
95        server_url: String,
96        error: String,
97    },
98}
99
100/// Phase of one server's bulk-auth lifecycle, streamed to subscribers.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum McpAuthPhase {
104    /// Resolving the authorization server + client (discovery/registration).
105    Discovering,
106    /// Authorize URL minted; waiting for the user to consent in the browser.
107    AwaitingConsent,
108    /// Exchanging the authorization code for a token.
109    Exchanging,
110    /// Token stored — the server is connected.
111    Connected,
112    /// This server failed; `detail` carries a redacted reason.
113    Failed,
114    /// This server was skipped (e.g. already connected); `detail` says why.
115    Skipped,
116}
117
118/// One per-server status event on the stream.
119#[derive(Clone, Debug, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub struct McpAuthStatus {
122    /// Display name of the server.
123    pub server: String,
124    /// Server URL (empty when unknown, e.g. a late callback).
125    pub server_url: String,
126    /// Current phase.
127    pub phase: McpAuthPhase,
128    /// Human-readable note or redacted error, when relevant.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub detail: Option<String>,
131}
132
133/// Driver tunables. **Data, not code:** loaded via [`BulkAuthConfig::load`]
134/// from `HARN_MCP_BULK_AUTH_CONFIG` or `~/.config/harn/mcp_bulk_auth.toml`
135/// (a `[bulk_auth]` table), so concurrency/timeout change without a recompile.
136#[derive(Clone, Copy, Debug, Deserialize)]
137#[serde(default)]
138pub struct BulkAuthConfig {
139    /// Max concurrent `begin_authorization` flows during prepare.
140    pub concurrency: usize,
141    /// Per-server budget for discovery + authorize-URL minting.
142    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    /// Resolve the effective config: the `HARN_MCP_BULK_AUTH_CONFIG` path wins,
162    /// else `~/.config/harn/mcp_bulk_auth.toml`, else defaults. Skipped under
163    /// `cfg(test)` so unit tests are deterministic.
164    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/// The OAuth operations the driver needs, abstracted so it can be exercised
194/// against a mock. The real impl delegates to [`crate::mcp_oauth`].
195#[async_trait]
196pub trait OAuthFlowEngine: Send + Sync {
197    /// A currently-valid bearer for the server (refreshing if needed), or
198    /// `None` when none is stored / the stored one cannot be made valid.
199    async fn current_bearer(&self, server_url: &str) -> Result<Option<String>, String>;
200    /// Whether *any* token is stored for the server (valid or not).
201    async fn has_token(&self, server_url: &str) -> Result<bool, String>;
202    /// Begin an authorization, returning the authorize URL + `state`.
203    async fn begin(&self, request: BeginAuthorization) -> Result<PendingAuthorization, String>;
204    /// Complete a flow by `state`, exchanging the code and persisting the token.
205    async fn complete(
206        &self,
207        state: &str,
208        code: &str,
209        issuer: Option<&str>,
210    ) -> Result<StoredMcpToken, String>;
211}
212
213/// Production [`OAuthFlowEngine`] — delegates flow ownership to `mcp_oauth`
214/// while carrying the callback adapter selected by the surface.
215#[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/// Name + URL remembered for a pending `state` so [`McpBulkAuth::complete`] can
258/// label its status events without the surface re-supplying them.
259#[derive(Clone, Debug)]
260struct FlowMeta {
261    name: String,
262    server_url: String,
263}
264
265/// Bulk OAuth orchestrator. Construct once, [`subscribe`](Self::subscribe) for
266/// the status stream, [`prepare`](Self::prepare) to begin all pending flows,
267/// then [`complete`](Self::complete) each captured callback.
268pub 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    /// Construct the driver backed by the real `mcp_oauth` engine, with config
277    /// resolved from the overlay.
278    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    /// Construct with an explicit engine + config (the test/injection seam).
291    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    /// Subscribe to the per-server status stream. Subscribe *before* calling
302    /// [`prepare`](Self::prepare) to observe every event.
303    pub fn subscribe(&self) -> broadcast::Receiver<McpAuthStatus> {
304        self.status_tx.subscribe()
305    }
306
307    /// Begin flows for all servers selected by `mode`, concurrently (bounded by
308    /// the configured concurrency). All flows share `redirect_uri` — the surface
309    /// binds one loopback listener and demuxes callbacks by `state`. Returns one
310    /// [`PrepareOutcome`] per input server; emits status events throughout.
311    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    /// Complete a flow whose callback the surface captured. Looks up the
343    /// server's display name by `state`, emits `Exchanging` then
344    /// `Connected`/`Failed`, and returns the stored token on success.
345    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    /// Number of flows still awaiting a callback (begun but not completed).
397    pub fn pending_count(&self) -> usize {
398        self.pending
399            .lock()
400            .unwrap_or_else(|poison| poison.into_inner())
401            .len()
402    }
403
404    /// Whether `state` belongs to a flow this driver began (and has not yet
405    /// completed). A surface that multiplexes single-URL and batch callbacks on
406    /// one channel uses this to route a captured `{ state, code }` to the driver
407    /// (so completion streams `Exchanging`/`Connected`) only when the state is
408    /// one of ours — otherwise it falls back to its per-flow path unchanged.
409    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
417/// Begin (or skip) one server, emitting its phase events. Free function so the
418/// per-server future owns only cloned handles (no `&self` borrow across the
419/// concurrent stream).
420async 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
503/// Outcome of the per-mode "does this server need a flow?" decision.
504enum 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            // None or error → not currently usable; begin (begin surfaces any
519            // real discovery error as a Failed outcome).
520            _ => AuthDecision::Begin,
521        },
522        BulkAuthMode::Expired => {
523            // Only re-auth servers that have a token which is no longer valid.
524            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    // A send with no live receivers is fine — status is best-effort telemetry.
564    let _ = status_tx.send(McpAuthStatus {
565        server: server.to_string(),
566        server_url: server_url.to_string(),
567        phase,
568        detail,
569    });
570}
571
572/// Canonical snake_case JSON for one prepare outcome — the "outcome as data"
573/// shape consumed by script/CLI surfaces (e.g. the `mcp.reauth_expired()`
574/// builtin, harn#3358). A `Pending` flow needs interactive consent, so it is
575/// reported as `reauth_required` with its `authorize_url`/`state`; `Skipped`
576/// and `Failed` carry their reason/error. (The ACP wire uses its own camelCase
577/// grouping — different consumer, different convention.)
578pub 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    /// Scripted mock engine: per-URL valid/stored-token state + a begin/complete
616    /// counter, so tests assert phase sequences and mode filtering with no
617    /// network or browser.
618    #[derive(Default)]
619    struct MockEngine {
620        /// URLs that currently have a valid bearer.
621        valid: Vec<String>,
622        /// URLs that have a stored token (valid or not).
623        stored: Vec<String>,
624        /// URLs whose `begin` should fail.
625        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        // concurrency 1 keeps the mock's `state-N` assignment deterministic.
692        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            // "stale" has a stored-but-invalid token, "fresh" is valid, "none"
779            // has nothing stored.
780            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        // The `mcp.reauth_expired()` (harn#3358) acceptance: two servers with
824        // expired (stored-but-invalid) tokens + one valid → exactly the two are
825        // driven to re-auth and the valid one is left untouched (Skipped).
826        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        // a and c still succeeded.
902        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        // Seed a pending flow whose state the mock will reject.
948        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}