@chrischall/mcp-utils 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session scaffolding for the MCP fleet — four related-but-distinct surfaces
2
+ * Session scaffolding for the MCP fleet — five related-but-distinct surfaces
3
3
  * consolidated behind one subpath (`@chrischall/mcp-utils/session`):
4
4
  *
5
5
  * 1. {@link SessionRegistry} — an *ephemeral, in-memory* registry of signed-in
@@ -12,12 +12,18 @@
12
12
  * (0600 file / 0700 dir), normalized keys, and a most-recently-used "active"
13
13
  * pointer. Used by ofw/creditkarma/honeybook.
14
14
  *
15
- * 3. {@link TokenManager} — a bearer-token lifecycle manager: proactive refresh
16
- * inside a 5-minute skew window, reactive 401-replay, and a single-flight
17
- * semaphore so concurrent callers coalesce into ONE refresh. Used by
18
- * skylight/canvas/creditkarma/honeybook/zola.
15
+ * 3. {@link StatePersistence} — the opt-in seam that lets the two managers
16
+ * below survive a process restart, with {@link createFileStatePersistence}
17
+ * (atomic, 0600) and {@link resolveStateDir} (`MCP_DATA_DIR` `HOME`) as
18
+ * the disk-backed default. Without it a scale-to-zero host re-runs a full
19
+ * login on every cold start, against endpoints that often rate-limit it.
19
20
  *
20
- * 4. {@link CookieSessionManager} — the cookie-session analog of TokenManager:
21
+ * 4. {@link TokenManager} — a bearer-token lifecycle manager: a lazily
22
+ * bootstrapped login, proactive refresh inside a 5-minute skew window,
23
+ * reactive 401-replay, and a single-flight semaphore so concurrent callers
24
+ * coalesce into ONE exchange. Used by skylight/canvas/creditkarma/honeybook/zola.
25
+ *
26
+ * 5. {@link CookieSessionManager} — the cookie-session analog of TokenManager:
21
27
  * a single-flight login + reactive expiry-replay (with heuristic, not just
22
28
  * status-code, expiry detection) + clear-on-settle so a rejected login never
23
29
  * sticks. Used by artsonia/canvas/evite/signupgenius/skylight.
@@ -26,10 +32,13 @@
26
32
  * the one audited implementation the fleet shares.
27
33
  */
28
34
  import { z } from 'zod';
29
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, } from 'node:fs';
30
- import { dirname } from 'node:path';
31
- import { randomBytes } from 'node:crypto';
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync, } from 'node:fs';
36
+ import { dirname, join, resolve } from 'node:path';
37
+ import { homedir } from 'node:os';
38
+ import { randomBytes, createHmac } from 'node:crypto';
32
39
  import { textResult } from '../response/index.js';
40
+ import { readEnvVar } from '../config/index.js';
41
+ import { ApiError, RateLimitedError, RequestTimeoutError } from '../http/index.js';
33
42
  /** Generate a short, collision-resistant label id. */
34
43
  function makeSessionId() {
35
44
  return Date.now().toString(36) + randomBytes(6).toString('hex');
@@ -389,72 +398,572 @@ export class SessionStore {
389
398
  this.mostRecentKey = null;
390
399
  }
391
400
  }
401
+ /**
402
+ * Digest a credential under a salt.
403
+ *
404
+ * Salted per record rather than a bare `sha256(secret)`: this artifact sits in
405
+ * the same file as the tokens, and callers are invited to pass a password, so an
406
+ * unsalted digest would be a stable, precomputable target. The salt is random
407
+ * per write, so the same credential never produces the same digest twice.
408
+ *
409
+ * This is a change-detector, not a password store — prefer passing a non-secret
410
+ * discriminator (an env-supplied refresh token, an account id) where one exists.
411
+ */
412
+ function bindingDigest(secret, salt) {
413
+ return createHmac('sha256', salt).update(secret).digest('hex').slice(0, 32);
414
+ }
415
+ function isEnvelope(raw) {
416
+ return raw !== null && typeof raw === 'object' && raw.v === 1 && 'state' in raw;
417
+ }
418
+ /**
419
+ * Wraps a failure that came from WRITING state, so the managers can tell it
420
+ * apart from a failure of the credential itself.
421
+ *
422
+ * This distinction is load-bearing, not cosmetic. `TokenManager` recovers from a
423
+ * rejected refresh by discarding the stored record and re-running the login —
424
+ * correct for a revoked token, catastrophic for a disk error, because the
425
+ * refresh that just SUCCEEDED already burned the old token upstream. Deleting
426
+ * the record at that point is precisely the lockout `onPersistError` exists to
427
+ * prevent, so a persistence failure is never routed into that recovery.
428
+ */
429
+ export class StatePersistenceError extends Error {
430
+ cause;
431
+ constructor(cause) {
432
+ super(cause instanceof Error ? cause.message : String(cause));
433
+ this.name = 'StatePersistenceError';
434
+ this.cause = cause;
435
+ Object.setPrototypeOf(this, new.target.prototype);
436
+ }
437
+ }
438
+ /**
439
+ * File-backed {@link StatePersistence}. `load` never throws — an absent, corrupt
440
+ * or rejected record is simply `null`. `save` DOES throw on a failed write, so
441
+ * the manager above it can decide what that means.
442
+ *
443
+ * The file is `0600`, re-asserted after
444
+ * the write because `mode` only applies on creation. A directory is created
445
+ * `0700` and re-asserted the same way — but ONLY one this call creates: a bare
446
+ * {@link resolveStateDir} is `$HOME`, and on `mcp-host` the data dir exists
447
+ * before the child starts, so re-permissioning a pre-existing directory would
448
+ * be an invasive side effect of writing one token file rather than hardening.
449
+ *
450
+ * Two differences from {@link SessionStore}, which is why this is its own
451
+ * implementation rather than a wrapper over it. It holds ONE record rather than
452
+ * a keyed collection; and it replaces the file **atomically** — written to a
453
+ * temp file beside it, then renamed over the target — because two children of
454
+ * the same registration can share a data directory, and a half-written token
455
+ * file that parses as valid JSON is worse than none.
456
+ *
457
+ * A load failure (absent, corrupt, rejected by `validate`) returns `null`; a
458
+ * save failure throws, leaving the previous file intact — the atomic replace
459
+ * means a failed write never damages what was already there. On `mcp-host` this
460
+ * belongs under {@link resolveStateDir}, which needs
461
+ * the registration to declare `state.dataDir: true` — the runner's
462
+ * unpersisted-state detector will report the omission rather than let the
463
+ * writes silently vanish on the next idle-stop.
464
+ */
465
+ export function createFileStatePersistence(opts) {
466
+ const { filePath, validate } = opts;
467
+ const secret = opts.boundTo;
468
+ return {
469
+ load() {
470
+ if (!existsSync(filePath))
471
+ return null;
472
+ try {
473
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
474
+ let state;
475
+ if (isEnvelope(raw)) {
476
+ const b = raw.boundTo;
477
+ if (secret === undefined) {
478
+ // A bound record is not ours to read when we hold no credential.
479
+ if (b !== undefined)
480
+ return null;
481
+ }
482
+ else {
483
+ if (b === undefined)
484
+ return null; // unbound record, binding required
485
+ if (bindingDigest(secret, b.salt) !== b.digest)
486
+ return null; // rotated
487
+ }
488
+ state = raw.state;
489
+ }
490
+ else {
491
+ // Legacy bare record. Trusted only when no binding is required: it
492
+ // carries no evidence of which credential minted it.
493
+ if (secret !== undefined)
494
+ return null;
495
+ state = raw;
496
+ }
497
+ if (validate !== undefined)
498
+ return validate(state);
499
+ return state;
500
+ }
501
+ catch {
502
+ // Corrupt or unreadable: the caller re-authenticates. Unlike
503
+ // SessionStore this does NOT preserve a `.corrupt` copy — the file
504
+ // holds one refreshable credential, not an irreplaceable capture.
505
+ return null;
506
+ }
507
+ },
508
+ save(state) {
509
+ const dir = dirname(filePath);
510
+ // A unique temp name so two writers cannot share (and tear) one temp file.
511
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString('hex')}`;
512
+ // Only a directory THIS call creates gets tightened. `resolveStateDir()`
513
+ // without a `subdir` is `$HOME`, and chmodding a user's home directory to
514
+ // 0700 is not an acceptable side effect of writing one token file.
515
+ const dirExisted = existsSync(dir);
516
+ try {
517
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
518
+ // mkdir's mode is subject to the umask, so re-assert it on what we made.
519
+ if (!dirExisted)
520
+ chmodSync(dir, 0o700);
521
+ let boundTo;
522
+ if (secret !== undefined) {
523
+ const salt = randomBytes(16).toString('hex');
524
+ boundTo = { salt, digest: bindingDigest(secret, salt) };
525
+ }
526
+ const envelope = { v: 1, ...(boundTo !== undefined ? { boundTo } : {}), state };
527
+ writeFileSync(tmp, JSON.stringify(envelope, null, 2), { mode: 0o600 });
528
+ // Tighten BEFORE the rename: the window where fresh secrets sit in a
529
+ // possibly-loose file should not exist at all.
530
+ chmodSync(tmp, 0o600);
531
+ renameSync(tmp, filePath);
532
+ chmodSync(filePath, 0o600);
533
+ }
534
+ catch (err) {
535
+ // Best-effort cleanup of the temp file so a failed write does not
536
+ // litter the data dir. The failure itself is RE-THROWN: whether losing
537
+ // a write is survivable is the manager's call (see `onPersistError`),
538
+ // not this file's — freshbooks-mcp's rotating single-use tokens make it
539
+ // fatal, most services make it a nuisance.
540
+ try {
541
+ if (existsSync(tmp))
542
+ unlinkSync(tmp);
543
+ }
544
+ catch {
545
+ /* best-effort */
546
+ }
547
+ throw err;
548
+ }
549
+ },
550
+ clear() {
551
+ try {
552
+ if (existsSync(filePath))
553
+ unlinkSync(filePath);
554
+ }
555
+ catch {
556
+ /* best-effort */
557
+ }
558
+ },
559
+ };
560
+ }
561
+ /**
562
+ * Many records in one file, keyed by account.
563
+ *
564
+ * {@link createFileStatePersistence} holds exactly one record, which is wrong
565
+ * for any server that authenticates as more than one identity — and actively
566
+ * unsafe for one that serves several users from a single process, where a
567
+ * single-record file would hand one user's token to the next. kiaaccess-mcp and
568
+ * alphaportal-mcp both hand-rolled this over {@link SessionStore}; this is the
569
+ * shared form, with the same atomic-replace and `0600`/`0700` hardening as the
570
+ * single-record store.
571
+ *
572
+ * Reads go through the file each time rather than an in-process cache, so a
573
+ * record written by a SIBLING process is picked up — the property kiaaccess-mcp's
574
+ * "constructed per call" comment exists to preserve.
575
+ *
576
+ * WRITES, though, are whole-file read-modify-write: two processes saving
577
+ * DIFFERENT keys at the same instant can lose one of the two, because each
578
+ * rewrites the map it read. The replace is atomic, so the file is never torn —
579
+ * only a concurrent sibling's update can be dropped, and the loser re-authenticates
580
+ * rather than reading anything wrong. That is acceptable for credential caches
581
+ * (rare writes, self-healing) and would not be for a general-purpose store. If
582
+ * that ever stops being true the fix is a lock file, not a bigger read.
583
+ */
584
+ export function createKeyedFileStatePersistence(opts) {
585
+ const { filePath, validate } = opts;
586
+ const normalize = opts.normalizeKey ?? ((k) => k.trim().toLowerCase());
587
+ const readAll = () => {
588
+ if (!existsSync(filePath))
589
+ return {};
590
+ try {
591
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
592
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
593
+ return {};
594
+ return raw;
595
+ }
596
+ catch {
597
+ return {};
598
+ }
599
+ };
600
+ const writeAll = (all) => {
601
+ const dir = dirname(filePath);
602
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString('hex')}`;
603
+ const dirExisted = existsSync(dir);
604
+ try {
605
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
606
+ if (!dirExisted)
607
+ chmodSync(dir, 0o700);
608
+ writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
609
+ chmodSync(tmp, 0o600);
610
+ renameSync(tmp, filePath);
611
+ chmodSync(filePath, 0o600);
612
+ }
613
+ catch (err) {
614
+ try {
615
+ if (existsSync(tmp))
616
+ unlinkSync(tmp);
617
+ }
618
+ catch {
619
+ /* best-effort */
620
+ }
621
+ throw err;
622
+ }
623
+ };
624
+ return {
625
+ forKey(key) {
626
+ const k = normalize(key);
627
+ return {
628
+ load() {
629
+ const raw = readAll()[k];
630
+ if (raw === undefined)
631
+ return null;
632
+ return validate !== undefined ? validate(raw) : raw;
633
+ },
634
+ save(state) {
635
+ const all = readAll();
636
+ all[k] = state;
637
+ writeAll(all);
638
+ },
639
+ clear() {
640
+ const all = readAll();
641
+ if (!(k in all))
642
+ return;
643
+ delete all[k];
644
+ writeAll(all);
645
+ },
646
+ };
647
+ },
648
+ keys() {
649
+ return Object.keys(readAll());
650
+ },
651
+ };
652
+ }
653
+ /**
654
+ * The home directory a state resolver should use: an injected `HOME` when the
655
+ * caller supplied an env, else the OS home. Shared by both branches below so
656
+ * they cannot drift — an override expanding `~` against a different home than
657
+ * the fallback resolves to is exactly the bug this centralises away.
658
+ */
659
+ function homeOf(env) {
660
+ return readEnvVar('HOME', { env }) ?? homedir();
661
+ }
662
+ /**
663
+ * Where a server should keep state that must survive a restart.
664
+ *
665
+ * `MCP_DATA_DIR` first — that is the variable `mcp-host` injects for a
666
+ * registration with `state.dataDir: true`, pointing at a path on the Fly volume
667
+ * keyed by the registration itself (a slot `$HOME` is handed out by arrival
668
+ * order and moves between boots, which is why the data dir is the fix and a
669
+ * bigger rootfs is not). Then `HOME`, then the OS home directory.
670
+ *
671
+ * Blank and unexpanded-placeholder values (`${MCP_DATA_DIR}`, the shape a host
672
+ * config leaves behind when a variable was never substituted) are ignored
673
+ * rather than used as a literal directory name — the same hardening
674
+ * {@link readEnvVar} applies.
675
+ */
676
+ export function resolveStateDir(opts = {}) {
677
+ // Delegated rather than re-implemented: readEnvVar is the fleet's one place
678
+ // that suppresses blank, `'null'`, `'undefined'` AND `${...}` placeholders.
679
+ // The sentinels matter as much as the placeholders here — `MCP_DATA_DIR=null`
680
+ // is a RELATIVE `./null` directory, so the credential would be written under
681
+ // the process cwd and silently stop surviving restarts.
682
+ const env = opts.env;
683
+ return join(readEnvVar('MCP_DATA_DIR', { env }) ?? homeOf(env), opts.subdir ?? '');
684
+ }
685
+ /**
686
+ * The full path to a state file: `<envVar>` if set, else
687
+ * {@link resolveStateDir}`/<subdir>/<fileName>`.
688
+ *
689
+ * The override goes through the same hardened {@link readEnvVar} as the base, so
690
+ * a host forwarding an unexpanded `${...}` — or the literal `null` — falls back
691
+ * rather than creating a relative directory of that name under the process cwd.
692
+ * The result is always absolute: `~` expands against the same home the fallback
693
+ * uses, and anything relative is resolved, because
694
+ * {@link FileStatePersistenceOptions.filePath} is documented as absolute and a
695
+ * cwd-relative store would move with the process.
696
+ */
697
+ export function resolveStateFile(opts) {
698
+ const override = opts.envVar !== undefined ? readEnvVar(opts.envVar, { env: opts.env }) : undefined;
699
+ if (override !== undefined) {
700
+ // `~` expanded against the SAME home the fallback branch uses — `expandPath`
701
+ // would resolve it against os.homedir() and ignore an injected
702
+ // `opts.env.HOME`, making the two branches disagree. `resolve` then keeps
703
+ // the documented "absolute path" guarantee: a relative override would
704
+ // otherwise follow the process's cwd around.
705
+ if (override === '~')
706
+ return homeOf(opts.env);
707
+ if (override.startsWith('~/'))
708
+ return join(homeOf(opts.env), override.slice(2));
709
+ return resolve(override);
710
+ }
711
+ const dirOpts = { env: opts.env };
712
+ if (opts.subdir !== undefined)
713
+ dirOpts.subdir = opts.subdir;
714
+ return join(resolveStateDir(dirOpts), opts.fileName);
715
+ }
392
716
  // ===========================================================================
393
- // 3. TokenManager — bearer lifecycle (skew, proactive + reactive, race-safe)
717
+ // 4. TokenManager — bearer lifecycle (skew, proactive + reactive, race-safe)
394
718
  // ===========================================================================
395
719
  /** Refresh proactively this many ms before the access token expires. */
396
720
  export const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
721
+ /**
722
+ * The default {@link TokenManagerOptions.isRefreshRevoked}: everything except
723
+ * the failures mcp-utils itself can prove are transient. Deliberately
724
+ * conservative — an unrecognised error is treated as a dead credential, because
725
+ * a needless re-login costs one request and an unrecoverable one costs the
726
+ * server until a human intervenes.
727
+ */
728
+ function defaultIsRefreshRevoked(err) {
729
+ if (err instanceof RateLimitedError || err instanceof RequestTimeoutError)
730
+ return false;
731
+ if (err instanceof ApiError && err.status >= 500)
732
+ return false;
733
+ return true;
734
+ }
735
+ /** Whether a parsed record has the shape of {@link BearerTokens}. */
736
+ function isBearerTokens(raw) {
737
+ if (raw === null || typeof raw !== 'object')
738
+ return false;
739
+ const t = raw;
740
+ if (typeof t.accessToken !== 'string' || t.accessToken === '')
741
+ return false;
742
+ if (typeof t.expiresAt !== 'number' || !Number.isFinite(t.expiresAt))
743
+ return false;
744
+ return t.refreshToken === undefined || typeof t.refreshToken === 'string';
745
+ }
397
746
  /**
398
747
  * Manages a bearer access token's lifecycle:
399
748
  *
749
+ * - **Lazy bootstrap:** with a function-form {@link TokenManagerOptions.initial}
750
+ * the login runs on first use, and only if {@link TokenManagerOptions.persistence}
751
+ * has no usable token — the difference between a cold start costing a login
752
+ * and costing nothing.
400
753
  * - **Proactive:** {@link TokenManager.getAccessToken} refreshes when the token
401
754
  * is within `skewMs` (default 5 min) of expiry, returning a still-valid token.
402
755
  * - **Reactive:** {@link TokenManager.withAuth} runs a request, and on a `401`
403
756
  * refreshes once and replays exactly once (no infinite loop).
404
- * - **Race-safe:** concurrent refreshes coalesce onto a single in-flight promise
405
- * (semaphore), so a burst of callers triggers exactly ONE token exchange. The
406
- * in-flight promise is cleared on settle so a later refresh can run again.
757
+ * - **Race-safe:** concurrent refreshes (and concurrent bootstraps) coalesce
758
+ * onto a single in-flight promise, so a burst of callers triggers exactly ONE
759
+ * exchange. The in-flight promise is cleared on settle so a later attempt can
760
+ * run again — a rejected bootstrap never sticks.
761
+ * - **Recoverable:** when a refresh fails and a bootstrap function is available,
762
+ * the stored credential is discarded and the login re-runs. A refresh token
763
+ * revoked between two runs of the process must not brick the server.
407
764
  */
408
765
  export class TokenManager {
409
- accessToken;
410
- refreshToken;
411
- expiresAt;
766
+ tokens;
767
+ bootstrapFn;
412
768
  refreshFn;
413
769
  skewMs;
770
+ persistence;
771
+ now;
772
+ isRefreshRevokedFn;
773
+ onPersistErrorFn;
414
774
  inFlight;
775
+ bootstrapInFlight;
776
+ /**
777
+ * Persistence is consulted at most once per process. Without this the
778
+ * revoked-token recovery below re-reads the SAME rejected record — `clear()`
779
+ * is optional on {@link StatePersistence} and its failures are swallowed, so
780
+ * recovery must not depend on it. After the first read the in-memory tokens
781
+ * (or their deliberate absence) are the truth.
782
+ */
783
+ persistenceRead = false;
415
784
  constructor(opts) {
416
- this.accessToken = opts.initial.accessToken;
417
- this.refreshToken = opts.initial.refreshToken;
418
- this.expiresAt = opts.initial.expiresAt;
785
+ if (typeof opts.initial === 'function') {
786
+ this.bootstrapFn = opts.initial;
787
+ }
788
+ else {
789
+ this.tokens = { ...opts.initial };
790
+ }
419
791
  this.refreshFn = opts.refresh;
420
792
  this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
793
+ this.persistence = opts.persistence;
794
+ this.now = opts.now ?? Date.now;
795
+ this.isRefreshRevokedFn = opts.isRefreshRevoked ?? defaultIsRefreshRevoked;
796
+ this.onPersistErrorFn = opts.onPersistError;
421
797
  }
422
798
  /** Whether the token is within the skew window of (or past) expiry. */
423
799
  needsRefresh() {
424
- return Date.now() >= this.expiresAt - this.skewMs;
800
+ if (this.tokens === undefined)
801
+ return false;
802
+ return this.now() >= this.tokens.expiresAt - this.skewMs;
803
+ }
804
+ /**
805
+ * A stored token is worth using when it is still valid, OR when it carries a
806
+ * refresh token — an expired-but-refreshable token still saves the login,
807
+ * which is the expensive half.
808
+ */
809
+ isUsable(t) {
810
+ return this.now() < t.expiresAt - this.skewMs || t.refreshToken !== undefined;
811
+ }
812
+ /** Read persisted tokens, guarding shape and usability. Never throws. */
813
+ async loadPersisted() {
814
+ if (this.persistence === undefined || this.persistenceRead)
815
+ return null;
816
+ this.persistenceRead = true;
817
+ try {
818
+ const raw = await this.persistence.load();
819
+ if (!isBearerTokens(raw) || !this.isUsable(raw))
820
+ return null;
821
+ return raw;
822
+ }
823
+ catch {
824
+ return null;
825
+ }
826
+ }
827
+ /**
828
+ * Write tokens. Silent by default (a lost write costs a future login, not this
829
+ * request); throws a {@link StatePersistenceError} when `onPersistError` does.
830
+ */
831
+ async persist(t) {
832
+ if (this.persistence === undefined)
833
+ return;
834
+ try {
835
+ await this.persistence.save(t);
836
+ }
837
+ catch (err) {
838
+ // Default: the in-memory token is still valid for this process, so the
839
+ // request proceeds. A hook that rethrows makes the lost write fatal —
840
+ // wrapped so the recovery path below cannot mistake a disk error for a
841
+ // revoked credential and delete the very record that was not written.
842
+ if (this.onPersistErrorFn === undefined)
843
+ return;
844
+ try {
845
+ this.onPersistErrorFn(err);
846
+ }
847
+ catch (hookErr) {
848
+ throw new StatePersistenceError(hookErr);
849
+ }
850
+ }
851
+ }
852
+ /** Discard persisted tokens (a refresh they could not satisfy). Never throws. */
853
+ async clearPersisted() {
854
+ if (this.persistence?.clear === undefined)
855
+ return;
856
+ try {
857
+ await this.persistence.clear();
858
+ }
859
+ catch {
860
+ /* best-effort */
861
+ }
862
+ }
863
+ /** The current tokens, single-flighting the bootstrap if there are none. */
864
+ ensureTokens() {
865
+ if (this.tokens !== undefined)
866
+ return Promise.resolve(this.tokens);
867
+ if (this.bootstrapInFlight === undefined) {
868
+ this.bootstrapInFlight = this.runBootstrap().finally(() => {
869
+ this.bootstrapInFlight = undefined;
870
+ });
871
+ }
872
+ return this.bootstrapInFlight;
873
+ }
874
+ /** One bootstrap attempt: persisted tokens if usable, else the login. */
875
+ async runBootstrap() {
876
+ const stored = await this.loadPersisted();
877
+ if (stored !== null) {
878
+ this.tokens = stored;
879
+ return stored;
880
+ }
881
+ if (this.bootstrapFn === undefined) {
882
+ throw new Error('TokenManager: no tokens and no bootstrap function to mint them.');
883
+ }
884
+ const fresh = await this.bootstrapFn();
885
+ this.tokens = fresh;
886
+ // Unwrapped here: there is no recovery to protect on the bootstrap path (the
887
+ // credential was just minted), and the caller should see the hook's own
888
+ // error — repos throw an McpToolError whose `hint` a wrapper would strip.
889
+ try {
890
+ await this.persist(fresh);
891
+ }
892
+ catch (err) {
893
+ throw err instanceof StatePersistenceError ? err.cause : err;
894
+ }
895
+ return fresh;
425
896
  }
426
897
  /**
427
898
  * Single-flight refresh. Concurrent callers share one in-flight promise; it is
428
899
  * cleared on settle (success or failure) so a subsequent refresh can proceed.
429
900
  */
430
901
  refreshNow() {
431
- if (!this.inFlight) {
432
- const rt = this.refreshToken;
433
- if (rt === undefined) {
434
- return Promise.reject(new Error('TokenManager: cannot refresh — no refresh token is available.'));
435
- }
436
- this.inFlight = (async () => {
437
- const tok = await this.refreshFn(rt);
438
- this.accessToken = tok.accessToken;
439
- if (tok.refreshToken !== undefined && tok.refreshToken !== '') {
440
- this.refreshToken = tok.refreshToken;
441
- }
442
- this.expiresAt = tok.expiresAt;
443
- })().finally(() => {
902
+ if (this.inFlight === undefined) {
903
+ this.inFlight = this.runRefresh().finally(() => {
444
904
  this.inFlight = undefined;
445
905
  });
446
906
  }
447
907
  return this.inFlight;
448
908
  }
909
+ /** One refresh attempt against the current refresh token. */
910
+ async runRefresh() {
911
+ const current = this.tokens ?? (await this.ensureTokens());
912
+ const rt = current.refreshToken;
913
+ if (rt === undefined) {
914
+ throw new Error('TokenManager: cannot refresh — no refresh token is available.');
915
+ }
916
+ const tok = await this.refreshFn(rt);
917
+ this.tokens = {
918
+ accessToken: tok.accessToken,
919
+ // Rotation is optional: keep the current refresh token when none comes back.
920
+ refreshToken: tok.refreshToken !== undefined && tok.refreshToken !== '' ? tok.refreshToken : rt,
921
+ expiresAt: tok.expiresAt,
922
+ };
923
+ await this.persist(this.tokens);
924
+ }
925
+ /**
926
+ * Recover from a refresh the current credential could not satisfy — commonly
927
+ * a refresh token restored from a previous process and revoked since. Without
928
+ * a bootstrap to fall back on this is terminal; with one, re-minting beats
929
+ * staying broken forever. Shared so the two entry points cannot diverge.
930
+ */
931
+ async reBootstrap(err) {
932
+ // A write that failed says nothing about the credential — and the refresh
933
+ // that produced it SUCCEEDED, spending the old token upstream. Clearing the
934
+ // store here would destroy the only surviving copy.
935
+ if (err instanceof StatePersistenceError)
936
+ throw err.cause;
937
+ if (this.bootstrapFn === undefined)
938
+ throw err;
939
+ // Only a credential we believe is DEAD is worth destroying. A 5xx or a
940
+ // timeout leaves a perfectly good refresh token that the next call can use.
941
+ if (!this.isRefreshRevokedFn(err))
942
+ throw err;
943
+ this.tokens = undefined;
944
+ await this.clearPersisted();
945
+ return this.ensureTokens();
946
+ }
449
947
  /** Get a valid access token, refreshing proactively inside the skew window. */
450
948
  async getAccessToken() {
451
- if (this.needsRefresh())
452
- await this.refreshNow();
453
- return this.accessToken;
949
+ // Not `await this.ensureTokens()` unconditionally: with tokens already in
950
+ // hand that await would defer the refresh below by a microtask, and callers
951
+ // rely on a concurrent burst reaching the single-flight in the SAME tick.
952
+ let tokens = this.tokens ?? (await this.ensureTokens());
953
+ if (this.needsRefresh()) {
954
+ try {
955
+ await this.refreshNow();
956
+ }
957
+ catch (err) {
958
+ return (await this.reBootstrap(err)).accessToken;
959
+ }
960
+ tokens = this.tokens ?? tokens;
961
+ }
962
+ return tokens.accessToken;
454
963
  }
455
- /** Current absolute expiry (epoch ms). */
964
+ /** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */
456
965
  getExpiresAt() {
457
- return this.expiresAt;
966
+ return this.tokens?.expiresAt ?? 0;
458
967
  }
459
968
  /**
460
969
  * Run an authenticated request with reactive 401-replay. `call` receives a
@@ -472,13 +981,31 @@ export class TokenManager {
472
981
  const usedToken = await this.getAccessToken();
473
982
  let res = await call(usedToken);
474
983
  if (res.status === 401) {
475
- if (this.accessToken === usedToken)
476
- await this.refreshNow();
477
- res = await call(this.accessToken);
984
+ if (this.tokens?.accessToken === usedToken) {
985
+ // Same revoked-credential recovery getAccessToken has: a 401 replay must
986
+ // not be the one entry point that throws where the other re-mints.
987
+ try {
988
+ await this.refreshNow();
989
+ }
990
+ catch (err) {
991
+ await this.reBootstrap(err);
992
+ }
993
+ }
994
+ res = await call(this.tokens?.accessToken ?? usedToken);
478
995
  }
479
996
  return res;
480
997
  }
481
998
  }
999
+ /** Whether a parsed record has the shape of {@link PersistedCookieSession}. */
1000
+ function isPersistedCookieSession(raw) {
1001
+ if (raw === null || typeof raw !== 'object')
1002
+ return false;
1003
+ const r = raw;
1004
+ if (typeof r.sessionAt !== 'number' || !Number.isFinite(r.sessionAt))
1005
+ return false;
1006
+ // Field-by-field, like isBearerTokens: a primitive is not a session shape.
1007
+ return typeof r.session === 'object' && r.session !== null;
1008
+ }
482
1009
  /**
483
1010
  * Cookie-session analog of {@link TokenManager}: owns a site's cookie-session
484
1011
  * lifecycle with the same single-flight / replay / clear-on-settle discipline,
@@ -531,6 +1058,17 @@ export class CookieSessionManager {
531
1058
  maxAgeMs;
532
1059
  now;
533
1060
  onReplayLoginErrorFn;
1061
+ persistence;
1062
+ onPersistErrorFn;
1063
+ /** Persistence is consulted once per process; a miss must not be re-read. */
1064
+ persistenceRead = false;
1065
+ /**
1066
+ * Serializes persistence writes. `seed()` and `invalidate()` are synchronous
1067
+ * by contract and so fire-and-forget their save/clear; with an async backend a
1068
+ * slow save could otherwise land AFTER the clear that followed it and leave an
1069
+ * invalidated session on disk.
1070
+ */
1071
+ persistChain = Promise.resolve();
534
1072
  constructor(opts) {
535
1073
  this.loginFn = opts.login;
536
1074
  // Optional: ensure-only consumers (no per-request expiry path) omit it; the
@@ -540,6 +1078,8 @@ export class CookieSessionManager {
540
1078
  this.maxAgeMs = opts.maxAgeMs;
541
1079
  this.now = opts.now ?? Date.now;
542
1080
  this.onReplayLoginErrorFn = opts.onReplayLoginError;
1081
+ this.persistence = opts.persistence;
1082
+ this.onPersistErrorFn = opts.onPersistError;
543
1083
  }
544
1084
  /** The current session, or `undefined` before the first successful login. */
545
1085
  get current() {
@@ -588,6 +1128,12 @@ export class CookieSessionManager {
588
1128
  this.session = session;
589
1129
  this.sessionAt = this.now();
590
1130
  this.inFlight = undefined; // detach any in-flight login (it won't re-stamp)
1131
+ // The caller has installed a session, so the stored one is superseded and
1132
+ // must never be restored over it (or over a later invalidate()).
1133
+ this.persistenceRead = true;
1134
+ // Fire-and-forget: seed() is synchronous by contract, and a persistence
1135
+ // failure must not change what the caller just installed.
1136
+ void this.persist(session, this.sessionAt);
591
1137
  }
592
1138
  /**
593
1139
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
@@ -602,14 +1148,35 @@ export class CookieSessionManager {
602
1148
  const holder = {};
603
1149
  holder.p = (async () => {
604
1150
  try {
1151
+ // A restored session is the whole point: skip the login entirely.
1152
+ // Guarded rather than awaited unconditionally — with no persistence the
1153
+ // await would defer loginFn() past the tick a concurrent burst needs.
1154
+ const restored = this.persistence !== undefined && !this.persistenceRead
1155
+ ? await this.restoreFromPersistence()
1156
+ : null;
1157
+ if (restored !== null) {
1158
+ if (this.inFlight === holder.p) {
1159
+ this.session = restored.session;
1160
+ this.sessionAt = restored.sessionAt;
1161
+ }
1162
+ return restored.session;
1163
+ }
605
1164
  const session = await this.loginFn();
1165
+ const at = this.now();
606
1166
  if (this.inFlight === holder.p) {
607
1167
  this.session = session;
608
- this.sessionAt = this.now();
1168
+ this.sessionAt = at;
609
1169
  }
1170
+ await this.persist(session, at);
610
1171
  return session;
611
1172
  }
612
1173
  catch (err) {
1174
+ // A failed WRITE is not a failed login: routing it through
1175
+ // isPermanentError can cache a transient disk error as permanent, and
1176
+ // then every ensure() after the next expiry throws forever without ever
1177
+ // retrying. The session itself was minted fine.
1178
+ if (err instanceof StatePersistenceError)
1179
+ throw err.cause;
613
1180
  if (this.isPermanentErrorFn(err))
614
1181
  this.permanentError = err;
615
1182
  throw err;
@@ -630,6 +1197,86 @@ export class CookieSessionManager {
630
1197
  invalidate() {
631
1198
  this.session = undefined;
632
1199
  this.inFlight = undefined;
1200
+ // Fire-and-forget, and unconditional: the stored copy is the same session
1201
+ // that just proved unusable. Leaving it would have the next ensure() read
1202
+ // it back and loop on the very expiry that caused this call.
1203
+ void this.clearPersisted();
1204
+ }
1205
+ /**
1206
+ * The persisted session, if there is one worth using. Read at most once per
1207
+ * process — after that the in-memory session (or its absence) is the truth,
1208
+ * so an invalidate() cannot be undone by a stale file.
1209
+ */
1210
+ async restoreFromPersistence() {
1211
+ if (this.persistence === undefined || this.persistenceRead)
1212
+ return null;
1213
+ this.persistenceRead = true;
1214
+ try {
1215
+ // Through the chain, not around it: `invalidate()` queues its `clear()`,
1216
+ // and a read that jumped that queue would restore the very session the
1217
+ // clear is about to remove.
1218
+ let raw = null;
1219
+ await this.enqueuePersist(async () => {
1220
+ raw = await this.persistence?.load();
1221
+ });
1222
+ if (!isPersistedCookieSession(raw))
1223
+ return null;
1224
+ // Honour the proactive TTL against the ORIGINAL login time.
1225
+ if (this.maxAgeMs !== undefined && this.now() - raw.sessionAt >= this.maxAgeMs)
1226
+ return null;
1227
+ return raw;
1228
+ }
1229
+ catch {
1230
+ return null;
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Append a persistence op to the chain, preserving call order.
1235
+ *
1236
+ * The RETURNED promise can reject — that is the whole `StatePersistenceError`
1237
+ * path, and the awaited login write depends on it. Only the retained chain is
1238
+ * swallowed, so one failed write cannot poison every later one.
1239
+ */
1240
+ enqueuePersist(op) {
1241
+ const next = this.persistChain.then(op);
1242
+ this.persistChain = next.catch(() => undefined);
1243
+ return next;
1244
+ }
1245
+ /** Write the session. Silent unless `onPersistError` throws. */
1246
+ persist(session, sessionAt) {
1247
+ return this.enqueuePersist(async () => {
1248
+ if (this.persistence === undefined)
1249
+ return;
1250
+ try {
1251
+ await this.persistence.save({ session, sessionAt });
1252
+ }
1253
+ catch (err) {
1254
+ // The in-memory session is still usable for this process. A hook that
1255
+ // rethrows is wrapped so runLogin can tell a disk failure from a login
1256
+ // failure — see the catch there.
1257
+ if (this.onPersistErrorFn === undefined)
1258
+ return;
1259
+ try {
1260
+ this.onPersistErrorFn(err);
1261
+ }
1262
+ catch (hookErr) {
1263
+ throw new StatePersistenceError(hookErr);
1264
+ }
1265
+ }
1266
+ });
1267
+ }
1268
+ /** Discard the persisted session. Never throws. */
1269
+ clearPersisted() {
1270
+ return this.enqueuePersist(async () => {
1271
+ if (this.persistence?.clear === undefined)
1272
+ return;
1273
+ try {
1274
+ await this.persistence.clear();
1275
+ }
1276
+ catch {
1277
+ /* best-effort */
1278
+ }
1279
+ });
633
1280
  }
634
1281
  /**
635
1282
  * Run an authenticated `call` with the current session and reactive