@softeria/ms-365-mcp-server 0.146.2 → 0.147.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.
package/README.md CHANGED
@@ -667,6 +667,20 @@ Parent directories are created automatically. Files are written with `0600` perm
667
667
 
668
668
  **Without a credential store** (headless Linux, most containers) the key is written to `.cache-key` next to the cache file, with `0600` permissions. That stops the tokens showing up in a stray `cat`, a backup or an accidental commit. It does not protect against anyone who can already read the directory - the key is right there. Use `MS365_MCP_AUTH_CACHE_COMMAND` below if you need the cache in a real secret store.
669
669
 
670
+ **Skipping the credential store on purpose:**
671
+
672
+ ```bash
673
+ export MS365_MCP_USE_KEYTAR=0 # also accepts false, no or off
674
+ ```
675
+
676
+ The key then goes to `.cache-key` on every platform, exactly as it does where no credential store exists, and nothing in the server calls keytar. Useful when the credential store prompts on each start - macOS re-asks whenever the calling binary changes, which under `npx` is every version bump - or when the native module misbehaves on your platform rather than simply failing to load. Any other value leaves the credential store in use, and an unrecognised one is warned about rather than passed over silently.
677
+
678
+ Switching it off strands a cache that was encrypted under a key already in the credential store, since nothing can reach that key any more. The server says so and replaces that cache on the next sign-in, which signs out **every** account it held, not just the one you sign back in as. Unset the variable first if that cache is worth keeping.
679
+
680
+ Only a cache that nothing on the machine can open is replaced. One that fails to decrypt while a usable key is sitting right there - a truncated file, a downgrade to an older build, a cache from somewhere else - is damage rather than a stranded cache, and is left alone exactly as it is by default.
681
+
682
+ Two things it deliberately does not do. It never deletes what this server already put in the credential store, on logout or otherwise, because reaching the store is the thing you just asked it to stop doing - clear the `ms-365-mcp-server` entries by hand if you want them gone. And a `.cache-key` that exists but cannot be read (wrong owner on a bind-mounted config directory, say) is treated as recoverable rather than missing: the server refuses both to overwrite a cache and to mint a replacement key, and says so, rather than deleting a key that would work again once the permissions are fixed. Fix the permissions, or delete `.cache-key` yourself to start over - which does mean signing in again.
683
+
670
684
  If the cache cannot be decrypted - key lost, keychain locked, file modified - you are asked to sign in again rather than the server failing to start. The cache file is left exactly as it was: not deleted, and not overwritten by that new sign-in either. A keychain that is merely locked usually reads fine on the next start, and the cache is still there when it does.
671
685
 
672
686
  The cost is that the new session is not saved while this lasts, so each start asks you to sign in again. If the key is genuinely gone and the cache will never open, delete `.token-cache.json` to start over - the log says so, and names the path.
@@ -18,6 +18,7 @@ const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
18
18
  const SELECTED_ACCOUNT_KEY = "selected-account";
19
19
  const AUTH_CACHE_COMMAND_ENV = "MS365_MCP_AUTH_CACHE_COMMAND";
20
20
  const AUTH_CACHE_COMMAND_TIMEOUT_ENV = "MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS";
21
+ const USE_KEYTAR_ENV = "MS365_MCP_USE_KEYTAR";
21
22
  const DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS = 1e4;
22
23
  const STDERR_LIMIT = 2048;
23
24
  const COMMAND_KILL_GRACE_MS = 1e3;
@@ -28,7 +29,33 @@ const CACHE_KEY_FILE = ".cache-key";
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const LEGACY_DIR = path.join(path.dirname(__filename), "..");
30
31
  let keytar = null;
32
+ let loggedKeytarOptOut = false;
33
+ let warnedKeytarValue = false;
34
+ const KEYTAR_OFF_VALUES = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
35
+ const KEYTAR_ON_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
36
+ function keytarEnabled() {
37
+ const raw = process.env[USE_KEYTAR_ENV];
38
+ if (raw === void 0) return true;
39
+ const value = raw.trim().toLowerCase();
40
+ if (KEYTAR_OFF_VALUES.has(value)) return false;
41
+ if (value !== "" && !KEYTAR_ON_VALUES.has(value) && !warnedKeytarValue) {
42
+ warnedKeytarValue = true;
43
+ logger.warn(
44
+ `${USE_KEYTAR_ENV} is set to ${JSON.stringify(raw)}, which is not a value this understands, so the system credential store stays in use. Set it to one of ${[...KEYTAR_OFF_VALUES].join(", ")} to turn it off.`
45
+ );
46
+ }
47
+ return true;
48
+ }
31
49
  async function getKeytar() {
50
+ if (!keytarEnabled()) {
51
+ if (!loggedKeytarOptOut) {
52
+ loggedKeytarOptOut = true;
53
+ logger.info(
54
+ `${USE_KEYTAR_ENV} is off, using file-based credential storage. Anything this server previously stored under "${SERVICE_NAME}" in the system credential store is now left alone, including on logout, so clear it by hand if you want it gone.`
55
+ );
56
+ }
57
+ return null;
58
+ }
32
59
  if (keytar === void 0) {
33
60
  return null;
34
61
  }
@@ -172,6 +199,7 @@ function writeFileAtomically(filePath, value) {
172
199
  }
173
200
  let keyStatePromise;
174
201
  let encryptionKeyPromise;
202
+ const mintedKeys = [];
175
203
  const legacyKeytarEntries = /* @__PURE__ */ new Set();
176
204
  const warnedUndecryptable = /* @__PURE__ */ new Set();
177
205
  function resetCacheKeyForTests() {
@@ -180,6 +208,9 @@ function resetCacheKeyForTests() {
180
208
  legacyKeytarEntries.clear();
181
209
  warnedUndecryptable.clear();
182
210
  legacyPathsMigrated = false;
211
+ loggedKeytarOptOut = false;
212
+ warnedKeytarValue = false;
213
+ mintedKeys.length = 0;
183
214
  }
184
215
  async function clearLegacyKeytarEntry(key) {
185
216
  if (!legacyKeytarEntries.delete(key)) return;
@@ -222,16 +253,26 @@ async function readCacheKeys() {
222
253
  }
223
254
  }
224
255
  let fileKeyIndex;
256
+ let fileKeyUnreadable = false;
225
257
  const keyPath = getCacheKeyPath();
226
- if (existsSync(keyPath)) {
258
+ let raw;
259
+ try {
260
+ raw = readFileSync(keyPath, "utf8");
261
+ } catch (error) {
262
+ if (error.code !== "ENOENT") {
263
+ fileKeyUnreadable = true;
264
+ logger.warn(`Could not read the auth cache key file: ${error.message}`);
265
+ }
266
+ }
267
+ if (raw !== void 0) {
227
268
  try {
228
- keys.push(parseCacheKey(readFileSync(keyPath, "utf8")));
269
+ keys.push(parseCacheKey(raw));
229
270
  fileKeyIndex = keys.length - 1;
230
271
  } catch (error) {
231
272
  logger.warn(`Ignoring unusable auth cache key file: ${error.message}`);
232
273
  }
233
274
  }
234
- return { keys, fileKeyIndex, canUseKeychain };
275
+ return { keys, fileKeyIndex, canUseKeychain, fileKeyUnreadable };
235
276
  }
236
277
  function getEncryptionKey() {
237
278
  if (!encryptionKeyPromise) {
@@ -247,7 +288,13 @@ async function resolveEncryptionKey() {
247
288
  const state = await loadKeyState();
248
289
  const preferred = state.keys[state.fileKeyIndex ?? 0];
249
290
  if (preferred) return preferred;
291
+ if (state.fileKeyUnreadable) {
292
+ throw new Error(
293
+ `Refusing to replace the auth cache key at ${getCacheKeyPath()}: it exists but could not be read this run, and minting over it would strand every cache encrypted under it. Fix the permissions on that file, or delete it to start over.`
294
+ );
295
+ }
250
296
  const minted = await persistCacheKey(generateCacheKey(), state.canUseKeychain);
297
+ if (!mintedKeys.some((k) => k.equals(minted))) mintedKeys.push(minted);
251
298
  state.keys.unshift(minted);
252
299
  if (state.fileKeyIndex !== void 0) state.fileKeyIndex += 1;
253
300
  return minted;
@@ -278,8 +325,16 @@ async function persistCacheKey(key, canUseKeychain) {
278
325
  );
279
326
  return key;
280
327
  }
328
+ let existingRaw;
329
+ try {
330
+ existingRaw = readFileSync(keyPath, "utf8");
331
+ } catch (readError) {
332
+ throw new Error(
333
+ `Refusing to replace the auth cache key file at ${keyPath}: it could not be read (${readError.message}), so whether it holds a usable key is unknown.`
334
+ );
335
+ }
281
336
  try {
282
- const existing = parseCacheKey(readFileSync(keyPath, "utf8"));
337
+ const existing = parseCacheKey(existingRaw);
283
338
  logger.info("Another process created the auth cache key file first, adopting it");
284
339
  return existing;
285
340
  } catch (parseError) {
@@ -333,12 +388,17 @@ async function readCacheFile(key, cachePath) {
333
388
  }
334
389
  if (onDisk === "") return { status: "absent" };
335
390
  if (isEncryptedCache(onDisk)) {
336
- const { keys } = await loadKeyState();
337
- const decrypted = decryptWithAnyKey(onDisk, keys, key);
391
+ const state = await loadKeyState();
392
+ const decrypted = decryptWithAnyKey(onDisk, state.keys, key);
338
393
  if (decrypted !== void 0) return { status: "decrypted", raw: decrypted };
339
394
  keyStatePromise = void 0;
340
395
  encryptionKeyPromise = void 0;
341
- return { status: "unreadable", reason: "no known key opens it" };
396
+ const preExisting = state.keys.filter((k) => !mintedKeys.some((m) => m.equals(k)));
397
+ return {
398
+ status: "unreadable",
399
+ reason: state.fileKeyUnreadable ? "a key file is present but could not be read" : preExisting.length > 0 ? "the keys on hand do not open it" : "no known key opens it",
400
+ noKeyMatched: preExisting.length === 0 && !state.fileKeyUnreadable
401
+ };
342
402
  }
343
403
  if (isPlainCacheJson(onDisk)) return { status: "plaintext", raw: onDisk };
344
404
  return { status: "unreadable", reason: "not a recognisable auth cache" };
@@ -366,6 +426,15 @@ async function assertOverwritable(key, cachePath) {
366
426
  warnedUndecryptable.delete(key);
367
427
  return;
368
428
  }
429
+ if (state.noKeyMatched && !keytarEnabled()) {
430
+ if (!warnedUndecryptable.has(key)) {
431
+ warnedUndecryptable.add(key);
432
+ logger.warn(
433
+ `Replacing ${cachePath}: there is no auth cache key on this machine to open it with, and ${USE_KEYTAR_ENV} is off, so the key it was written under is most likely the one in the system credential store. Signing in again rewrites it against the key file instead. If that cache is worth keeping, stop the server and unset ${USE_KEYTAR_ENV} before signing in.`
434
+ );
435
+ }
436
+ return;
437
+ }
369
438
  if (!warnedUndecryptable.has(key)) {
370
439
  warnedUndecryptable.add(key);
371
440
  logger.warn(
@@ -614,6 +683,7 @@ export {
614
683
  getCacheKeyPath,
615
684
  getSelectedAccountPath,
616
685
  getTokenCachePath,
686
+ keytarEnabled,
617
687
  migrateLegacyPathsFrom,
618
688
  pickNewest,
619
689
  resetCacheKeyForTests,
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.2",
4
+ "version": "0.147.0",
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",