@softeria/ms-365-mcp-server 0.136.0 → 0.138.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/.env.example CHANGED
@@ -1,5 +1,10 @@
1
1
  # Microsoft 365 OAuth Configuration
2
2
  # Create an Azure AD app registration and get these values:
3
+ #
4
+ # NOTE: .env is read from the directory the server is started in, which the MCP
5
+ # client picks. Only the four variables below are read from it. Everything else
6
+ # (token cache paths, the auth-cache command, Key Vault, logging, HTTP settings)
7
+ # must be set in your shell or MCP client config.
3
8
 
4
9
  # Your Azure AD App Registration Client ID
5
10
  MS365_MCP_CLIENT_ID=your-azure-ad-app-client-id-here
@@ -38,7 +43,8 @@ MS365_MCP_TENANT_ID=common
38
43
  # When set, secrets are fetched from Azure Key Vault instead of environment variables.
39
44
  # This is useful for production deployments, especially with Azure Container Apps.
40
45
  #
41
- # MS365_MCP_KEYVAULT_URL=https://your-keyvault-name.vault.azure.net
46
+ # Not read from .env - export MS365_MCP_KEYVAULT_URL in the environment instead:
47
+ # export MS365_MCP_KEYVAULT_URL=https://your-keyvault-name.vault.azure.net
42
48
  #
43
49
  # Key Vault secret names (store these in your Key Vault):
44
50
  # - ms365-mcp-client-id (required)
@@ -58,8 +64,10 @@ MS365_MCP_TENANT_ID=common
58
64
  # -------------------------------------------------------------------
59
65
  # Headless local-MSAL deployments can store token-cache and selected-account
60
66
  # metadata with a provider-neutral executable wrapper. The value is a real
61
- # executable path, not a shell command string; put any provider-specific args
62
- # inside the wrapper script.
67
+ # executable path (absolute), not a shell command string; put any
68
+ # provider-specific args inside the wrapper script.
63
69
  #
64
- # MS365_MCP_AUTH_CACHE_COMMAND=/path/to/ms365-auth-cache-store
65
- # MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS=10000
70
+ # This one starts a process, so it is deliberately NOT read from .env. Export it
71
+ # in the environment that launches the server:
72
+ # export MS365_MCP_AUTH_CACHE_COMMAND=/path/to/ms365-auth-cache-store
73
+ # export MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS=10000
package/README.md CHANGED
@@ -440,6 +440,11 @@ registration:
440
440
 
441
441
  With these configured, the server will use your custom Azure app instead of the built-in one.
442
442
 
443
+ > **Note**: `.env` is read from the directory the server is started in, and the MCP client decides what
444
+ > that is. Only `MS365_MCP_CLIENT_ID`, `MS365_MCP_CLIENT_SECRET`, `MS365_MCP_TENANT_ID` and
445
+ > `MS365_MCP_CLOUD_TYPE` are read from it. Every other variable listed above must be set in your shell
446
+ > or MCP client config; anything else found in a `.env` is ignored with a warning on stderr.
447
+
443
448
  #### 3. Bring Your Own Token (BYOT)
444
449
 
445
450
  If you are running ms-365-mcp-server as part of a larger system that manages Microsoft OAuth tokens externally, you can
@@ -625,11 +630,23 @@ Environment variables:
625
630
 
626
631
  ## Token Storage
627
632
 
628
- Authentication tokens are stored using the OS credential store (via keytar) when available. If keytar is not installed or fails (common on headless Linux), the server falls back to file-based storage.
633
+ Authentication tokens are stored in an encrypted file (AES-256-GCM). Only the 32-byte encryption key goes to the OS credential store via keytar.
634
+
635
+ The cache itself is too big for some credential stores to hold - a Windows Credential Manager blob caps out at 2560 bytes and a real token cache is several times that, so on Windows the write could never succeed. A key is 32 bytes regardless of how many accounts are signed in, so this works the same way on every platform.
636
+
637
+ **Default paths** are in the per-user config directory:
638
+
639
+ | Platform | Location |
640
+ | -------- | ------------------------------------------------------------------------- |
641
+ | Windows | `%APPDATA%\ms-365-mcp-server\` |
642
+ | macOS | `~/Library/Application Support/ms-365-mcp-server/` |
643
+ | Linux | `$XDG_CONFIG_HOME/ms-365-mcp-server/` (or `~/.config/ms-365-mcp-server/`) |
629
644
 
630
- **Default fallback paths** are relative to the installed package directory. This means tokens can be lost when the package is reinstalled or updated via npm.
645
+ Earlier versions defaulted to a path inside the installed package, which under `npx` resolves to a content-hashed cache directory that `npm cache clean` or a version bump throws away. A cache still sitting in the package directory is moved to the new location on first run.
631
646
 
632
- To persist tokens across updates, set custom paths outside the package directory:
647
+ That covers global and local installs, and `npx` when the hash has not changed. It cannot reach a cache left behind in a _previous_ `npx` hash directory, so upgrading an `npx` install one last time means signing in again. Adopting a cache from another directory would mean trusting a directory this package cannot prove it wrote, which is not worth one saved sign-in.
648
+
649
+ Override the paths if you need to:
633
650
 
634
651
  ```bash
635
652
  export MS365_MCP_TOKEN_CACHE_PATH="$HOME/.config/ms365-mcp/.token-cache.json"
@@ -638,7 +655,11 @@ export MS365_MCP_SELECTED_ACCOUNT_PATH="$HOME/.config/ms365-mcp/.selected-accoun
638
655
 
639
656
  Parent directories are created automatically. Files are written with `0600` permissions.
640
657
 
641
- > **Security note**: File-based token storage writes sensitive credentials to disk. Ensure the chosen directory has appropriate access controls. The OS credential store (keytar) is preferred when available.
658
+ **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.
659
+
660
+ 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.
661
+
662
+ 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.
642
663
 
643
664
  > **Hosted/sandboxed environments** (e.g. Anthropic Cowork): Set `MS365_MCP_TOKEN_CACHE_PATH` and `MS365_MCP_SELECTED_ACCOUNT_PATH` to a persistent mount so tokens survive between sessions.
644
665
 
@@ -844,8 +844,8 @@ const microsoft_graph_driveItem = z.object({
844
844
  webUrl: z.string().describe(
845
845
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
846
846
  ).nullish(),
847
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
848
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
847
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
848
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
849
849
  audio: microsoft_graph_audio.optional(),
850
850
  bundle: microsoft_graph_bundle.optional(),
851
851
  cTag: z.string().describe(
@@ -2584,8 +2584,8 @@ const microsoft_graph_listItem = z.object({
2584
2584
  webUrl: z.string().describe(
2585
2585
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2586
2586
  ).nullish(),
2587
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2588
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2587
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2588
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2589
2589
  contentType: microsoft_graph_contentTypeInfo.optional(),
2590
2590
  deleted: microsoft_graph_deleted.optional(),
2591
2591
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2593,7 +2593,7 @@ const microsoft_graph_listItem = z.object({
2593
2593
  documentSetVersions: z.array(microsoft_graph_documentSetVersion).describe("Version information for a document set version created by a user.").optional(),
2594
2594
  driveItem: microsoft_graph_driveItem.describe("[Note: Simplified from 45 properties to 25 most common ones]").optional(),
2595
2595
  fields: microsoft_graph_fieldValueSet.optional(),
2596
- permissions: z.array(microsoft_graph_permission).optional(),
2596
+ permissions: z.array(microsoft_graph_permission).describe("The set of permissions for the item. Read-only. Nullable.").optional(),
2597
2597
  versions: z.array(microsoft_graph_listItemVersion).describe("The list of previous versions of the list item.").optional()
2598
2598
  }).passthrough();
2599
2599
  const microsoft_graph_longRunningOperationStatus = z.enum([
@@ -2706,8 +2706,8 @@ const microsoft_graph_list = z.lazy(
2706
2706
  webUrl: z.string().describe(
2707
2707
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2708
2708
  ).nullish(),
2709
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2710
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2709
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2710
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2711
2711
  displayName: z.string().describe("The displayable title of the list.").nullish(),
2712
2712
  list: microsoft_graph_listInfo.optional(),
2713
2713
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2717,7 +2717,7 @@ const microsoft_graph_list = z.lazy(
2717
2717
  drive: microsoft_graph_drive.optional(),
2718
2718
  items: z.array(microsoft_graph_listItem).describe("All items contained in the list.").optional(),
2719
2719
  operations: z.array(microsoft_graph_richLongRunningOperation).describe("The collection of long-running operations on the list.").optional(),
2720
- permissions: z.array(microsoft_graph_permission).optional(),
2720
+ permissions: z.array(microsoft_graph_permission).describe("The set of permissions for the item. Read-only. Nullable.").optional(),
2721
2721
  subscriptions: z.array(microsoft_graph_subscription).describe("The set of subscriptions on the list.").optional()
2722
2722
  }).passthrough()
2723
2723
  );
@@ -2739,8 +2739,8 @@ const microsoft_graph_drive = z.lazy(
2739
2739
  webUrl: z.string().describe(
2740
2740
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2741
2741
  ).nullish(),
2742
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2743
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
2742
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2743
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2744
2744
  driveType: z.string().describe(
2745
2745
  "Describes the type of drive represented by this resource. OneDrive personal drives return personal. OneDrive for Business returns business. SharePoint document libraries return documentLibrary. Read-only."
2746
2746
  ).nullish(),
@@ -3014,7 +3014,7 @@ const microsoft_graph_team = z.lazy(
3014
3014
  ).nullish(),
3015
3015
  allChannels: z.array(microsoft_graph_channel).describe("List of channels either hosted in or shared with the team (incoming channels).").optional(),
3016
3016
  channels: z.array(microsoft_graph_channel).describe("The collection of channels and messages associated with the team.").optional(),
3017
- group: microsoft_graph_group.describe("[Note: Simplified from 80 properties to 25 most common ones]").optional(),
3017
+ group: microsoft_graph_group.describe("[Note: Simplified from 81 properties to 25 most common ones]").optional(),
3018
3018
  incomingChannels: z.array(microsoft_graph_channel).describe("List of channels shared with the team.").optional(),
3019
3019
  installedApps: z.array(microsoft_graph_teamsAppInstallation).describe("The apps installed in this team.").optional(),
3020
3020
  members: z.array(microsoft_graph_conversationMember).describe("Members and owners of the team.").optional(),
@@ -3586,6 +3586,13 @@ const microsoft_graph_chatInfo = z.object({
3586
3586
  const microsoft_graph_chatRestrictions = z.object({
3587
3587
  allowTextOnly: z.boolean().describe("Indicates whether only text is allowed in the meeting chat. Optional.").nullish()
3588
3588
  }).passthrough();
3589
+ const microsoft_graph_cloudVideoInteropInfo = z.object({
3590
+ moreInfoWebUrl: z.string().describe("Provides other video teleconferencing (VTC) dial-in options. Read-only.").nullish(),
3591
+ tenantKey: z.string().describe(
3592
+ "The tenant key that is used to dial into the interactive voice response (IVR) of the partner CVI service."
3593
+ ).nullish(),
3594
+ videoTeleconferenceId: z.string().describe("The video teleconferencing ID. Read-only.").nullish()
3595
+ }).passthrough();
3589
3596
  const microsoft_graph_joinMeetingIdSettings = z.object({
3590
3597
  isPasscodeRequired: z.boolean().describe(
3591
3598
  "Indicates whether a passcode is required to join a meeting when using joinMeetingId. Optional."
@@ -3617,6 +3624,7 @@ const microsoft_graph_onlineMeeting = z.object({
3617
3624
  audioConferencing: microsoft_graph_audioConferencing.optional(),
3618
3625
  chatInfo: microsoft_graph_chatInfo.optional(),
3619
3626
  chatRestrictions: microsoft_graph_chatRestrictions.optional(),
3627
+ cloudVideoInteropInfo: microsoft_graph_cloudVideoInteropInfo.optional(),
3620
3628
  expiryDateTime: z.string().regex(
3621
3629
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
3622
3630
  ).datetime({ offset: true }).describe(
@@ -3625,8 +3633,7 @@ const microsoft_graph_onlineMeeting = z.object({
3625
3633
  isEndToEndEncryptionEnabled: z.boolean().describe("Indicates whether end-to-end encryption (E2EE) is enabled for the online meeting.").nullish(),
3626
3634
  isEntryExitAnnounced: z.boolean().describe("Indicates whether to announce when callers join or leave.").nullish(),
3627
3635
  joinInformation: microsoft_graph_itemBody.optional(),
3628
- joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional(),
3629
- joinWebUrl: z.string().describe("The join URL of the online meeting. Read-only.").nullish()
3636
+ joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional()
3630
3637
  }).passthrough().passthrough();
3631
3638
  const microsoft_graph_onlineMeetingCollectionResponse = z.object({
3632
3639
  "@odata.count": z.number().int().nullable(),
@@ -4549,8 +4556,8 @@ const microsoft_graph_baseItem = z.object({
4549
4556
  webUrl: z.string().describe(
4550
4557
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4551
4558
  ).nullish(),
4552
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
4553
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional()
4559
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4560
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional()
4554
4561
  }).passthrough();
4555
4562
  const microsoft_graph_site = z.object({
4556
4563
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
@@ -4571,8 +4578,8 @@ const microsoft_graph_site = z.object({
4571
4578
  webUrl: z.string().describe(
4572
4579
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4573
4580
  ).nullish(),
4574
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
4575
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
4581
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4582
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4576
4583
  isPersonalSite: z.boolean().describe("Identifies whether the site is personal or not. Read-only.").nullish(),
4577
4584
  root: microsoft_graph_root.optional(),
4578
4585
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -4692,6 +4699,7 @@ const microsoft_graph_virtualEventSession = z.object({
4692
4699
  audioConferencing: microsoft_graph_audioConferencing.optional(),
4693
4700
  chatInfo: microsoft_graph_chatInfo.optional(),
4694
4701
  chatRestrictions: microsoft_graph_chatRestrictions.optional(),
4702
+ cloudVideoInteropInfo: microsoft_graph_cloudVideoInteropInfo.optional(),
4695
4703
  expiryDateTime: z.string().regex(
4696
4704
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
4697
4705
  ).datetime({ offset: true }).describe(
@@ -4700,8 +4708,7 @@ const microsoft_graph_virtualEventSession = z.object({
4700
4708
  isEndToEndEncryptionEnabled: z.boolean().describe("Indicates whether end-to-end encryption (E2EE) is enabled for the online meeting.").nullish(),
4701
4709
  isEntryExitAnnounced: z.boolean().describe("Indicates whether to announce when callers join or leave.").nullish(),
4702
4710
  joinInformation: microsoft_graph_itemBody.optional(),
4703
- joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional(),
4704
- joinWebUrl: z.string().describe("The join URL of the online meeting. Read-only.").nullish()
4711
+ joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional()
4705
4712
  }).passthrough().passthrough();
4706
4713
  const microsoft_graph_meetingAudience = z.enum(["everyone", "organization", "unknownFutureValue"]);
4707
4714
  const microsoft_graph_communicationsUserIdentity = z.object({
@@ -4723,10 +4730,14 @@ const microsoft_graph_virtualEventRegistrationQuestionBase = z.object({
4723
4730
  const microsoft_graph_virtualEventWebinarRegistrationConfiguration = z.object({
4724
4731
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
4725
4732
  capacity: z.number().gte(-2147483648).lte(2147483647).describe("Total capacity of the virtual event.").nullish(),
4733
+ isManualApprovalEnabled: z.boolean().describe(
4734
+ "Indicates whether registrations require organizer approval before a participant is confirmed."
4735
+ ).nullish(),
4736
+ isWaitlistEnabled: z.boolean().describe(
4737
+ "Indicates whether more registrants are automatically placed on a waitlist when capacity is reached."
4738
+ ).nullish(),
4726
4739
  registrationWebUrl: z.string().describe("Registration URL of the virtual event.").nullish(),
4727
- questions: z.array(microsoft_graph_virtualEventRegistrationQuestionBase).describe("Registration questions.").optional(),
4728
- isManualApprovalEnabled: z.boolean().nullish(),
4729
- isWaitlistEnabled: z.boolean().nullish()
4740
+ questions: z.array(microsoft_graph_virtualEventRegistrationQuestionBase).describe("Registration questions.").optional()
4730
4741
  }).passthrough();
4731
4742
  const microsoft_graph_virtualEventRegistrationQuestionAnswer = z.object({
4732
4743
  booleanValue: z.boolean().describe(
@@ -4783,6 +4794,7 @@ const microsoft_graph_virtualEventWebinar = z.object({
4783
4794
  externalEventInformation: z.array(microsoft_graph_virtualEventExternalInformation).describe(
4784
4795
  "The external information of a virtual event. Returned only for event organizers or coorganizers; otherwise, null."
4785
4796
  ).optional(),
4797
+ isRegistrationRequired: z.boolean().describe("Indicates whether attendee registration is enabled for the virtual event.").nullish(),
4786
4798
  settings: microsoft_graph_virtualEventSettings.optional(),
4787
4799
  startDateTime: microsoft_graph_dateTimeTimeZone.optional(),
4788
4800
  status: microsoft_graph_virtualEventStatus.optional(),
@@ -5212,6 +5224,7 @@ const schemas = {
5212
5224
  microsoft_graph_audioConferencing,
5213
5225
  microsoft_graph_chatInfo,
5214
5226
  microsoft_graph_chatRestrictions,
5227
+ microsoft_graph_cloudVideoInteropInfo,
5215
5228
  microsoft_graph_joinMeetingIdSettings,
5216
5229
  microsoft_graph_onlineMeeting,
5217
5230
  microsoft_graph_onlineMeetingCollectionResponse,
@@ -5865,8 +5878,8 @@ const endpoints = makeApi([
5865
5878
  webUrl: z.string().describe(
5866
5879
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5867
5880
  ).nullish(),
5868
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
5869
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
5881
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5882
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5870
5883
  audio: microsoft_graph_audio.optional(),
5871
5884
  bundle: microsoft_graph_bundle.optional(),
5872
5885
  cTag: z.string().describe(
@@ -5980,8 +5993,8 @@ const endpoints = makeApi([
5980
5993
  webUrl: z.string().describe(
5981
5994
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5982
5995
  ).nullish(),
5983
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
5984
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 136 properties to 25 most common ones]").optional(),
5996
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5997
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5985
5998
  audio: microsoft_graph_audio.optional(),
5986
5999
  bundle: microsoft_graph_bundle.optional(),
5987
6000
  cTag: z.string().describe(
@@ -11185,6 +11198,7 @@ resource.`,
11185
11198
  audioConferencing: microsoft_graph_audioConferencing.optional(),
11186
11199
  chatInfo: microsoft_graph_chatInfo.optional(),
11187
11200
  chatRestrictions: microsoft_graph_chatRestrictions.optional(),
11201
+ cloudVideoInteropInfo: microsoft_graph_cloudVideoInteropInfo.optional(),
11188
11202
  expiryDateTime: z.string().regex(
11189
11203
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
11190
11204
  ).datetime({ offset: true }).describe(
@@ -11195,8 +11209,7 @@ resource.`,
11195
11209
  ).nullish(),
11196
11210
  isEntryExitAnnounced: z.boolean().describe("Indicates whether to announce when callers join or leave.").nullish(),
11197
11211
  joinInformation: microsoft_graph_itemBody.optional(),
11198
- joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional(),
11199
- joinWebUrl: z.string().describe("The join URL of the online meeting. Read-only.").nullish()
11212
+ joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional()
11200
11213
  }).passthrough().passthrough()
11201
11214
  }
11202
11215
  ],
@@ -11257,6 +11270,7 @@ resource.`,
11257
11270
  audioConferencing: microsoft_graph_audioConferencing.optional(),
11258
11271
  chatInfo: microsoft_graph_chatInfo.optional(),
11259
11272
  chatRestrictions: microsoft_graph_chatRestrictions.optional(),
11273
+ cloudVideoInteropInfo: microsoft_graph_cloudVideoInteropInfo.optional(),
11260
11274
  expiryDateTime: z.string().regex(
11261
11275
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
11262
11276
  ).datetime({ offset: true }).describe(
@@ -11267,8 +11281,7 @@ resource.`,
11267
11281
  ).nullish(),
11268
11282
  isEntryExitAnnounced: z.boolean().describe("Indicates whether to announce when callers join or leave.").nullish(),
11269
11283
  joinInformation: microsoft_graph_itemBody.optional(),
11270
- joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional(),
11271
- joinWebUrl: z.string().describe("The join URL of the online meeting. Read-only.").nullish()
11284
+ joinMeetingIdSettings: microsoft_graph_joinMeetingIdSettings.optional()
11272
11285
  }).passthrough().passthrough()
11273
11286
  }
11274
11287
  ],
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import "dotenv/config";
2
+ import { loadEnvFile } from "./load-env.js";
3
3
  import { parseArgs } from "./cli.js";
4
4
  import logger from "./logger.js";
5
5
  import AuthManager, { buildAllowedScopeDiagnostics, resolveAuthScopes } from "./auth.js";
@@ -33,6 +33,7 @@ process.on("uncaughtException", (err, origin) => {
33
33
  });
34
34
  async function main() {
35
35
  try {
36
+ loadEnvFile();
36
37
  const args = parseArgs();
37
38
  const includeWorkScopes = args.orgMode || false;
38
39
  if (includeWorkScopes) {
@@ -0,0 +1,89 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ const ALGORITHM = "aes-256-gcm";
3
+ const ALGORITHM_LABEL = "A256GCM";
4
+ const ENVELOPE_VERSION = 2;
5
+ const IV_BYTES = 12;
6
+ const TAG_BYTES = 16;
7
+ const CACHE_KEY_BYTES = 32;
8
+ function generateCacheKey() {
9
+ return randomBytes(CACHE_KEY_BYTES);
10
+ }
11
+ function parseCacheKey(encoded) {
12
+ const key = Buffer.from(encoded.trim(), "base64");
13
+ if (key.length !== CACHE_KEY_BYTES) {
14
+ throw new Error(`Auth cache key must be ${CACHE_KEY_BYTES} bytes, got ${key.length}`);
15
+ }
16
+ return key;
17
+ }
18
+ function encodeCacheKey(key) {
19
+ return key.toString("base64");
20
+ }
21
+ function additionalData(purpose) {
22
+ return Buffer.from(`ms365:v${ENVELOPE_VERSION}:${ALGORITHM_LABEL}:${purpose}`, "utf8");
23
+ }
24
+ function encryptCache(plaintext, key, purpose) {
25
+ const iv = randomBytes(IV_BYTES);
26
+ const cipher = createCipheriv(ALGORITHM, key, iv);
27
+ cipher.setAAD(additionalData(purpose));
28
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
29
+ const envelope = {
30
+ _cacheEnvelope: true,
31
+ v: ENVELOPE_VERSION,
32
+ alg: ALGORITHM_LABEL,
33
+ iv: iv.toString("base64"),
34
+ tag: cipher.getAuthTag().toString("base64"),
35
+ data: ciphertext.toString("base64")
36
+ };
37
+ return JSON.stringify(envelope);
38
+ }
39
+ function isEncryptedCache(raw) {
40
+ return readEnvelope(raw) !== void 0;
41
+ }
42
+ function decryptCache(raw, key, purpose) {
43
+ const envelope = readEnvelope(raw);
44
+ if (!envelope) {
45
+ throw new Error("Auth cache is not an encrypted envelope");
46
+ }
47
+ if (envelope.v !== ENVELOPE_VERSION) {
48
+ throw new Error(`Unsupported auth cache envelope version: ${envelope.v}`);
49
+ }
50
+ if (envelope.alg !== ALGORITHM_LABEL) {
51
+ throw new Error(`Unsupported auth cache algorithm: ${envelope.alg}`);
52
+ }
53
+ if (typeof envelope.iv !== "string" || typeof envelope.tag !== "string" || typeof envelope.data !== "string") {
54
+ throw new Error("Auth cache envelope is missing iv, tag or data");
55
+ }
56
+ const iv = Buffer.from(envelope.iv, "base64");
57
+ const tag = Buffer.from(envelope.tag, "base64");
58
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
59
+ throw new Error("Auth cache envelope has a malformed iv or tag");
60
+ }
61
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
62
+ decipher.setAAD(additionalData(purpose));
63
+ decipher.setAuthTag(tag);
64
+ return Buffer.concat([
65
+ decipher.update(Buffer.from(envelope.data, "base64")),
66
+ decipher.final()
67
+ ]).toString("utf8");
68
+ }
69
+ function readEnvelope(raw) {
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ return void 0;
75
+ }
76
+ if (!parsed || typeof parsed !== "object") return void 0;
77
+ const candidate = parsed;
78
+ if (candidate._cacheEnvelope !== true || typeof candidate.v !== "number") return void 0;
79
+ return candidate;
80
+ }
81
+ export {
82
+ CACHE_KEY_BYTES,
83
+ decryptCache,
84
+ encodeCacheKey,
85
+ encryptCache,
86
+ generateCacheKey,
87
+ isEncryptedCache,
88
+ parseCacheKey
89
+ };
@@ -0,0 +1,20 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ const APP_DIR_NAME = "ms-365-mcp-server";
4
+ function getConfigDir() {
5
+ if (process.platform === "win32") {
6
+ const appData = process.env.APPDATA?.trim();
7
+ return path.join(appData || path.join(os.homedir(), "AppData", "Roaming"), APP_DIR_NAME);
8
+ }
9
+ if (process.platform === "darwin") {
10
+ return path.join(os.homedir(), "Library", "Application Support", APP_DIR_NAME);
11
+ }
12
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
13
+ if (xdg && path.isAbsolute(xdg)) {
14
+ return path.join(xdg, APP_DIR_NAME);
15
+ }
16
+ return path.join(os.homedir(), ".config", APP_DIR_NAME);
17
+ }
18
+ export {
19
+ getConfigDir
20
+ };
@@ -0,0 +1,41 @@
1
+ import { parse } from "dotenv";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const ENV_FILE_ALLOWLIST = [
5
+ "MS365_MCP_CLIENT_ID",
6
+ "MS365_MCP_CLIENT_SECRET",
7
+ "MS365_MCP_TENANT_ID",
8
+ "MS365_MCP_CLOUD_TYPE"
9
+ ];
10
+ function loadEnvFile(options = {}) {
11
+ const file = options.path ?? path.resolve(process.cwd(), ".env");
12
+ let fromFile = {};
13
+ try {
14
+ fromFile = parse(fs.readFileSync(file, "utf8"));
15
+ } catch {
16
+ }
17
+ const applied = [];
18
+ const ignored = [];
19
+ for (const [key, value] of Object.entries(fromFile)) {
20
+ if (!ENV_FILE_ALLOWLIST.includes(key)) {
21
+ ignored.push(key);
22
+ continue;
23
+ }
24
+ if (process.env[key] !== void 0) {
25
+ continue;
26
+ }
27
+ process.env[key] = value;
28
+ applied.push(key);
29
+ }
30
+ const ignoredOwn = ignored.filter((key) => key.startsWith("MS365_MCP_"));
31
+ if (ignoredOwn.length > 0) {
32
+ console.error(
33
+ `[ms365-mcp] Ignored ${ignoredOwn.length} variable(s) from .env: ${[...ignoredOwn].sort().join(", ")}. Only ${ENV_FILE_ALLOWLIST.join(", ")} are read from .env - set anything else in your shell or MCP client config.`
34
+ );
35
+ }
36
+ return { applied, ignored };
37
+ }
38
+ export {
39
+ ENV_FILE_ALLOWLIST,
40
+ loadEnvFile
41
+ };
package/dist/server.js CHANGED
@@ -479,48 +479,16 @@ class MicrosoftGraphServer {
479
479
  allowUnauthenticatedDiscovery: this.options.allowUnauthenticatedDiscovery,
480
480
  publicUrl: publicBase
481
481
  });
482
- app.get(
483
- "/mcp",
484
- mcpAuth,
485
- async (req, res) => {
486
- const handler = async () => {
487
- const server = this.createMcpServer();
488
- const transport = new StreamableHTTPServerTransport({
489
- sessionIdGenerator: void 0
490
- // Stateless mode
491
- });
492
- res.on("close", () => {
493
- transport.close();
494
- server.close();
495
- });
496
- await server.connect(transport);
497
- await transport.handleRequest(req, res, void 0);
498
- };
499
- try {
500
- if (req.microsoftAuth) {
501
- let accessToken = req.microsoftAuth.accessToken;
502
- if (this.oboClient) {
503
- accessToken = await this.oboClient.exchangeToken(accessToken);
504
- }
505
- await requestContext.run({ accessToken }, handler);
506
- } else {
507
- await handler();
508
- }
509
- } catch (error) {
510
- logger.error("Error handling MCP GET request:", error);
511
- if (!res.headersSent) {
512
- res.status(500).json({
513
- jsonrpc: "2.0",
514
- error: {
515
- code: -32603,
516
- message: "Internal server error"
517
- },
518
- id: null
519
- });
520
- }
521
- }
522
- }
523
- );
482
+ app.get("/mcp", (req, res) => {
483
+ res.status(405).json({
484
+ jsonrpc: "2.0",
485
+ error: {
486
+ code: -32e3,
487
+ message: "Method not allowed."
488
+ },
489
+ id: null
490
+ });
491
+ });
524
492
  app.post(
525
493
  "/mcp",
526
494
  mcpAuth,
@@ -528,8 +496,10 @@ class MicrosoftGraphServer {
528
496
  const handler = async () => {
529
497
  const server = this.createMcpServer();
530
498
  const transport = new StreamableHTTPServerTransport({
531
- sessionIdGenerator: void 0
499
+ sessionIdGenerator: void 0,
532
500
  // Stateless mode
501
+ enableJsonResponse: true
502
+ // Reply to POSTs with plain JSON, not one-shot SSE
533
503
  });
534
504
  res.on("close", () => {
535
505
  transport.close();
@@ -1,7 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import fs, { existsSync, readFileSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
6
+ import {
7
+ decryptCache,
8
+ encodeCacheKey,
9
+ encryptCache,
10
+ generateCacheKey,
11
+ isEncryptedCache,
12
+ parseCacheKey
13
+ } from "./lib/cache-encryption.js";
14
+ import { getConfigDir } from "./lib/config-paths.js";
5
15
  import logger from "./logger.js";
6
16
  const SERVICE_NAME = "ms-365-mcp-server";
7
17
  const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
@@ -11,11 +21,12 @@ const AUTH_CACHE_COMMAND_TIMEOUT_ENV = "MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS"
11
21
  const DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS = 1e4;
12
22
  const STDERR_LIMIT = 2048;
13
23
  const COMMAND_KILL_GRACE_MS = 1e3;
24
+ const CACHE_KEY_ACCOUNT = "cache-key";
25
+ const TOKEN_CACHE_FILE = ".token-cache.json";
26
+ const SELECTED_ACCOUNT_FILE = ".selected-account.json";
27
+ const CACHE_KEY_FILE = ".cache-key";
14
28
  const __filename = fileURLToPath(import.meta.url);
15
- const __dirname = path.dirname(__filename);
16
- const FALLBACK_DIR = __dirname;
17
- const DEFAULT_TOKEN_CACHE_PATH = path.join(FALLBACK_DIR, "..", ".token-cache.json");
18
- const DEFAULT_SELECTED_ACCOUNT_PATH = path.join(FALLBACK_DIR, "..", ".selected-account.json");
29
+ const LEGACY_DIR = path.join(path.dirname(__filename), "..");
19
30
  let keytar = null;
20
31
  async function getKeytar() {
21
32
  if (keytar === void 0) {
@@ -64,11 +75,68 @@ function pickNewestRaw(keytarRaw, fileRaw) {
64
75
  }
65
76
  function getTokenCachePath() {
66
77
  const envPath = process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim();
67
- return envPath || DEFAULT_TOKEN_CACHE_PATH;
78
+ return envPath || path.join(getConfigDir(), TOKEN_CACHE_FILE);
68
79
  }
69
80
  function getSelectedAccountPath() {
70
81
  const envPath = process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim();
71
- return envPath || DEFAULT_SELECTED_ACCOUNT_PATH;
82
+ return envPath || path.join(getConfigDir(), SELECTED_ACCOUNT_FILE);
83
+ }
84
+ function getCacheKeyPath() {
85
+ return path.join(path.dirname(getTokenCachePath()), CACHE_KEY_FILE);
86
+ }
87
+ let legacyPathsMigrated = false;
88
+ function migrateLegacyPaths() {
89
+ if (legacyPathsMigrated) return;
90
+ legacyPathsMigrated = true;
91
+ migrateLegacyPathsFrom(LEGACY_DIR);
92
+ }
93
+ function migrateLegacyPathsFrom(legacyDir) {
94
+ const moves = [
95
+ [TOKEN_CACHE_FILE, getTokenCachePath(), process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim()],
96
+ [
97
+ SELECTED_ACCOUNT_FILE,
98
+ getSelectedAccountPath(),
99
+ process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim()
100
+ ]
101
+ ];
102
+ for (const [fileName, target, envOverride] of moves) {
103
+ if (envOverride || existsSync(target)) continue;
104
+ const legacyPath = path.join(legacyDir, fileName);
105
+ if (!existsSync(legacyPath) || existsSync(`${legacyPath}.migrated`)) continue;
106
+ let moved = false;
107
+ try {
108
+ ensureParentDir(target);
109
+ fs.renameSync(legacyPath, target);
110
+ moved = true;
111
+ } catch {
112
+ try {
113
+ ensureParentDir(target);
114
+ fs.copyFileSync(legacyPath, target);
115
+ moved = true;
116
+ try {
117
+ fs.unlinkSync(legacyPath);
118
+ } catch {
119
+ logger.warn(`Copied auth cache to ${target} but could not remove ${legacyPath}`);
120
+ try {
121
+ fs.writeFileSync(`${legacyPath}.migrated`, "", { mode: 384 });
122
+ } catch {
123
+ }
124
+ }
125
+ } catch (copyError) {
126
+ logger.warn(
127
+ `Could not migrate auth cache ${fileName} to ${target}: ${copyError.message}`
128
+ );
129
+ }
130
+ }
131
+ if (!moved) continue;
132
+ if (tightenPermissions(target)) {
133
+ logger.info(`Moved auth cache ${fileName} out of the package directory to ${target}`);
134
+ } else {
135
+ logger.error(
136
+ `Moved auth cache ${fileName} to ${target}, but could not make it owner-only. It holds credentials in plaintext until the next save re-encrypts it - restrict it by hand, or delete it and sign in again.`
137
+ );
138
+ }
139
+ }
72
140
  }
73
141
  function storageAccountForKey(key) {
74
142
  assertValidKey(key);
@@ -83,56 +151,268 @@ function assertValidKey(key) {
83
151
  throw new Error(`Unknown auth cache storage key: ${String(key)}`);
84
152
  }
85
153
  }
154
+ function tightenPermissions(filePath) {
155
+ try {
156
+ fs.chmodSync(filePath, 384);
157
+ return true;
158
+ } catch (error) {
159
+ logger.warn(`Could not restrict permissions on ${filePath}: ${error.message}`);
160
+ return process.platform === "win32";
161
+ }
162
+ }
86
163
  function ensureParentDir(filePath) {
87
164
  const dir = path.dirname(filePath);
88
165
  fs.mkdirSync(dir, { recursive: true, mode: 448 });
89
166
  }
90
167
  function writeFileAtomically(filePath, value) {
91
168
  ensureParentDir(filePath);
92
- const tempPath = path.join(
93
- path.dirname(filePath),
94
- `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`
95
- );
169
+ const tempPath = tempPathFor(filePath);
96
170
  fs.writeFileSync(tempPath, value, { mode: 384 });
97
171
  fs.renameSync(tempPath, filePath);
98
172
  }
173
+ let keyStatePromise;
174
+ let encryptionKeyPromise;
175
+ const legacyKeytarEntries = /* @__PURE__ */ new Set();
176
+ const warnedUndecryptable = /* @__PURE__ */ new Set();
177
+ function resetCacheKeyForTests() {
178
+ keyStatePromise = void 0;
179
+ encryptionKeyPromise = void 0;
180
+ legacyKeytarEntries.clear();
181
+ warnedUndecryptable.clear();
182
+ legacyPathsMigrated = false;
183
+ }
184
+ async function clearLegacyKeytarEntry(key) {
185
+ if (!legacyKeytarEntries.delete(key)) return;
186
+ try {
187
+ const kt = await getKeytar();
188
+ if (kt) {
189
+ await kt.deletePassword(SERVICE_NAME, storageAccountForKey(key));
190
+ logger.info(`Removed the pre-encryption keychain entry for ${key}`);
191
+ }
192
+ } catch (error) {
193
+ logger.warn(
194
+ `Could not remove the legacy keychain entry for ${key}: ${error.message}`
195
+ );
196
+ }
197
+ }
198
+ function loadKeyState() {
199
+ if (!keyStatePromise) keyStatePromise = readCacheKeys();
200
+ return keyStatePromise;
201
+ }
202
+ async function readCacheKeys() {
203
+ const keys = [];
204
+ let canUseKeychain = false;
205
+ const kt = await getKeytar();
206
+ if (kt) {
207
+ let stored = null;
208
+ try {
209
+ stored = await kt.getPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT);
210
+ canUseKeychain = true;
211
+ } catch (error) {
212
+ logger.warn(`Keychain access failed for the auth cache key: ${error.message}`);
213
+ }
214
+ if (stored) {
215
+ try {
216
+ keys.push(parseCacheKey(stored));
217
+ } catch (error) {
218
+ logger.warn(
219
+ `Ignoring unusable auth cache key in the keychain: ${error.message}`
220
+ );
221
+ }
222
+ }
223
+ }
224
+ let fileKeyIndex;
225
+ const keyPath = getCacheKeyPath();
226
+ if (existsSync(keyPath)) {
227
+ try {
228
+ keys.push(parseCacheKey(readFileSync(keyPath, "utf8")));
229
+ fileKeyIndex = keys.length - 1;
230
+ } catch (error) {
231
+ logger.warn(`Ignoring unusable auth cache key file: ${error.message}`);
232
+ }
233
+ }
234
+ return { keys, fileKeyIndex, canUseKeychain };
235
+ }
236
+ function getEncryptionKey() {
237
+ if (!encryptionKeyPromise) {
238
+ encryptionKeyPromise = resolveEncryptionKey().catch((error) => {
239
+ encryptionKeyPromise = void 0;
240
+ keyStatePromise = void 0;
241
+ throw error;
242
+ });
243
+ }
244
+ return encryptionKeyPromise;
245
+ }
246
+ async function resolveEncryptionKey() {
247
+ const state = await loadKeyState();
248
+ const preferred = state.keys[state.fileKeyIndex ?? 0];
249
+ if (preferred) return preferred;
250
+ const minted = await persistCacheKey(generateCacheKey(), state.canUseKeychain);
251
+ state.keys.unshift(minted);
252
+ if (state.fileKeyIndex !== void 0) state.fileKeyIndex += 1;
253
+ return minted;
254
+ }
255
+ async function persistCacheKey(key, canUseKeychain) {
256
+ const encoded = encodeCacheKey(key);
257
+ if (canUseKeychain) {
258
+ const kt = await getKeytar();
259
+ if (kt) {
260
+ try {
261
+ await kt.setPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT, encoded);
262
+ const winner = await kt.getPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT);
263
+ if (winner && winner !== encoded) {
264
+ logger.info("Another process stored an auth cache key first, adopting it");
265
+ return parseCacheKey(winner);
266
+ }
267
+ logger.info("Stored a new auth cache key in the system keychain");
268
+ return key;
269
+ } catch (error) {
270
+ logger.warn(`Keychain save failed for the auth cache key: ${error.message}`);
271
+ }
272
+ }
273
+ }
274
+ const keyPath = getCacheKeyPath();
275
+ if (createFileExclusively(keyPath, encoded)) {
276
+ logger.info(
277
+ `No usable system keychain; auth cache key written to ${keyPath}. Encryption guards against casual disclosure only when the key sits beside the cache.`
278
+ );
279
+ return key;
280
+ }
281
+ try {
282
+ const existing = parseCacheKey(readFileSync(keyPath, "utf8"));
283
+ logger.info("Another process created the auth cache key file first, adopting it");
284
+ return existing;
285
+ } catch (parseError) {
286
+ logger.warn(
287
+ `Replacing an unusable auth cache key file at ${keyPath}: ${parseError.message}`
288
+ );
289
+ try {
290
+ fs.unlinkSync(keyPath);
291
+ } catch {
292
+ }
293
+ if (createFileExclusively(keyPath, encoded)) return key;
294
+ return parseCacheKey(readFileSync(keyPath, "utf8"));
295
+ }
296
+ }
297
+ function createFileExclusively(filePath, contents) {
298
+ ensureParentDir(filePath);
299
+ const tempPath = tempPathFor(filePath);
300
+ try {
301
+ fs.writeFileSync(tempPath, contents, { mode: 384 });
302
+ fs.linkSync(tempPath, filePath);
303
+ return true;
304
+ } catch (error) {
305
+ if (error.code === "EEXIST") return false;
306
+ try {
307
+ fs.writeFileSync(filePath, contents, { mode: 384, flag: "wx" });
308
+ return fs.readFileSync(filePath, "utf8") === contents;
309
+ } catch (fallbackError) {
310
+ if (fallbackError.code === "EEXIST") return false;
311
+ throw fallbackError;
312
+ }
313
+ } finally {
314
+ try {
315
+ fs.unlinkSync(tempPath);
316
+ } catch {
317
+ }
318
+ }
319
+ }
320
+ function tempPathFor(filePath) {
321
+ return path.join(
322
+ path.dirname(filePath),
323
+ `.${path.basename(filePath)}.${randomBytes(8).toString("hex")}.tmp`
324
+ );
325
+ }
326
+ async function readCacheFile(key, cachePath) {
327
+ let onDisk;
328
+ try {
329
+ onDisk = readFileSync(cachePath, "utf8");
330
+ } catch (error) {
331
+ if (error.code === "ENOENT") return { status: "absent" };
332
+ return { status: "unreadable", reason: error.message };
333
+ }
334
+ if (onDisk === "") return { status: "absent" };
335
+ if (isEncryptedCache(onDisk)) {
336
+ const { keys } = await loadKeyState();
337
+ const decrypted = decryptWithAnyKey(onDisk, keys, key);
338
+ if (decrypted !== void 0) return { status: "decrypted", raw: decrypted };
339
+ keyStatePromise = void 0;
340
+ encryptionKeyPromise = void 0;
341
+ return { status: "unreadable", reason: "no known key opens it" };
342
+ }
343
+ if (isPlainCacheJson(onDisk)) return { status: "plaintext", raw: onDisk };
344
+ return { status: "unreadable", reason: "not a recognisable auth cache" };
345
+ }
346
+ function isPlainCacheJson(raw) {
347
+ try {
348
+ const parsed = JSON.parse(raw);
349
+ return parsed !== null && typeof parsed === "object";
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+ function decryptWithAnyKey(raw, keys, purpose) {
355
+ for (const key of keys) {
356
+ try {
357
+ return decryptCache(raw, key, purpose);
358
+ } catch {
359
+ }
360
+ }
361
+ return void 0;
362
+ }
363
+ async function assertOverwritable(key, cachePath) {
364
+ const state = await readCacheFile(key, cachePath);
365
+ if (state.status !== "unreadable") {
366
+ warnedUndecryptable.delete(key);
367
+ return;
368
+ }
369
+ if (!warnedUndecryptable.has(key)) {
370
+ warnedUndecryptable.add(key);
371
+ logger.warn(
372
+ `Not persisting ${key}: ${cachePath} cannot be read back (${state.reason}), and overwriting it would discard whatever it holds. A locked keychain usually reads fine on the next start. If it never does, delete ${cachePath} to start over.`
373
+ );
374
+ }
375
+ throw new Error(`Refusing to overwrite the unreadable auth cache at ${cachePath}`);
376
+ }
99
377
  class DefaultTokenCacheStorage {
100
378
  constructor() {
101
- this.description = "default (keytar+file)";
379
+ this.description = "default (encrypted file)";
102
380
  this.failClosed = false;
103
381
  }
104
382
  async load(key) {
105
383
  assertValidKey(key);
384
+ migrateLegacyPaths();
106
385
  let keytarRaw;
107
386
  try {
108
387
  const kt = await getKeytar();
109
388
  if (kt) {
110
389
  keytarRaw = await kt.getPassword(SERVICE_NAME, storageAccountForKey(key)) ?? void 0;
390
+ if (keytarRaw) legacyKeytarEntries.add(key);
111
391
  }
112
392
  } catch (error) {
113
393
  logger.warn(`Keychain access failed for ${key}: ${error.message}`);
114
394
  }
115
- let fileRaw;
116
395
  const cachePath = filePathForKey(key);
117
- if (existsSync(cachePath)) {
118
- fileRaw = readFileSync(cachePath, "utf8");
396
+ const state = await readCacheFile(key, cachePath);
397
+ let fileRaw;
398
+ if (state.status === "decrypted" || state.status === "plaintext") {
399
+ fileRaw = state.raw;
400
+ } else if (state.status === "unreadable") {
401
+ logger.warn(
402
+ `Could not read ${key} back (${state.reason}); leaving it in place and re-authenticating`
403
+ );
119
404
  }
120
405
  return pickNewestRaw(keytarRaw, fileRaw);
121
406
  }
122
407
  async save(key, value) {
123
408
  assertValidKey(key);
124
- try {
125
- const kt = await getKeytar();
126
- if (kt) {
127
- await kt.setPassword(SERVICE_NAME, storageAccountForKey(key), value);
128
- return;
129
- }
130
- } catch (error) {
131
- logger.warn(
132
- `Keychain save failed for ${key}, falling back to file storage: ${error.message}`
133
- );
134
- }
135
- writeFileAtomically(filePathForKey(key), value);
409
+ migrateLegacyPaths();
410
+ const cachePath = filePathForKey(key);
411
+ await assertOverwritable(key, cachePath);
412
+ const encryptionKey = await getEncryptionKey();
413
+ await assertOverwritable(key, cachePath);
414
+ writeFileAtomically(cachePath, encryptCache(value, encryptionKey, key));
415
+ await clearLegacyKeytarEntry(key);
136
416
  }
137
417
  async delete(key) {
138
418
  assertValidKey(key);
@@ -289,6 +569,9 @@ function parseTimeoutMs(value) {
289
569
  return parsed;
290
570
  }
291
571
  async function assertCommandUsable(commandPath) {
572
+ if (!path.isAbsolute(commandPath)) {
573
+ throw new Error(`${AUTH_CACHE_COMMAND_ENV} must be an absolute path.`);
574
+ }
292
575
  let stats;
293
576
  try {
294
577
  stats = await fs.promises.stat(commandPath);
@@ -328,9 +611,12 @@ export {
328
611
  CommandTokenCacheStorage,
329
612
  DefaultTokenCacheStorage,
330
613
  createTokenCacheStorage,
614
+ getCacheKeyPath,
331
615
  getSelectedAccountPath,
332
616
  getTokenCachePath,
617
+ migrateLegacyPathsFrom,
333
618
  pickNewest,
619
+ resetCacheKeyForTests,
334
620
  unwrapCache,
335
621
  wrapCache
336
622
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.136.0",
3
+ "version": "0.138.0",
4
4
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",