@softeria/ms-365-mcp-server 0.109.0 → 0.110.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
@@ -467,6 +467,32 @@ npx @softeria/ms-365-mcp-server --list-accounts
467
467
  - **100% backward compatible**: existing single-account setups work unchanged.
468
468
  - The `account` parameter accepts email address (e.g. `user@outlook.com`) or MSAL `homeAccountId`.
469
469
 
470
+ ### Strict Account Pinning
471
+
472
+ Headless stdio deployments can pin the local MSAL cache to one expected Microsoft account:
473
+
474
+ ```bash
475
+ # Username matching is case-insensitive
476
+ MS365_MCP_EXPECTED_USERNAME=work@company.com npx @softeria/ms-365-mcp-server --login
477
+
478
+ # Or pin the exact MSAL homeAccountId shown by --list-accounts
479
+ npx @softeria/ms-365-mcp-server --expected-home-account-id <homeAccountId> --login
480
+ ```
481
+
482
+ Use `--list-accounts` to discover `homeAccountId` values. The MCP `list-accounts` tool intentionally hides account IDs, so use the CLI for exact ID pinning.
483
+
484
+ Pinning is opt-in and local-MSAL only:
485
+
486
+ - CLI values (`--expected-username`, `--expected-home-account-id`) take precedence over `MS365_MCP_EXPECTED_USERNAME` and `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`.
487
+ - Supplying an empty pin value fails at startup instead of being ignored.
488
+ - Username pins are compared case-insensitively; `homeAccountId` pins are exact.
489
+ - If both pins are set, they must resolve to the same cached account.
490
+ - Local stdio startup fails fast when the expected account is not in the token cache. Bootstrap by setting the pin, running `--login`, then starting the headless server.
491
+ - Device-code and browser logins reject a missing or mismatched account before persisting the selected account or token cache.
492
+ - Pinning collapses the effective MCP mode to single-account: the server does not advertise an `account` parameter and MCP instructions do not suggest account switching.
493
+ - `--http`, `--obo`, and `MS365_MCP_OAUTH_TOKEN` use request-provided tokens for Graph calls, so account pins are warning-only in those modes. If HTTP auth tools are enabled, the pin still applies to those local MSAL helper flows.
494
+ - `--logout` clears all cached accounts, including the pinned account. For surgical cleanup, prefer `--remove-account <id>`.
495
+
470
496
  > **For MCP multiplexers (Legate, Governor):** Multi-account mode replaces the N-process pattern. Instead of spawning one server per account, a single instance handles all accounts via the `account` parameter, reducing tool duplication from N×110 to 110.
471
497
 
472
498
  ## Tool Presets
@@ -504,6 +530,8 @@ The following options can be used when running ms-365-mcp-server directly from t
504
530
  --force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
505
531
  --cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
506
532
  --allowed-scopes <scopes> Limit exposed tools to Graph scopes covered by this allowlist
533
+ --expected-username <username> Require local MSAL auth to use this account username
534
+ --expected-home-account-id <id> Require local MSAL auth to use this exact homeAccountId
507
535
  ```
508
536
 
509
537
  ### Server Options
@@ -543,6 +571,8 @@ Environment variables:
543
571
  - `MS365_MCP_KEYVAULT_URL`: Azure Key Vault URL for secrets management (see Azure Key Vault section)
544
572
  - `MS365_MCP_TOKEN_CACHE_PATH`: Custom file path for MSAL token cache (see Token Storage below)
545
573
  - `MS365_MCP_SELECTED_ACCOUNT_PATH`: Custom file path for selected account metadata (see Token Storage below)
574
+ - `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
575
+ - `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
546
576
 
547
577
  ## Token Storage
548
578
 
@@ -89,7 +89,15 @@ function registerAuthTools(server, authManager) {
89
89
  }
90
90
  });
91
91
  server.tool("verify-login", "Check current Microsoft authentication status", {}, async () => {
92
- const testResult = await authManager.testLogin();
92
+ let testResult;
93
+ try {
94
+ testResult = await authManager.testLogin();
95
+ } catch (error) {
96
+ testResult = {
97
+ success: false,
98
+ message: `Login failed: ${error.message}`
99
+ };
100
+ }
93
101
  return {
94
102
  content: [
95
103
  {
@@ -112,6 +120,7 @@ function registerAuthTools(server, authManager) {
112
120
  try {
113
121
  const accounts = await authManager.listAccounts();
114
122
  const selectedAccountId = authManager.getSelectedAccountId();
123
+ const pinnedMode = authManager.hasExpectedAccount();
115
124
  const result = accounts.map((account) => ({
116
125
  email: account.username || "unknown",
117
126
  name: account.name,
@@ -124,7 +133,7 @@ function registerAuthTools(server, authManager) {
124
133
  text: JSON.stringify({
125
134
  accounts: result,
126
135
  count: result.length,
127
- tip: "Pass the 'email' value as the 'account' parameter in any tool call to target a specific account."
136
+ tip: pinnedMode ? "Expected account pinning is configured; account parameters are disabled." : "Pass the 'email' value as the 'account' parameter in any tool call to target a specific account."
128
137
  })
129
138
  }
130
139
  ]
package/dist/auth.js CHANGED
@@ -288,7 +288,7 @@ function buildScopeDiagnostics(toolScopes, allowedScopesInput) {
288
288
  };
289
289
  }
290
290
  class AuthManager {
291
- constructor(config, scopes = []) {
291
+ constructor(config, scopes = [], expectedAccount) {
292
292
  logger.info(`And scopes are ${scopes.join(", ")}`, scopes);
293
293
  this.config = config;
294
294
  this.scopes = scopes;
@@ -297,6 +297,10 @@ class AuthManager {
297
297
  this.tokenExpiry = null;
298
298
  this.selectedAccountId = null;
299
299
  this.useInteractiveAuth = false;
300
+ this.expectedUsername = this.normalizeExpectedUsername(expectedAccount?.expectedUsername);
301
+ this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
302
+ expectedAccount?.expectedHomeAccountId
303
+ );
300
304
  const oauthTokenFromEnv = process.env.MS365_MCP_OAUTH_TOKEN;
301
305
  this.oauthToken = oauthTokenFromEnv ?? null;
302
306
  this.isOAuthMode = oauthTokenFromEnv != null;
@@ -305,10 +309,10 @@ class AuthManager {
305
309
  * Creates an AuthManager instance with secrets loaded from the configured provider.
306
310
  * Uses Key Vault if MS365_MCP_KEYVAULT_URL is set, otherwise environment variables.
307
311
  */
308
- static async create(scopes = []) {
312
+ static async create(scopes = [], expectedAccount) {
309
313
  const secrets = await getSecrets();
310
314
  const config = createMsalConfig(secrets);
311
- return new AuthManager(config, scopes);
315
+ return new AuthManager(config, scopes, expectedAccount);
312
316
  }
313
317
  async loadTokenCache() {
314
318
  try {
@@ -411,6 +415,118 @@ class AuthManager {
411
415
  logger.error(`Error saving selected account: ${error.message}`);
412
416
  }
413
417
  }
418
+ normalizeExpectedUsername(value) {
419
+ if (value === void 0) {
420
+ return null;
421
+ }
422
+ const trimmed = value.trim();
423
+ if (trimmed === "") {
424
+ throw new Error("Expected Microsoft account username was provided but is empty.");
425
+ }
426
+ return trimmed.toLowerCase();
427
+ }
428
+ normalizeExpectedHomeAccountId(value) {
429
+ if (value === void 0) {
430
+ return null;
431
+ }
432
+ const trimmed = value.trim();
433
+ if (trimmed === "") {
434
+ throw new Error("Expected Microsoft account homeAccountId was provided but is empty.");
435
+ }
436
+ return trimmed;
437
+ }
438
+ hasExpectedAccount() {
439
+ return this.expectedUsername !== null || this.expectedHomeAccountId !== null;
440
+ }
441
+ expectedAccountLabel() {
442
+ const parts = [];
443
+ if (this.expectedUsername) {
444
+ parts.push(`username ${this.expectedUsername}`);
445
+ }
446
+ if (this.expectedHomeAccountId) {
447
+ parts.push(`homeAccountId ${this.expectedHomeAccountId}`);
448
+ }
449
+ return parts.join(" and ");
450
+ }
451
+ describeAccount(account) {
452
+ return account?.username || account?.name || "unknown";
453
+ }
454
+ describeCachedAccounts(accounts) {
455
+ if (accounts.length === 0) {
456
+ return "none";
457
+ }
458
+ return accounts.map((account) => this.describeAccount(account)).join(", ");
459
+ }
460
+ accountMatchesExpected(account) {
461
+ if (!this.hasExpectedAccount() || !account) {
462
+ return !this.hasExpectedAccount();
463
+ }
464
+ if (this.expectedUsername && account.username?.toLowerCase() !== this.expectedUsername) {
465
+ return false;
466
+ }
467
+ if (this.expectedHomeAccountId && account.homeAccountId !== this.expectedHomeAccountId) {
468
+ return false;
469
+ }
470
+ return true;
471
+ }
472
+ buildExpectedAccountMissingError(accounts) {
473
+ return new Error(
474
+ `Expected Microsoft account '${this.expectedAccountLabel()}' not found in token cache. Cached accounts: ${this.describeCachedAccounts(accounts)}. Run --login after configuring the expected account, or use --select-account to recover.`
475
+ );
476
+ }
477
+ resolveExpectedAccountFromAccounts(accounts) {
478
+ if (!this.hasExpectedAccount()) {
479
+ throw new Error("No expected Microsoft account is configured.");
480
+ }
481
+ const usernameMatch = this.expectedUsername ? accounts.find((account) => account.username?.toLowerCase() === this.expectedUsername) : void 0;
482
+ const homeAccountIdMatch = this.expectedHomeAccountId ? accounts.find((account) => account.homeAccountId === this.expectedHomeAccountId) : void 0;
483
+ if (this.expectedUsername && this.expectedHomeAccountId) {
484
+ if (!usernameMatch || !homeAccountIdMatch) {
485
+ throw this.buildExpectedAccountMissingError(accounts);
486
+ }
487
+ if (usernameMatch.homeAccountId !== homeAccountIdMatch.homeAccountId) {
488
+ throw new Error(
489
+ `Expected Microsoft account pins conflict: username ${this.expectedUsername} matched ${this.describeAccount(usernameMatch)}, but homeAccountId ${this.expectedHomeAccountId} matched ${this.describeAccount(homeAccountIdMatch)}.`
490
+ );
491
+ }
492
+ return usernameMatch;
493
+ }
494
+ const expectedAccount = usernameMatch ?? homeAccountIdMatch;
495
+ if (!expectedAccount) {
496
+ throw this.buildExpectedAccountMissingError(accounts);
497
+ }
498
+ return expectedAccount;
499
+ }
500
+ async assertExpectedAccountAvailable() {
501
+ if (!this.hasExpectedAccount()) {
502
+ return;
503
+ }
504
+ const accounts = await this.msalApp.getTokenCache().getAllAccounts();
505
+ this.resolveExpectedAccountFromAccounts(accounts);
506
+ }
507
+ async rejectUnexpectedLoginAccount(account) {
508
+ if (!this.hasExpectedAccount()) {
509
+ return;
510
+ }
511
+ if (this.accountMatchesExpected(account)) {
512
+ return;
513
+ }
514
+ this.accessToken = null;
515
+ this.tokenExpiry = null;
516
+ if (account) {
517
+ try {
518
+ await this.msalApp.getTokenCache().removeAccount(account);
519
+ } catch (error) {
520
+ logger.warn(`Failed to remove unexpected account from cache: ${error.message}`);
521
+ }
522
+ throw new Error(
523
+ `Authenticated Microsoft account '${this.describeAccount(account)}' does not match expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
524
+ );
525
+ }
526
+ throw new Error(
527
+ `Microsoft login did not return an account. Expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
528
+ );
529
+ }
414
530
  async setOAuthToken(token) {
415
531
  this.oauthToken = token;
416
532
  this.isOAuthMode = true;
@@ -443,6 +559,9 @@ class AuthManager {
443
559
  }
444
560
  async getCurrentAccount() {
445
561
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
562
+ if (this.hasExpectedAccount()) {
563
+ return this.resolveExpectedAccountFromAccounts(accounts);
564
+ }
446
565
  if (accounts.length === 0) {
447
566
  return null;
448
567
  }
@@ -480,6 +599,7 @@ class AuthManager {
480
599
  logger.info("Device code login successful");
481
600
  this.accessToken = response?.accessToken || null;
482
601
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
602
+ await this.rejectUnexpectedLoginAccount(response?.account);
483
603
  if (!this.selectedAccountId && response?.account) {
484
604
  this.selectedAccountId = response.account.homeAccountId;
485
605
  await this.saveSelectedAccount();
@@ -521,6 +641,7 @@ class AuthManager {
521
641
  logger.info("Interactive browser login successful");
522
642
  this.accessToken = response?.accessToken || null;
523
643
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
644
+ await this.rejectUnexpectedLoginAccount(response?.account);
524
645
  if (!this.selectedAccountId && response?.account) {
525
646
  this.selectedAccountId = response.account.homeAccountId;
526
647
  await this.saveSelectedAccount();
@@ -625,6 +746,11 @@ class AuthManager {
625
746
  }
626
747
  async selectAccount(identifier) {
627
748
  const account = await this.resolveAccount(identifier);
749
+ if (this.hasExpectedAccount() && !this.accountMatchesExpected(account)) {
750
+ throw new Error(
751
+ `Account '${identifier}' does not match expected Microsoft account '${this.expectedAccountLabel()}'.`
752
+ );
753
+ }
628
754
  this.selectedAccountId = account.homeAccountId;
629
755
  await this.saveSelectedAccount();
630
756
  this.accessToken = null;
@@ -686,6 +812,9 @@ class AuthManager {
686
812
  * Used to decide whether to inject the `account` parameter into tool schemas.
687
813
  */
688
814
  async isMultiAccount() {
815
+ if (this.hasExpectedAccount()) {
816
+ return false;
817
+ }
689
818
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
690
819
  return accounts.length > 1;
691
820
  }
@@ -706,7 +835,18 @@ class AuthManager {
706
835
  return this.oauthToken;
707
836
  }
708
837
  let targetAccount = null;
709
- if (identifier) {
838
+ if (this.hasExpectedAccount()) {
839
+ const accounts = await this.msalApp.getTokenCache().getAllAccounts();
840
+ targetAccount = this.resolveExpectedAccountFromAccounts(accounts);
841
+ if (identifier) {
842
+ const requestedAccount = await this.resolveAccount(identifier);
843
+ if (requestedAccount.homeAccountId !== targetAccount.homeAccountId) {
844
+ throw new Error(
845
+ `Account '${identifier}' does not match expected Microsoft account '${this.expectedAccountLabel()}'.`
846
+ );
847
+ }
848
+ }
849
+ } else if (identifier) {
710
850
  targetAccount = await this.resolveAccount(identifier);
711
851
  } else {
712
852
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
package/dist/cli.js CHANGED
@@ -8,7 +8,13 @@ const packageJsonPath = path.join(__dirname, "..", "package.json");
8
8
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
9
9
  const version = packageJson.version;
10
10
  const program = new Command();
11
- program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").version(version).option("-v", "Enable verbose logging").option("--login", "Login to Microsoft account").option("--logout", "Log out and clear saved credentials").option("--verify-login", "Verify login without starting the server").option("--list-accounts", "List all cached accounts").option("--select-account <accountId>", "Select a specific account by ID").option("--remove-account <accountId>", "Remove a specific account by ID").option("--read-only", "Start server in read-only mode, disabling write operations").option(
11
+ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").version(version).option("-v", "Enable verbose logging").option("--login", "Login to Microsoft account").option("--logout", "Log out and clear saved credentials").option("--verify-login", "Verify login without starting the server").option("--list-accounts", "List all cached accounts").option("--select-account <accountId>", "Select a specific account by ID").option("--remove-account <accountId>", "Remove a specific account by ID").option(
12
+ "--expected-username <username>",
13
+ "Require local MSAL authentication to use this Microsoft account username"
14
+ ).option(
15
+ "--expected-home-account-id <id>",
16
+ "Require local MSAL authentication to use this exact MSAL homeAccountId"
17
+ ).option("--read-only", "Start server in read-only mode, disabling write operations").option(
12
18
  "--http [address]",
13
19
  'Use Streamable HTTP transport instead of stdio. Format: [host:]port (e.g., "localhost:3000", ":3000", "3000"). Default: all interfaces on port 3000'
14
20
  ).option(
@@ -85,6 +91,32 @@ function parseArgs() {
85
91
  );
86
92
  process.exit(1);
87
93
  }
94
+ if (options.expectedUsername === void 0 && process.env.MS365_MCP_EXPECTED_USERNAME !== void 0) {
95
+ options.expectedUsername = process.env.MS365_MCP_EXPECTED_USERNAME;
96
+ }
97
+ if (options.expectedHomeAccountId === void 0 && process.env.MS365_MCP_EXPECTED_HOME_ACCOUNT_ID !== void 0) {
98
+ options.expectedHomeAccountId = process.env.MS365_MCP_EXPECTED_HOME_ACCOUNT_ID;
99
+ }
100
+ if (options.expectedUsername !== void 0) {
101
+ const expectedUsername = String(options.expectedUsername).trim();
102
+ if (expectedUsername === "") {
103
+ console.error(
104
+ "Error: --expected-username / MS365_MCP_EXPECTED_USERNAME was provided but is empty. Provide a Microsoft account username, or omit it to allow any cached account."
105
+ );
106
+ process.exit(1);
107
+ }
108
+ options.expectedUsername = expectedUsername;
109
+ }
110
+ if (options.expectedHomeAccountId !== void 0) {
111
+ const expectedHomeAccountId = String(options.expectedHomeAccountId).trim();
112
+ if (expectedHomeAccountId === "") {
113
+ console.error(
114
+ "Error: --expected-home-account-id / MS365_MCP_EXPECTED_HOME_ACCOUNT_ID was provided but is empty. Provide an MSAL homeAccountId, or omit it to allow any cached account."
115
+ );
116
+ process.exit(1);
117
+ }
118
+ options.expectedHomeAccountId = expectedHomeAccountId;
119
+ }
88
120
  if (options.enabledTools) {
89
121
  try {
90
122
  new RegExp(options.enabledTools, "i");
package/dist/index.js CHANGED
@@ -4,6 +4,10 @@ import { parseArgs } from "./cli.js";
4
4
  import logger from "./logger.js";
5
5
  import AuthManager, { buildAllowedScopeDiagnostics, resolveAuthScopes } from "./auth.js";
6
6
  import MicrosoftGraphServer from "./server.js";
7
+ import {
8
+ getExpectedAccountInertWarning,
9
+ shouldAssertExpectedAccountAtStartup
10
+ } from "./startup-pinning.js";
7
11
  import { version } from "./version.js";
8
12
  async function main() {
9
13
  try {
@@ -37,12 +41,20 @@ async function main() {
37
41
  );
38
42
  process.exit(0);
39
43
  }
40
- const authManager = await AuthManager.create(effectiveScopes);
44
+ const authManager = await AuthManager.create(effectiveScopes, {
45
+ expectedUsername: args.expectedUsername,
46
+ expectedHomeAccountId: args.expectedHomeAccountId
47
+ });
41
48
  await authManager.loadTokenCache();
42
49
  if (args.authBrowser) {
43
50
  authManager.setUseInteractiveAuth(true);
44
51
  logger.info("Browser-based interactive auth enabled");
45
52
  }
53
+ const expectedAccountWarning = getExpectedAccountInertWarning(args, authManager);
54
+ if (expectedAccountWarning) {
55
+ logger.warn(expectedAccountWarning);
56
+ console.error(expectedAccountWarning);
57
+ }
46
58
  if (args.login) {
47
59
  if (args.authBrowser) {
48
60
  await authManager.acquireTokenInteractive();
@@ -97,6 +109,9 @@ async function main() {
97
109
  }
98
110
  process.exit(0);
99
111
  }
112
+ if (shouldAssertExpectedAccountAtStartup(args, authManager)) {
113
+ await authManager.assertExpectedAccountAvailable();
114
+ }
100
115
  const server = new MicrosoftGraphServer(authManager, args);
101
116
  await server.initialize(version);
102
117
  await server.start();
@@ -0,0 +1,40 @@
1
+ const LOCAL_ACCOUNT_COMMANDS = [
2
+ "login",
3
+ "logout",
4
+ "listAccounts",
5
+ "selectAccount",
6
+ "removeAccount",
7
+ "verifyLogin"
8
+ ];
9
+ function getExpectedAccountInertWarning(args, authManager) {
10
+ if (!authManager.hasExpectedAccount()) {
11
+ return null;
12
+ }
13
+ const inertModes = [];
14
+ if (args.http) {
15
+ inertModes.push("--http");
16
+ }
17
+ if (args.obo) {
18
+ inertModes.push("--obo");
19
+ }
20
+ if (authManager.isOAuthModeEnabled()) {
21
+ inertModes.push("MS365_MCP_OAUTH_TOKEN");
22
+ }
23
+ if (inertModes.length === 0) {
24
+ return null;
25
+ }
26
+ return `Warning: expected account pinning is configured, but ${inertModes.join(", ")} uses request-provided tokens for Graph calls. The pin only guards local MSAL auth helpers.`;
27
+ }
28
+ function shouldAssertExpectedAccountAtStartup(args, authManager) {
29
+ if (!authManager.hasExpectedAccount()) {
30
+ return false;
31
+ }
32
+ if (getExpectedAccountInertWarning(args, authManager)) {
33
+ return false;
34
+ }
35
+ return !LOCAL_ACCOUNT_COMMANDS.some((key) => Boolean(args[key]));
36
+ }
37
+ export {
38
+ getExpectedAccountInertWarning,
39
+ shouldAssertExpectedAccountAtStartup
40
+ };
@@ -194,6 +194,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
194
194
  ## Security Considerations
195
195
 
196
196
  - **Stateless**: the server does not store tokens — each request carries the user's Bearer token
197
+ - **Account pinning**: `MS365_MCP_EXPECTED_USERNAME` and `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID` protect local MSAL cache flows for headless stdio deployments. In `--http`, `--obo`, or `MS365_MCP_OAUTH_TOKEN` deployments they are warning-only because Graph calls use request-provided tokens.
197
198
  - **Admin consent**: grant tenant-wide consent to avoid per-user consent prompts
198
199
  - **Managed identity**: use managed identity for Key Vault access (no secrets in environment variables)
199
200
  - **Read-only mode**: use `--read-only` to disable all write operations (send, delete, update, create)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.109.0",
3
+ "version": "0.110.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",