@softeria/ms-365-mcp-server 0.146.1 → 0.146.2

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,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import logger from "./logger.js";
2
3
  import { getRequestTokens } from "./request-context.js";
3
4
  function registerAuthTools(server, authManager) {
4
5
  server.tool(
@@ -41,7 +42,10 @@ function registerAuthTools(server, authManager) {
41
42
  };
42
43
  }
43
44
  const text = await new Promise((resolve, reject) => {
44
- authManager.acquireTokenByDeviceCode(resolve).catch(reject);
45
+ authManager.acquireTokenByDeviceCode(resolve).catch((error) => {
46
+ logger.error(`Device code login failed after the code was issued: ${error.message}`);
47
+ reject(error);
48
+ });
45
49
  });
46
50
  return {
47
51
  content: [
package/dist/auth.js CHANGED
@@ -5,6 +5,9 @@ import { fileURLToPath } from "url";
5
5
  import path from "path";
6
6
  import { getSecrets } from "./secrets.js";
7
7
  import { getCloudEndpoints, getDefaultClientId } from "./cloud-config.js";
8
+ import {
9
+ dedupeRefreshTokens
10
+ } from "./lib/cache-dedupe.js";
8
11
  import {
9
12
  createTokenCacheStorage,
10
13
  DefaultTokenCacheStorage,
@@ -31,13 +34,104 @@ function createMsalConfig(secrets) {
31
34
  }
32
35
  };
33
36
  }
37
+ const warnedDuplicateRefreshTokens = /* @__PURE__ */ new Set();
38
+ let unresolvedDuplicateAccounts = /* @__PURE__ */ new Set();
39
+ function resetRefreshTokenWarningsForTests() {
40
+ warnedDuplicateRefreshTokens.clear();
41
+ unresolvedDuplicateAccounts = /* @__PURE__ */ new Set();
42
+ }
43
+ function canonicalKeyResolver(tokenCache) {
44
+ const storage = tokenCache?.storage;
45
+ const generate = storage?.generateCredentialKey;
46
+ if (typeof generate !== "function") return void 0;
47
+ return (entity) => {
48
+ if (!entity.home_account_id || !entity.environment || !entity.client_id) return void 0;
49
+ try {
50
+ const key = generate.call(storage, {
51
+ homeAccountId: entity.home_account_id,
52
+ environment: entity.environment,
53
+ credentialType: "RefreshToken",
54
+ clientId: entity.client_id,
55
+ ...entity.family_id ? { familyId: entity.family_id } : {},
56
+ // Each of these owns a segment of the key. MSAL does not put them on refresh tokens
57
+ // today, but a serialized entity can carry them, and dropping them would map two
58
+ // different credentials onto one key - and then this deletes a live one
59
+ ...entity.realm ? { realm: entity.realm } : {},
60
+ ...entity.target ? { target: entity.target } : {},
61
+ ...entity.token_type ? { tokenType: entity.token_type } : {},
62
+ secret: entity.secret ?? ""
63
+ });
64
+ return typeof key === "string" ? key : void 0;
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ };
69
+ }
70
+ function pruneDuplicateRefreshTokens(data, tokenCache) {
71
+ const result = dedupeRefreshTokens(
72
+ data,
73
+ tokenCache === void 0 ? void 0 : canonicalKeyResolver(tokenCache)
74
+ );
75
+ for (const drop of result.dropped) {
76
+ const signature = `${drop.environment}->${drop.keptEnvironment}`;
77
+ if (warnedDuplicateRefreshTokens.has(signature)) continue;
78
+ warnedDuplicateRefreshTokens.add(signature);
79
+ logger.warn(
80
+ `Dropped a stale refresh token cached under ${drop.environment}, keeping the one under ${drop.keptEnvironment}. MSAL treats those as the same account and spends whichever it finds first, which is what strands a sign-in on a months-old token (issue #648).`
81
+ );
82
+ }
83
+ unresolvedDuplicateAccounts = new Set(result.ambiguousAccounts);
84
+ if (result.ambiguous > 0 && !warnedDuplicateRefreshTokens.has("ambiguous")) {
85
+ warnedDuplicateRefreshTokens.add("ambiguous");
86
+ logger.warn(
87
+ `The auth cache holds ${result.ambiguous} set(s) of duplicate refresh tokens with nothing to say which is current, so they were left alone. If silent refresh keeps failing for an account, log out and sign in again to clear them.`
88
+ );
89
+ }
90
+ return result;
91
+ }
92
+ function dropFromKVStore(tokenCache, dropped) {
93
+ if (dropped.length === 0) return;
94
+ const store = tokenCache.getKVStore?.();
95
+ if (!store) return;
96
+ for (const drop of dropped) {
97
+ delete store[drop.key];
98
+ }
99
+ }
100
+ const PERSISTENCE_RECHECK_MS = 150;
101
+ function delay(ms) {
102
+ return new Promise((resolve) => setTimeout(resolve, ms));
103
+ }
104
+ function persistedRefreshTokens(cacheJson, homeAccountId) {
105
+ let refreshTokens;
106
+ try {
107
+ const parsed = JSON.parse(cacheJson);
108
+ if (typeof parsed !== "object" || parsed === null) return void 0;
109
+ refreshTokens = parsed.RefreshToken;
110
+ } catch {
111
+ return void 0;
112
+ }
113
+ const secrets = /* @__PURE__ */ new Set();
114
+ if (typeof refreshTokens !== "object" || refreshTokens === null) return secrets;
115
+ for (const entity of Object.values(refreshTokens)) {
116
+ if (typeof entity !== "object" || entity === null) continue;
117
+ const credential = entity;
118
+ if (credential.home_account_id !== homeAccountId) continue;
119
+ if (credential.secret) secrets.add(credential.secret);
120
+ }
121
+ return secrets;
122
+ }
34
123
  function buildDiskCoherencyCachePlugin(storage) {
35
124
  return {
36
125
  beforeCacheAccess: async (context) => {
37
126
  try {
38
127
  const cacheRaw = await storage.load("token-cache");
39
128
  if (cacheRaw) {
40
- context.tokenCache.deserialize(unwrapCache(cacheRaw).data);
129
+ const pruned = pruneDuplicateRefreshTokens(
130
+ unwrapCache(cacheRaw).data,
131
+ context.tokenCache
132
+ );
133
+ context.tokenCache.deserialize(pruned.data);
134
+ dropFromKVStore(context.tokenCache, pruned.dropped);
41
135
  }
42
136
  } catch (error) {
43
137
  logger.error(`Error reloading token cache: ${error.message}`);
@@ -334,6 +428,12 @@ function describeAuthError(error) {
334
428
  }
335
429
  return error.message;
336
430
  }
431
+ function duplicateRefreshTokenHint(account, unresolvedAccounts) {
432
+ if (!account || !unresolvedAccounts.has(account.homeAccountId)) {
433
+ return null;
434
+ }
435
+ return "The auth cache holds more than one refresh token for this account and nothing indicates which is current, so a stale one may be winning. Logging in again will not clear it - run logout first, then log in.";
436
+ }
337
437
  const MSA_HOME_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
338
438
  function consumersAuthorityHint(error, account, authority) {
339
439
  if (error instanceof AuthError && error.errorCode === "invalid_grant" && account?.tenantId === MSA_HOME_TENANT_ID && (!authority || /\/common\/?$/i.test(authority))) {
@@ -358,6 +458,7 @@ class AuthManager {
358
458
  this.tokenExpiry = null;
359
459
  this.selectedAccountId = null;
360
460
  this.useInteractiveAuth = false;
461
+ this.loginPersistence = null;
361
462
  this.expectedUsername = this.normalizeExpectedUsername(expectedAccount?.expectedUsername);
362
463
  this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
363
464
  expectedAccount?.expectedHomeAccountId
@@ -380,7 +481,10 @@ class AuthManager {
380
481
  try {
381
482
  const cacheRaw = await this.storage.load("token-cache");
382
483
  if (cacheRaw) {
383
- this.msalApp.getTokenCache().deserialize(unwrapCache(cacheRaw).data);
484
+ const tokenCache = this.msalApp.getTokenCache();
485
+ const pruned = pruneDuplicateRefreshTokens(unwrapCache(cacheRaw).data, tokenCache);
486
+ tokenCache.deserialize(pruned.data);
487
+ dropFromKVStore(tokenCache, pruned.dropped);
384
488
  }
385
489
  await this.loadSelectedAccount();
386
490
  } catch (error) {
@@ -531,6 +635,115 @@ class AuthManager {
531
635
  `Microsoft login did not return an account. Expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
532
636
  );
533
637
  }
638
+ async readPersistedRefreshTokens(homeAccountId) {
639
+ const cacheRaw = await this.storage.load("token-cache");
640
+ return cacheRaw ? persistedRefreshTokens(unwrapCache(cacheRaw).data, homeAccountId) : /* @__PURE__ */ new Set();
641
+ }
642
+ /**
643
+ * The cache as it stood before a sign-in, so the check afterwards can tell a write that
644
+ * never happened from one a sibling overwrote. The account is not known until the sign-in
645
+ * returns, so keep the whole blob and filter later. Best-effort.
646
+ */
647
+ async snapshotPersistedCache() {
648
+ try {
649
+ const cacheRaw = await this.storage.load("token-cache");
650
+ return cacheRaw ? unwrapCache(cacheRaw).data : void 0;
651
+ } catch {
652
+ return void 0;
653
+ }
654
+ }
655
+ /**
656
+ * Records why a sign-in could not be confirmed, then throws.
657
+ *
658
+ * The message has to outlive the throw. On the MCP `login` path the tool has already
659
+ * returned - it resolves as soon as there is a device code to show - so this rejection
660
+ * has nowhere to go, and `verify login` is where the user looks next. Clearing the
661
+ * in-memory token stops that check from answering out of memory and calling it a
662
+ * success (issue #648).
663
+ */
664
+ failLoginPersistence(account, reason) {
665
+ const message = `Signed in as '${this.describeAccount(account)}', but ${reason}`;
666
+ this.loginPersistence = { homeAccountId: account.homeAccountId, message };
667
+ this.accessToken = null;
668
+ this.tokenExpiry = null;
669
+ logger.error(message);
670
+ throw new Error(message);
671
+ }
672
+ /** Refresh token secrets MSAL holds for an account right now, before anything reloads. */
673
+ inMemoryRefreshTokens(homeAccountId) {
674
+ const secrets = /* @__PURE__ */ new Set();
675
+ const cache = this.msalApp.getTokenCache();
676
+ const store = cache.getKVStore?.();
677
+ if (!store) return secrets;
678
+ for (const entity of Object.values(store)) {
679
+ if (typeof entity !== "object" || entity === null) continue;
680
+ const credential = entity;
681
+ if (credential.credentialType !== "RefreshToken") continue;
682
+ if (credential.homeAccountId !== homeAccountId) continue;
683
+ if (credential.secret) secrets.add(credential.secret);
684
+ }
685
+ return secrets;
686
+ }
687
+ /**
688
+ * Confirms the refresh token MSAL just issued actually reached the cache.
689
+ *
690
+ * Persistence is best-effort by design: DefaultTokenCacheStorage is not fail-closed, so
691
+ * afterCacheAccess swallows a refused or failed write and the sign-in still reports
692
+ * success. The account then works for exactly one access token lifetime - the one held
693
+ * in memory - and every silent refresh afterwards falls back to whatever stale token is
694
+ * still on disk. Nothing surfaces that until it has been happening for weeks, by which
695
+ * point the stale token has aged past Entra's 90-day inactivity limit and the account is
696
+ * dead (issue #648). Cheaper to read the cache back once than to debug that later.
697
+ *
698
+ * `before` is the cache ahead of the sign-in. Demanding the issued token on disk is right
699
+ * until you remember the cache is last-writer-wins, not locked (issue #545): a sibling
700
+ * mid-refresh can rotate the same credential just after this one saved. That leaves a
701
+ * live token that isn't ours, which is a working account. The failure is a write that
702
+ * left no trace at all.
703
+ */
704
+ async assertLoginPersisted(account, before) {
705
+ this.loginPersistence = null;
706
+ if (!account) return;
707
+ const issued = this.inMemoryRefreshTokens(account.homeAccountId);
708
+ if (issued.size === 0) return;
709
+ let persisted;
710
+ try {
711
+ persisted = await this.readPersistedRefreshTokens(account.homeAccountId);
712
+ if (persisted && ![...issued].every((secret) => persisted.has(secret))) {
713
+ await delay(PERSISTENCE_RECHECK_MS);
714
+ persisted = await this.readPersistedRefreshTokens(account.homeAccountId);
715
+ }
716
+ } catch (error) {
717
+ this.failLoginPersistence(
718
+ account,
719
+ `the auth cache could not be read back (${error.message}), so the sign-in cannot be confirmed as saved. It would stop working when this access token expires. Fix the cache location, then log in again.`
720
+ );
721
+ }
722
+ if (persisted === void 0) {
723
+ this.failLoginPersistence(
724
+ account,
725
+ `the auth cache did not read back as a usable cache, so the sign-in cannot be confirmed as saved. It would stop working when this access token expires. Check the log for 'Not persisting token-cache'.`
726
+ );
727
+ }
728
+ if (![...issued].every((secret) => persisted.has(secret))) {
729
+ const beforeSecrets = before === void 0 ? void 0 : persistedRefreshTokens(before, account.homeAccountId);
730
+ const supersededByAnotherProcess = beforeSecrets !== void 0 && [...persisted].some((secret) => !beforeSecrets.has(secret));
731
+ if (!supersededByAnotherProcess) {
732
+ this.failLoginPersistence(
733
+ account,
734
+ `the refresh token was not written to the auth cache, so access would stop working when this access token expires. The log says why - look for 'Not persisting token-cache' or 'Error saving token cache'.`
735
+ );
736
+ }
737
+ logger.warn(
738
+ `Another process rewrote the auth cache for '${this.describeAccount(account)}' during sign-in. The refresh token on disk is not the one just issued, but it was not there before this sign-in either, so something wrote a live credential and the sign-in counts as saved (issue #545).`
739
+ );
740
+ }
741
+ if (persisted.size > 1) {
742
+ logger.warn(
743
+ `The auth cache holds ${persisted.size} refresh tokens for '${this.describeAccount(account)}'. MSAL spends whichever it finds first, so a stale one can win (issue #648). Log out and sign in again if silent refresh starts failing.`
744
+ );
745
+ }
746
+ }
534
747
  async setOAuthToken(token) {
535
748
  this.oauthToken = token;
536
749
  this.isOAuthMode = true;
@@ -554,7 +767,10 @@ class AuthManager {
554
767
  this.tokenExpiry = response.expiresOn ? new Date(response.expiresOn).getTime() : null;
555
768
  return this.accessToken;
556
769
  } catch (error) {
557
- const hint = consumersAuthorityHint(error, currentAccount, this.config.auth.authority);
770
+ const hint = [
771
+ consumersAuthorityHint(error, currentAccount, this.config.auth.authority),
772
+ duplicateRefreshTokenHint(currentAccount, unresolvedDuplicateAccounts)
773
+ ].filter(Boolean).join(" ") || null;
558
774
  logger.error(
559
775
  `Silent token acquisition failed: ${describeAuthError(error)}${hint ? ` ${hint}` : ""}`
560
776
  );
@@ -602,12 +818,14 @@ class AuthManager {
602
818
  try {
603
819
  logger.info("Requesting device code...");
604
820
  logger.info(`Requesting scopes: ${this.scopes.join(", ")}`);
821
+ const before = await this.snapshotPersistedCache();
605
822
  const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest);
606
823
  logger.info(`Granted scopes: ${response?.scopes?.join(", ") || "none"}`);
607
824
  logger.info("Device code login successful");
608
825
  this.accessToken = response?.accessToken || null;
609
826
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
610
827
  await this.rejectUnexpectedLoginAccount(response?.account);
828
+ await this.assertLoginPersisted(response?.account, before);
611
829
  if (!this.selectedAccountId && response?.account) {
612
830
  this.selectedAccountId = response.account.homeAccountId;
613
831
  await this.saveSelectedAccount();
@@ -643,12 +861,14 @@ class AuthManager {
643
861
  try {
644
862
  logger.info("Requesting interactive browser login...");
645
863
  logger.info(`Requesting scopes: ${this.scopes.join(", ")}`);
864
+ const before = await this.snapshotPersistedCache();
646
865
  const response = await this.msalApp.acquireTokenInteractive(interactiveRequest);
647
866
  logger.info(`Granted scopes: ${response?.scopes?.join(", ") || "none"}`);
648
867
  logger.info("Interactive browser login successful");
649
868
  this.accessToken = response?.accessToken || null;
650
869
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
651
870
  await this.rejectUnexpectedLoginAccount(response?.account);
871
+ await this.assertLoginPersisted(response?.account, before);
652
872
  if (!this.selectedAccountId && response?.account) {
653
873
  this.selectedAccountId = response.account.homeAccountId;
654
874
  await this.saveSelectedAccount();
@@ -660,9 +880,25 @@ class AuthManager {
660
880
  throw error;
661
881
  }
662
882
  }
883
+ /**
884
+ * Whether the recorded failure is about the account this check would test.
885
+ *
886
+ * An explicit selection is the only thing that says "not asking about that sign-in".
887
+ * Resolving the account is not: a failed sign-in never reaches the auto-select and its
888
+ * account is usually missing from the cache anyway, so getCurrentAccount falls back to
889
+ * whichever is first and gives the neighbour a clean bill of health (issue #648).
890
+ */
891
+ loginPersistenceAppliesToCurrentAccount() {
892
+ if (!this.loginPersistence) return false;
893
+ return this.selectedAccountId === null || this.selectedAccountId === this.loginPersistence.homeAccountId;
894
+ }
663
895
  async testLogin() {
664
896
  try {
665
897
  logger.info("Testing login...");
898
+ if (this.loginPersistence && this.loginPersistenceAppliesToCurrentAccount()) {
899
+ logger.error(`Login test failed - ${this.loginPersistence.message}`);
900
+ return { success: false, message: this.loginPersistence.message };
901
+ }
666
902
  const token = await this.getToken();
667
903
  if (!token) {
668
904
  logger.error("Login test failed - no token received");
@@ -723,6 +959,7 @@ class AuthManager {
723
959
  this.accessToken = null;
724
960
  this.tokenExpiry = null;
725
961
  this.selectedAccountId = null;
962
+ this.loginPersistence = null;
726
963
  await this.storage.delete("token-cache");
727
964
  await this.storage.delete("selected-account");
728
965
  return true;
@@ -746,6 +983,9 @@ class AuthManager {
746
983
  await this.saveSelectedAccount();
747
984
  this.accessToken = null;
748
985
  this.tokenExpiry = null;
986
+ if (this.loginPersistence && this.loginPersistence.homeAccountId !== account.homeAccountId) {
987
+ this.loginPersistence = null;
988
+ }
749
989
  logger.info(`Selected account: ${account.username} (${account.homeAccountId})`);
750
990
  return true;
751
991
  }
@@ -753,6 +993,9 @@ class AuthManager {
753
993
  const account = await this.resolveAccount(identifier);
754
994
  try {
755
995
  await this.msalApp.getTokenCache().removeAccount(account);
996
+ if (this.loginPersistence?.homeAccountId === account.homeAccountId) {
997
+ this.loginPersistence = null;
998
+ }
756
999
  if (this.selectedAccountId === account.homeAccountId) {
757
1000
  this.selectedAccountId = null;
758
1001
  await this.saveSelectedAccount();
@@ -891,6 +1134,7 @@ export {
891
1134
  consumersAuthorityHint,
892
1135
  auth_default as default,
893
1136
  describeAuthError,
1137
+ duplicateRefreshTokenHint,
894
1138
  getEndpointRequiredScopes,
895
1139
  getEndpointScopeGroups,
896
1140
  getMissingAllowedScopes,
@@ -899,6 +1143,7 @@ export {
899
1143
  getTokenCachePath,
900
1144
  parseAllowedScopes,
901
1145
  pickNewest,
1146
+ resetRefreshTokenWarningsForTests,
902
1147
  resolveAuthScopes,
903
1148
  unwrapCache,
904
1149
  wrapCache
@@ -0,0 +1,126 @@
1
+ function isDict(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ function isEntity(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ function groupKey(homeAccountId, owner) {
8
+ return `${homeAccountId} ${owner}`;
9
+ }
10
+ function environmentPreferences(cache) {
11
+ const byOwner = /* @__PURE__ */ new Map();
12
+ if (isDict(cache.AccessToken)) {
13
+ for (const entity of Object.values(cache.AccessToken)) {
14
+ const home = entity?.home_account_id;
15
+ const environment = entity?.environment;
16
+ const client = entity?.client_id;
17
+ if (!home || !environment || !client) continue;
18
+ const cachedAt = Number(entity.cached_at);
19
+ if (!Number.isFinite(cachedAt)) continue;
20
+ const key = groupKey(home, client);
21
+ const best = byOwner.get(key);
22
+ if (!best || cachedAt > best.cachedAt) {
23
+ byOwner.set(key, { environment, cachedAt, tied: false });
24
+ } else if (cachedAt === best.cachedAt && environment !== best.environment) {
25
+ best.tied = true;
26
+ }
27
+ }
28
+ }
29
+ return byOwner;
30
+ }
31
+ function preferredEnvironment(preferences, group) {
32
+ const choice = preferences.get(groupKey(group.homeAccountId, group.owner));
33
+ if (!choice || choice.tied) return void 0;
34
+ return choice.environment;
35
+ }
36
+ function dropSupersededKeyFormats(refreshTokens, canonicalKeyFor) {
37
+ const byCanonical = /* @__PURE__ */ new Map();
38
+ for (const [key, entity] of Object.entries(refreshTokens)) {
39
+ if (!isEntity(entity)) continue;
40
+ const canonical = canonicalKeyFor(entity);
41
+ if (!canonical) continue;
42
+ byCanonical.set(canonical, [...byCanonical.get(canonical) ?? [], key]);
43
+ }
44
+ const dropped = [];
45
+ for (const [canonical, keys] of byCanonical) {
46
+ if (keys.length < 2 || !keys.includes(canonical)) continue;
47
+ for (const key of keys) {
48
+ if (key === canonical) continue;
49
+ dropped.push({
50
+ key,
51
+ environment: refreshTokens[key]?.environment ?? "unknown",
52
+ keptEnvironment: refreshTokens[canonical]?.environment ?? "unknown"
53
+ });
54
+ }
55
+ }
56
+ return dropped;
57
+ }
58
+ function dedupeRefreshTokens(cacheJson, canonicalKeyFor) {
59
+ const unchanged = {
60
+ data: cacheJson,
61
+ dropped: [],
62
+ ambiguous: 0,
63
+ ambiguousAccounts: []
64
+ };
65
+ let cache;
66
+ try {
67
+ const parsed = JSON.parse(cacheJson);
68
+ if (!isDict(parsed)) return unchanged;
69
+ cache = parsed;
70
+ } catch {
71
+ return unchanged;
72
+ }
73
+ const refreshTokens = cache.RefreshToken;
74
+ if (!isDict(refreshTokens)) return unchanged;
75
+ const supersededDrops = canonicalKeyFor ? dropSupersededKeyFormats(refreshTokens, canonicalKeyFor) : [];
76
+ for (const drop of supersededDrops) delete refreshTokens[drop.key];
77
+ const groups = /* @__PURE__ */ new Map();
78
+ for (const [key, entity] of Object.entries(refreshTokens)) {
79
+ if (!isEntity(entity)) continue;
80
+ const home = entity.home_account_id;
81
+ const owner = entity.family_id || entity.client_id;
82
+ if (!home || !owner) continue;
83
+ const group = groups.get(groupKey(home, owner));
84
+ if (group) group.keys.push(key);
85
+ else groups.set(groupKey(home, owner), { homeAccountId: home, owner, keys: [key] });
86
+ }
87
+ const duplicated = [...groups.values()].filter((group) => group.keys.length > 1);
88
+ if (duplicated.length === 0) {
89
+ return supersededDrops.length > 0 ? {
90
+ data: JSON.stringify(cache),
91
+ dropped: supersededDrops,
92
+ ambiguous: 0,
93
+ ambiguousAccounts: []
94
+ } : unchanged;
95
+ }
96
+ const preferences = environmentPreferences(cache);
97
+ const dropped = [...supersededDrops];
98
+ let ambiguous = 0;
99
+ const ambiguousAccounts = /* @__PURE__ */ new Set();
100
+ for (const group of duplicated) {
101
+ const keep = preferredEnvironment(preferences, group);
102
+ const winners = keep ? group.keys.filter((key) => refreshTokens[key]?.environment === keep) : [];
103
+ if (!keep || winners.length !== 1) {
104
+ ambiguous += 1;
105
+ ambiguousAccounts.add(group.homeAccountId);
106
+ continue;
107
+ }
108
+ for (const key of group.keys) {
109
+ if (key === winners[0]) continue;
110
+ dropped.push({
111
+ key,
112
+ environment: refreshTokens[key]?.environment ?? "unknown",
113
+ keptEnvironment: keep
114
+ });
115
+ }
116
+ }
117
+ const unranked = [...ambiguousAccounts];
118
+ if (dropped.length === 0) {
119
+ return { data: cacheJson, dropped, ambiguous, ambiguousAccounts: unranked };
120
+ }
121
+ for (const drop of dropped) delete refreshTokens[drop.key];
122
+ return { data: JSON.stringify(cache), dropped, ambiguous, ambiguousAccounts: unranked };
123
+ }
124
+ export {
125
+ dedupeRefreshTokens
126
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.146.1",
4
+ "version": "0.146.2",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",