@softeria/ms-365-mcp-server 0.146.2 → 0.148.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.
@@ -165,6 +165,38 @@
165
165
  "workScopes": ["Mail.Send.Shared"],
166
166
  "llmTip": "Forward a message from a shared mailbox preserving full HTML formatting and attachments. The 'user-id' is the shared mailbox email address. 'toRecipients' is required. The 'comment' field adds text above the forwarded content. Requires Send As permission on the shared mailbox in Exchange."
167
167
  },
168
+ {
169
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createReply",
170
+ "method": "post",
171
+ "toolName": "create-shared-mailbox-reply-draft",
172
+ "presets": ["work"],
173
+ "workScopes": ["Mail.ReadWrite.Shared"],
174
+ "llmTip": "Create a reply draft in a shared mailbox preserving thread metadata (conversationId, In-Reply-To, References). The 'user-id' is the shared mailbox email address (e.g. support@contoso.com). Does not send — use send-shared-mailbox-draft to send afterwards. For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Specifying both 'comment' and Message.body returns 400. Requires delegated access to the shared mailbox."
175
+ },
176
+ {
177
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createReplyAll",
178
+ "method": "post",
179
+ "toolName": "create-shared-mailbox-reply-all-draft",
180
+ "presets": ["work"],
181
+ "workScopes": ["Mail.ReadWrite.Shared"],
182
+ "llmTip": "Create a reply-all draft in a shared mailbox preserving thread metadata (conversationId, In-Reply-To, References). The 'user-id' is the shared mailbox email address. Does not send — use send-shared-mailbox-draft to send afterwards. For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Specifying both 'comment' and Message.body returns 400. Requires delegated access to the shared mailbox."
183
+ },
184
+ {
185
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createForward",
186
+ "method": "post",
187
+ "toolName": "create-shared-mailbox-forward-draft",
188
+ "presets": ["work"],
189
+ "workScopes": ["Mail.ReadWrite.Shared"],
190
+ "llmTip": "Create a forward draft in a shared mailbox without sending. The 'user-id' is the shared mailbox email address. Does not send — use send-shared-mailbox-draft to send afterwards. Useful when the user wants to review before the forward goes out. Requires delegated access to the shared mailbox."
191
+ },
192
+ {
193
+ "pathPattern": "/users/{user-id}/messages/{message-id}/send",
194
+ "method": "post",
195
+ "toolName": "send-shared-mailbox-draft",
196
+ "presets": ["work"],
197
+ "workScopes": ["Mail.Send.Shared"],
198
+ "llmTip": "Send an existing draft from a shared mailbox. No request body needed — just call with the message ID and the shared mailbox user-id. Draft must exist in the shared mailbox's Drafts folder. Requires Send As permission on the shared mailbox in Exchange."
199
+ },
168
200
  {
169
201
  "pathPattern": "/users",
170
202
  "method": "get",
@@ -15252,6 +15252,64 @@ Only the policyViolation property of a chatMessage can be updated in application
15252
15252
  ],
15253
15253
  response: z.void()
15254
15254
  },
15255
+ {
15256
+ method: "post",
15257
+ path: "/users/:userId/messages/:messageId/createForward",
15258
+ alias: "create-shared-mailbox-forward-draft",
15259
+ description: `Create a shared mailbox forward draft.`,
15260
+ requestFormat: "json",
15261
+ parameters: [
15262
+ {
15263
+ name: "body",
15264
+ description: `Action parameters`,
15265
+ type: "Body",
15266
+ schema: create_forward_draft_Body
15267
+ }
15268
+ ],
15269
+ response: z.void()
15270
+ },
15271
+ {
15272
+ method: "post",
15273
+ path: "/users/:userId/messages/:messageId/createReply",
15274
+ alias: "create-shared-mailbox-reply-draft",
15275
+ description: `Create a draft to reply to the sender of a message in either JSON or MIME format. When using JSON format:
15276
+ - Specify either a comment or the body property of the message parameter. Specifying both will return an HTTP 400 Bad Request error.
15277
+ - If replyTo is specified in the original message, per Internet Message Format (RFC 2822), you should send the reply to the recipients in replyTo, and not the recipients in from.
15278
+ - You can update the draft later to add reply content to the body or change other message properties. When using MIME format:
15279
+ - Provide the applicable Internet message headers and the MIME content, all encoded in base64 format in the request body.
15280
+ - Add any attachments and S/MIME properties to the MIME content. Send the draft message in a subsequent operation. Alternatively, reply to a message in a single operation.`,
15281
+ requestFormat: "json",
15282
+ parameters: [
15283
+ {
15284
+ name: "body",
15285
+ description: `Action parameters`,
15286
+ type: "Body",
15287
+ schema: create_reply_draft_Body
15288
+ }
15289
+ ],
15290
+ response: z.void()
15291
+ },
15292
+ {
15293
+ method: "post",
15294
+ path: "/users/:userId/messages/:messageId/createReplyAll",
15295
+ alias: "create-shared-mailbox-reply-all-draft",
15296
+ description: `Create a draft to reply to the sender and all recipients of a message in either JSON or MIME format. When using JSON format:
15297
+ - Specify either a comment or the body property of the message parameter. Specifying both will return an HTTP 400 Bad Request error.
15298
+ - If the original message specifies a recipient in the replyTo property, per Internet Message Format (RFC 2822), you should send the reply to the recipients in the replyTo and toRecipients properties, and not the recipients in the from and toRecipients properties.
15299
+ - You can update the draft later to add reply content to the body or change other message properties. When using MIME format:
15300
+ - Provide the applicable Internet message headers and the MIME content, all encoded in base64 format in the request body.
15301
+ - Add any attachments and S/MIME properties to the MIME content. Send the draft message in a subsequent operation. Alternatively, reply-all to a message in a single action.`,
15302
+ requestFormat: "json",
15303
+ parameters: [
15304
+ {
15305
+ name: "body",
15306
+ description: `Action parameters`,
15307
+ type: "Body",
15308
+ schema: create_reply_draft_Body
15309
+ }
15310
+ ],
15311
+ response: z.void()
15312
+ },
15255
15313
  {
15256
15314
  method: "post",
15257
15315
  path: "/users/:userId/messages/:messageId/forward",
@@ -15328,6 +15386,14 @@ Only the policyViolation property of a chatMessage can be updated in application
15328
15386
  ],
15329
15387
  response: z.void()
15330
15388
  },
15389
+ {
15390
+ method: "post",
15391
+ path: "/users/:userId/messages/:messageId/send",
15392
+ alias: "send-shared-mailbox-draft",
15393
+ description: `Send an existing draft message. The draft message can be a new message draft, reply draft, reply-all draft, or a forward draft. This method saves the message in the Sent Items folder. Alternatively, send a new message in a single operation.`,
15394
+ requestFormat: "json",
15395
+ response: z.void()
15396
+ },
15331
15397
  {
15332
15398
  method: "get",
15333
15399
  path: "/users/:userId/presence",
@@ -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.148.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",
@@ -165,6 +165,38 @@
165
165
  "workScopes": ["Mail.Send.Shared"],
166
166
  "llmTip": "Forward a message from a shared mailbox preserving full HTML formatting and attachments. The 'user-id' is the shared mailbox email address. 'toRecipients' is required. The 'comment' field adds text above the forwarded content. Requires Send As permission on the shared mailbox in Exchange."
167
167
  },
168
+ {
169
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createReply",
170
+ "method": "post",
171
+ "toolName": "create-shared-mailbox-reply-draft",
172
+ "presets": ["work"],
173
+ "workScopes": ["Mail.ReadWrite.Shared"],
174
+ "llmTip": "Create a reply draft in a shared mailbox preserving thread metadata (conversationId, In-Reply-To, References). The 'user-id' is the shared mailbox email address (e.g. support@contoso.com). Does not send — use send-shared-mailbox-draft to send afterwards. For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Specifying both 'comment' and Message.body returns 400. Requires delegated access to the shared mailbox."
175
+ },
176
+ {
177
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createReplyAll",
178
+ "method": "post",
179
+ "toolName": "create-shared-mailbox-reply-all-draft",
180
+ "presets": ["work"],
181
+ "workScopes": ["Mail.ReadWrite.Shared"],
182
+ "llmTip": "Create a reply-all draft in a shared mailbox preserving thread metadata (conversationId, In-Reply-To, References). The 'user-id' is the shared mailbox email address. Does not send — use send-shared-mailbox-draft to send afterwards. For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Specifying both 'comment' and Message.body returns 400. Requires delegated access to the shared mailbox."
183
+ },
184
+ {
185
+ "pathPattern": "/users/{user-id}/messages/{message-id}/createForward",
186
+ "method": "post",
187
+ "toolName": "create-shared-mailbox-forward-draft",
188
+ "presets": ["work"],
189
+ "workScopes": ["Mail.ReadWrite.Shared"],
190
+ "llmTip": "Create a forward draft in a shared mailbox without sending. The 'user-id' is the shared mailbox email address. Does not send — use send-shared-mailbox-draft to send afterwards. Useful when the user wants to review before the forward goes out. Requires delegated access to the shared mailbox."
191
+ },
192
+ {
193
+ "pathPattern": "/users/{user-id}/messages/{message-id}/send",
194
+ "method": "post",
195
+ "toolName": "send-shared-mailbox-draft",
196
+ "presets": ["work"],
197
+ "workScopes": ["Mail.Send.Shared"],
198
+ "llmTip": "Send an existing draft from a shared mailbox. No request body needed — just call with the message ID and the shared mailbox user-id. Draft must exist in the shared mailbox's Drafts folder. Requires Send As permission on the shared mailbox in Exchange."
199
+ },
168
200
  {
169
201
  "pathPattern": "/users",
170
202
  "method": "get",