@softeria/ms-365-mcp-server 0.108.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
@@ -121,6 +121,35 @@ npx @softeria/ms-365-mcp-server --preset mail --list-permissions
121
121
 
122
122
  This is useful for enterprise environments where Graph API permissions must be pre-approved and admin-consented before deploying a new version.
123
123
 
124
+ The `--list-permissions` JSON includes:
125
+
126
+ - `toolPermissions`: permissions implied by the tool surface before `--allowed-scopes` filtering
127
+ - `effectivePermissions`: permissions implied by the tools that remain enabled after `--allowed-scopes`
128
+ - `permissions`: legacy alias for `effectivePermissions`, kept for compatibility with existing scripts
129
+ - `allowedScopes`: the configured scope allowlist, when provided
130
+ - `disabledTools`: tools hidden because their required Graph scopes are not covered by `allowedScopes`
131
+ - `missingAllowedScopesForTools`: unique missing scopes across disabled tools
132
+ - `extraAllowedScopesNotUsedByTools`: allowed scopes that are not used by the current tool surface
133
+
134
+ ### Allowed Scopes
135
+
136
+ By default, MSAL requests the scopes implied by the enabled tools, and the tool surface is controlled by `--enabled-tools`, `--preset`, `--org-mode`, and `--read-only`.
137
+
138
+ Enterprise and headless deployments can add a scope boundary with `--allowed-scopes` or `MS365_MCP_ALLOWED_SCOPES`. When configured, the server first computes the normal tool surface, then hides Graph tools whose required scopes are not covered by the allowlist. OAuth metadata and login flows request only the effective permissions for the tools that remain enabled.
139
+
140
+ ```bash
141
+ npx @softeria/ms-365-mcp-server \
142
+ --org-mode \
143
+ --enabled-tools '^(list-mail-messages|get-mail-message|list-drives|get-drive-item|download-bytes)$' \
144
+ --allowed-scopes 'User.Read Mail.Read Files.Read'
145
+ ```
146
+
147
+ CLI value takes precedence over `MS365_MCP_ALLOWED_SCOPES`; if neither is set, the default tool-derived scope behavior is unchanged. Supplying an empty value fails at startup so deployments do not accidentally fall back to a wider tool surface.
148
+
149
+ Scope coverage is hierarchy-aware: for example, `Mail.ReadWrite` covers tools that require `Mail.Read`, and `Files.ReadWrite.All` covers tools that require `Files.Read`.
150
+
151
+ In HTTP mode, OAuth discovery advertises the effective filtered permissions so clients request the same consent surface. On-Behalf-Of mode (`--obo`) still advertises `api://<clientId>/access_as_user` for protected-resource metadata; `--allowed-scopes` does not override OBO.
152
+
124
153
  ## Organization/Work Mode
125
154
 
126
155
  To access work/school features (Teams, SharePoint, etc.), enable organization mode using any of these flags:
@@ -438,6 +467,32 @@ npx @softeria/ms-365-mcp-server --list-accounts
438
467
  - **100% backward compatible**: existing single-account setups work unchanged.
439
468
  - The `account` parameter accepts email address (e.g. `user@outlook.com`) or MSAL `homeAccountId`.
440
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
+
441
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.
442
497
 
443
498
  ## Tool Presets
@@ -469,11 +524,14 @@ The following options can be used when running ms-365-mcp-server directly from t
469
524
  --login Login using device code flow
470
525
  --logout Log out and clear saved credentials
471
526
  --verify-login Verify login without starting the server
472
- --list-permissions List all required Graph API permissions and exit (respects --org-mode, --preset, --enabled-tools)
527
+ --list-permissions List required Graph API permissions and exit (respects --org-mode, --preset, --enabled-tools, --allowed-scopes)
473
528
  --org-mode Enable organization/work mode from start (includes Teams, SharePoint, etc.)
474
529
  --work-mode Alias for --org-mode
475
530
  --force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
476
531
  --cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
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
477
535
  ```
478
536
 
479
537
  ### Server Options
@@ -513,6 +571,8 @@ Environment variables:
513
571
  - `MS365_MCP_KEYVAULT_URL`: Azure Key Vault URL for secrets management (see Azure Key Vault section)
514
572
  - `MS365_MCP_TOKEN_CACHE_PATH`: Custom file path for MSAL token cache (see Token Storage below)
515
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)
516
576
 
517
577
  ## Token Storage
518
578
 
@@ -573,6 +573,104 @@ describe("graph-tools", () => {
573
573
  expect(payload.error).toMatch(/relative Microsoft Graph path/);
574
574
  });
575
575
  });
576
+ describe("allowed scopes filtering", () => {
577
+ it("registerGraphTools hides Graph tools outside the allowed scopes", async () => {
578
+ mockEndpoints.push(
579
+ {
580
+ alias: "list-mail-messages",
581
+ method: "get",
582
+ path: "/me/messages",
583
+ description: "List mail",
584
+ parameters: []
585
+ },
586
+ {
587
+ alias: "list-calendar-events",
588
+ method: "get",
589
+ path: "/me/events",
590
+ description: "List events",
591
+ parameters: []
592
+ }
593
+ );
594
+ mockEndpointsJson = [
595
+ {
596
+ toolName: "list-mail-messages",
597
+ method: "get",
598
+ pathPattern: "/me/messages",
599
+ scopes: ["Mail.Read"]
600
+ },
601
+ {
602
+ toolName: "list-calendar-events",
603
+ method: "get",
604
+ pathPattern: "/me/events",
605
+ scopes: ["Calendars.Read"]
606
+ }
607
+ ];
608
+ const server = createMockServer();
609
+ const { registerGraphTools } = await loadModule();
610
+ registerGraphTools(
611
+ server,
612
+ createMockGraphClient(),
613
+ false,
614
+ void 0,
615
+ false,
616
+ void 0,
617
+ false,
618
+ [],
619
+ "Mail.Read"
620
+ );
621
+ expect(server.tools.has("list-mail-messages")).toBe(true);
622
+ expect(server.tools.has("list-calendar-events")).toBe(false);
623
+ });
624
+ it("discovery hides Graph tools outside the allowed scopes", async () => {
625
+ mockEndpoints.push(
626
+ {
627
+ alias: "list-mail-messages",
628
+ method: "get",
629
+ path: "/me/messages",
630
+ description: "List mail",
631
+ parameters: []
632
+ },
633
+ {
634
+ alias: "list-calendar-events",
635
+ method: "get",
636
+ path: "/me/events",
637
+ description: "List events",
638
+ parameters: []
639
+ }
640
+ );
641
+ mockEndpointsJson = [
642
+ {
643
+ toolName: "list-mail-messages",
644
+ method: "get",
645
+ pathPattern: "/me/messages",
646
+ scopes: ["Mail.Read"]
647
+ },
648
+ {
649
+ toolName: "list-calendar-events",
650
+ method: "get",
651
+ pathPattern: "/me/events",
652
+ scopes: ["Calendars.Read"]
653
+ }
654
+ ];
655
+ const server = createMockServer();
656
+ const { registerDiscoveryTools } = await loadModule();
657
+ registerDiscoveryTools(
658
+ server,
659
+ {},
660
+ false,
661
+ false,
662
+ void 0,
663
+ false,
664
+ [],
665
+ void 0,
666
+ "Mail.Read"
667
+ );
668
+ const result = await server.tools.get("search-tools").handler({ limit: 50 });
669
+ const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
670
+ expect(found).toContain("list-mail-messages");
671
+ expect(found).not.toContain("list-calendar-events");
672
+ });
673
+ });
576
674
  describe("discovery mode: utility tools", () => {
577
675
  it('search-tools surfaces download-bytes for "download" queries', async () => {
578
676
  mockEndpoints.length = 0;
@@ -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
@@ -89,6 +89,34 @@ const SCOPE_HIERARCHY = {
89
89
  "Tasks.ReadWrite": ["Tasks.Read"],
90
90
  "Contacts.ReadWrite": ["Contacts.Read"]
91
91
  };
92
+ function parseAllowedScopes(value) {
93
+ if (value === void 0) {
94
+ return void 0;
95
+ }
96
+ return Array.from(new Set(value.trim().split(/\s+/).filter(Boolean)));
97
+ }
98
+ function getEndpointRequiredScopes(endpoint, includeWorkAccountScopes = false) {
99
+ if (!endpoint) {
100
+ return [];
101
+ }
102
+ const scopes = /* @__PURE__ */ new Set();
103
+ if (endpoint.scopes && Array.isArray(endpoint.scopes)) {
104
+ endpoint.scopes.forEach((scope) => scopes.add(scope));
105
+ }
106
+ if (includeWorkAccountScopes && endpoint.workScopes && Array.isArray(endpoint.workScopes)) {
107
+ endpoint.workScopes.forEach((scope) => scopes.add(scope));
108
+ }
109
+ return Array.from(scopes);
110
+ }
111
+ function collapseRedundantScopes(scopes) {
112
+ const scopesSet = new Set(scopes);
113
+ Object.entries(SCOPE_HIERARCHY).forEach(([higherScope, lowerScopes]) => {
114
+ if (scopesSet.has(higherScope) && lowerScopes.every((scope) => scopesSet.has(scope))) {
115
+ lowerScopes.forEach((scope) => scopesSet.delete(scope));
116
+ }
117
+ });
118
+ return Array.from(scopesSet);
119
+ }
92
120
  function buildScopesFromEndpoints(includeWorkAccountScopes = false, enabledToolsPattern, readOnly = false) {
93
121
  const scopesSet = /* @__PURE__ */ new Set();
94
122
  let enabledToolsRegex;
@@ -104,7 +132,9 @@ function buildScopesFromEndpoints(includeWorkAccountScopes = false, enabledTools
104
132
  }
105
133
  endpoints.default.forEach((endpoint) => {
106
134
  if (readOnly && endpoint.method.toUpperCase() !== "GET") {
107
- return;
135
+ if (!(endpoint.method.toUpperCase() === "POST" && endpoint.readOnly)) {
136
+ return;
137
+ }
108
138
  }
109
139
  if (enabledToolsRegex && !enabledToolsRegex.test(endpoint.toolName)) {
110
140
  return;
@@ -112,26 +142,153 @@ function buildScopesFromEndpoints(includeWorkAccountScopes = false, enabledTools
112
142
  if (!includeWorkAccountScopes && !endpoint.scopes && endpoint.workScopes) {
113
143
  return;
114
144
  }
115
- if (endpoint.scopes && Array.isArray(endpoint.scopes)) {
116
- endpoint.scopes.forEach((scope) => scopesSet.add(scope));
117
- }
118
- if (includeWorkAccountScopes && endpoint.workScopes && Array.isArray(endpoint.workScopes)) {
119
- endpoint.workScopes.forEach((scope) => scopesSet.add(scope));
120
- }
145
+ getEndpointRequiredScopes(endpoint, includeWorkAccountScopes).forEach(
146
+ (scope) => scopesSet.add(scope)
147
+ );
121
148
  });
122
- Object.entries(SCOPE_HIERARCHY).forEach(([higherScope, lowerScopes]) => {
123
- if (scopesSet.has(higherScope) && lowerScopes.every((scope) => scopesSet.has(scope))) {
124
- lowerScopes.forEach((scope) => scopesSet.delete(scope));
125
- }
126
- });
127
- const scopes = Array.from(scopesSet);
149
+ const scopes = collapseRedundantScopes(Array.from(scopesSet));
128
150
  if (enabledToolsPattern) {
129
151
  logger.info(`Built ${scopes.length} scopes for filtered tools: ${scopes.join(", ")}`);
130
152
  }
131
153
  return scopes;
132
154
  }
155
+ function lowerScopesFor(scope) {
156
+ const lowerScopes = new Set(SCOPE_HIERARCHY[scope] ?? []);
157
+ if (scope.endsWith(".ReadWrite.All")) {
158
+ const readAllScope = scope.replace(/\.ReadWrite\.All$/, ".Read.All");
159
+ const readWriteScope = scope.replace(/\.ReadWrite\.All$/, ".ReadWrite");
160
+ const readScope = scope.replace(/\.ReadWrite\.All$/, ".Read");
161
+ lowerScopes.add(readAllScope);
162
+ lowerScopes.add(readWriteScope);
163
+ lowerScopes.add(readScope);
164
+ } else if (scope.endsWith(".ReadWrite.Shared")) {
165
+ lowerScopes.add(scope.replace(/\.ReadWrite\.Shared$/, ".Read.Shared"));
166
+ } else if (scope.endsWith(".ReadWrite")) {
167
+ lowerScopes.add(scope.replace(/\.ReadWrite$/, ".Read"));
168
+ } else if (scope.endsWith(".Read.All")) {
169
+ lowerScopes.add(scope.replace(/\.Read\.All$/, ".Read"));
170
+ }
171
+ return Array.from(lowerScopes);
172
+ }
173
+ function addImpliedScopes(scope, scopesSet) {
174
+ for (const lowerScope of lowerScopesFor(scope)) {
175
+ if (!scopesSet.has(lowerScope)) {
176
+ scopesSet.add(lowerScope);
177
+ addImpliedScopes(lowerScope, scopesSet);
178
+ }
179
+ }
180
+ }
181
+ function collapseScopeHierarchy(scopes) {
182
+ const scopesSet = new Set(scopes);
183
+ for (const scope of scopes) {
184
+ addImpliedScopes(scope, scopesSet);
185
+ }
186
+ return Array.from(scopesSet);
187
+ }
188
+ function getMissingAllowedScopes(requiredScopes, allowedScopes) {
189
+ if (allowedScopes === void 0) {
190
+ return [];
191
+ }
192
+ const coveredAllowedScopes = new Set(collapseScopeHierarchy(allowedScopes));
193
+ return requiredScopes.filter((scope) => !coveredAllowedScopes.has(scope));
194
+ }
195
+ function isScopeUsedByTools(allowedScope, toolScopes) {
196
+ const coveredByAllowedScope = new Set(collapseScopeHierarchy([allowedScope]));
197
+ return toolScopes.some((scope) => coveredByAllowedScope.has(scope));
198
+ }
199
+ function endpointMatchesNormalToolSurface(endpoint, includeWorkAccountScopes, enabledToolsRegex, readOnly = false) {
200
+ if (readOnly && endpoint.method.toUpperCase() !== "GET") {
201
+ if (!(endpoint.method.toUpperCase() === "POST" && endpoint.readOnly)) {
202
+ return false;
203
+ }
204
+ }
205
+ if (enabledToolsRegex && !enabledToolsRegex.test(endpoint.toolName)) {
206
+ return false;
207
+ }
208
+ if (!includeWorkAccountScopes && !endpoint.scopes && endpoint.workScopes) {
209
+ return false;
210
+ }
211
+ return true;
212
+ }
213
+ function buildAllowedScopeDiagnostics(options = {}) {
214
+ const allowedScopes = parseAllowedScopes(options.allowedScopes);
215
+ let enabledToolsRegex;
216
+ if (options.enabledTools) {
217
+ try {
218
+ enabledToolsRegex = new RegExp(options.enabledTools, "i");
219
+ } catch {
220
+ logger.error(
221
+ `Invalid tool filter regex pattern: ${options.enabledTools}. Building diagnostics without filter.`
222
+ );
223
+ }
224
+ }
225
+ const normalToolScopes = /* @__PURE__ */ new Set();
226
+ const effectiveToolScopes = /* @__PURE__ */ new Set();
227
+ const disabledTools = [];
228
+ for (const endpoint of endpoints.default) {
229
+ if (!endpointMatchesNormalToolSurface(
230
+ endpoint,
231
+ Boolean(options.orgMode),
232
+ enabledToolsRegex,
233
+ Boolean(options.readOnly)
234
+ )) {
235
+ continue;
236
+ }
237
+ const requiredScopes = getEndpointRequiredScopes(endpoint, Boolean(options.orgMode));
238
+ requiredScopes.forEach((scope) => normalToolScopes.add(scope));
239
+ const missingScopes = getMissingAllowedScopes(requiredScopes, allowedScopes);
240
+ if (missingScopes.length > 0) {
241
+ disabledTools.push({
242
+ toolName: endpoint.toolName,
243
+ requiredScopes: requiredScopes.sort((a, b) => a.localeCompare(b)),
244
+ missingScopes: missingScopes.sort((a, b) => a.localeCompare(b))
245
+ });
246
+ continue;
247
+ }
248
+ requiredScopes.forEach((scope) => effectiveToolScopes.add(scope));
249
+ }
250
+ const toolPermissions = collapseRedundantScopes(Array.from(normalToolScopes)).sort(
251
+ (a, b) => a.localeCompare(b)
252
+ );
253
+ const effectivePermissions = collapseRedundantScopes(Array.from(effectiveToolScopes)).sort(
254
+ (a, b) => a.localeCompare(b)
255
+ );
256
+ const sortedAllowedScopes = allowedScopes ? [...allowedScopes].sort((a, b) => a.localeCompare(b)) : void 0;
257
+ const missingAllowedScopesForTools = Array.from(
258
+ new Set(disabledTools.flatMap((tool) => tool.missingScopes))
259
+ ).sort((a, b) => a.localeCompare(b));
260
+ const extraAllowedScopesNotUsedByTools = sortedAllowedScopes?.filter((scope) => !isScopeUsedByTools(scope, effectivePermissions)) ?? [];
261
+ return {
262
+ permissions: effectivePermissions,
263
+ toolPermissions,
264
+ effectivePermissions,
265
+ ...sortedAllowedScopes ? { allowedScopes: sortedAllowedScopes } : {},
266
+ disabledTools,
267
+ missingAllowedScopesForTools,
268
+ extraAllowedScopesNotUsedByTools
269
+ };
270
+ }
271
+ function resolveAuthScopes(options = {}) {
272
+ return buildAllowedScopeDiagnostics(options).effectivePermissions;
273
+ }
274
+ function buildScopeDiagnostics(toolScopes, allowedScopesInput) {
275
+ const toolPermissions = [...toolScopes].sort((a, b) => a.localeCompare(b));
276
+ const coveredAllowedScopes = new Set(collapseScopeHierarchy(allowedScopesInput));
277
+ const missingAllowedScopesForTools = toolPermissions.filter(
278
+ (scope) => !coveredAllowedScopes.has(scope)
279
+ );
280
+ return {
281
+ permissions: toolPermissions.filter((scope) => coveredAllowedScopes.has(scope)),
282
+ toolPermissions,
283
+ effectivePermissions: toolPermissions.filter((scope) => coveredAllowedScopes.has(scope)),
284
+ allowedScopes: [...allowedScopesInput].sort((a, b) => a.localeCompare(b)),
285
+ disabledTools: [],
286
+ missingAllowedScopesForTools,
287
+ extraAllowedScopesNotUsedByTools: [...allowedScopesInput].sort((a, b) => a.localeCompare(b)).filter((scope) => !isScopeUsedByTools(scope, toolPermissions))
288
+ };
289
+ }
133
290
  class AuthManager {
134
- constructor(config, scopes = buildScopesFromEndpoints()) {
291
+ constructor(config, scopes = [], expectedAccount) {
135
292
  logger.info(`And scopes are ${scopes.join(", ")}`, scopes);
136
293
  this.config = config;
137
294
  this.scopes = scopes;
@@ -140,6 +297,10 @@ class AuthManager {
140
297
  this.tokenExpiry = null;
141
298
  this.selectedAccountId = null;
142
299
  this.useInteractiveAuth = false;
300
+ this.expectedUsername = this.normalizeExpectedUsername(expectedAccount?.expectedUsername);
301
+ this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
302
+ expectedAccount?.expectedHomeAccountId
303
+ );
143
304
  const oauthTokenFromEnv = process.env.MS365_MCP_OAUTH_TOKEN;
144
305
  this.oauthToken = oauthTokenFromEnv ?? null;
145
306
  this.isOAuthMode = oauthTokenFromEnv != null;
@@ -148,10 +309,10 @@ class AuthManager {
148
309
  * Creates an AuthManager instance with secrets loaded from the configured provider.
149
310
  * Uses Key Vault if MS365_MCP_KEYVAULT_URL is set, otherwise environment variables.
150
311
  */
151
- static async create(scopes = buildScopesFromEndpoints()) {
312
+ static async create(scopes = [], expectedAccount) {
152
313
  const secrets = await getSecrets();
153
314
  const config = createMsalConfig(secrets);
154
- return new AuthManager(config, scopes);
315
+ return new AuthManager(config, scopes, expectedAccount);
155
316
  }
156
317
  async loadTokenCache() {
157
318
  try {
@@ -254,6 +415,118 @@ class AuthManager {
254
415
  logger.error(`Error saving selected account: ${error.message}`);
255
416
  }
256
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
+ }
257
530
  async setOAuthToken(token) {
258
531
  this.oauthToken = token;
259
532
  this.isOAuthMode = true;
@@ -286,6 +559,9 @@ class AuthManager {
286
559
  }
287
560
  async getCurrentAccount() {
288
561
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
562
+ if (this.hasExpectedAccount()) {
563
+ return this.resolveExpectedAccountFromAccounts(accounts);
564
+ }
289
565
  if (accounts.length === 0) {
290
566
  return null;
291
567
  }
@@ -323,6 +599,7 @@ class AuthManager {
323
599
  logger.info("Device code login successful");
324
600
  this.accessToken = response?.accessToken || null;
325
601
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
602
+ await this.rejectUnexpectedLoginAccount(response?.account);
326
603
  if (!this.selectedAccountId && response?.account) {
327
604
  this.selectedAccountId = response.account.homeAccountId;
328
605
  await this.saveSelectedAccount();
@@ -364,6 +641,7 @@ class AuthManager {
364
641
  logger.info("Interactive browser login successful");
365
642
  this.accessToken = response?.accessToken || null;
366
643
  this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
644
+ await this.rejectUnexpectedLoginAccount(response?.account);
367
645
  if (!this.selectedAccountId && response?.account) {
368
646
  this.selectedAccountId = response.account.homeAccountId;
369
647
  await this.saveSelectedAccount();
@@ -468,6 +746,11 @@ class AuthManager {
468
746
  }
469
747
  async selectAccount(identifier) {
470
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
+ }
471
754
  this.selectedAccountId = account.homeAccountId;
472
755
  await this.saveSelectedAccount();
473
756
  this.accessToken = null;
@@ -529,6 +812,9 @@ class AuthManager {
529
812
  * Used to decide whether to inject the `account` parameter into tool schemas.
530
813
  */
531
814
  async isMultiAccount() {
815
+ if (this.hasExpectedAccount()) {
816
+ return false;
817
+ }
532
818
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
533
819
  return accounts.length > 1;
534
820
  }
@@ -549,7 +835,18 @@ class AuthManager {
549
835
  return this.oauthToken;
550
836
  }
551
837
  let targetAccount = null;
552
- 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) {
553
850
  targetAccount = await this.resolveAccount(identifier);
554
851
  } else {
555
852
  const accounts = await this.msalApp.getTokenCache().getAllAccounts();
@@ -587,11 +884,18 @@ class AuthManager {
587
884
  }
588
885
  var auth_default = AuthManager;
589
886
  export {
887
+ buildAllowedScopeDiagnostics,
888
+ buildScopeDiagnostics,
590
889
  buildScopesFromEndpoints,
890
+ collapseScopeHierarchy,
591
891
  auth_default as default,
892
+ getEndpointRequiredScopes,
893
+ getMissingAllowedScopes,
592
894
  getSelectedAccountPath,
593
895
  getTokenCachePath,
896
+ parseAllowedScopes,
594
897
  pickNewest,
898
+ resolveAuthScopes,
595
899
  unwrapCache,
596
900
  wrapCache
597
901
  };
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(
@@ -17,6 +23,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
17
23
  ).option(
18
24
  "--enabled-tools <pattern>",
19
25
  'Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)'
26
+ ).option(
27
+ "--allowed-scopes <scopes>",
28
+ "Limit exposed tools to Graph scopes covered by this whitespace-separated allowlist"
20
29
  ).option(
21
30
  "--preset <names>",
22
31
  "Use preset tool categories (comma-separated). Available: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, all"
@@ -73,6 +82,41 @@ function parseArgs() {
73
82
  if (process.env.ENABLED_TOOLS) {
74
83
  options.enabledTools = process.env.ENABLED_TOOLS;
75
84
  }
85
+ if (options.allowedScopes === void 0 && process.env.MS365_MCP_ALLOWED_SCOPES !== void 0) {
86
+ options.allowedScopes = process.env.MS365_MCP_ALLOWED_SCOPES;
87
+ }
88
+ if (options.allowedScopes !== void 0 && options.allowedScopes.trim() === "") {
89
+ console.error(
90
+ "Error: --allowed-scopes / MS365_MCP_ALLOWED_SCOPES was provided but is empty. Provide one or more whitespace-separated scopes, or omit it to use tool-derived scopes."
91
+ );
92
+ process.exit(1);
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
+ }
76
120
  if (options.enabledTools) {
77
121
  try {
78
122
  new RegExp(options.enabledTools, "i");
@@ -1,4 +1,9 @@
1
1
  import logger from "./logger.js";
2
+ import {
3
+ getEndpointRequiredScopes,
4
+ getMissingAllowedScopes,
5
+ parseAllowedScopes
6
+ } from "./auth.js";
2
7
  import { api } from "./generated/client.js";
3
8
  import { z } from "zod";
4
9
  import { readFileSync } from "fs";
@@ -34,6 +39,11 @@ function clampTopQueryParam(queryParams) {
34
39
  logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
35
40
  queryParams["$top"] = String(cap);
36
41
  }
42
+ function formatDisabledToolsForLog(disabledTools) {
43
+ const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
44
+ const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
45
+ return `${shown.join("; ")}${suffix}`;
46
+ }
37
47
  const UTILITY_TOOLS = [
38
48
  {
39
49
  name: "parse-teams-url",
@@ -404,7 +414,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
404
414
  };
405
415
  }
406
416
  }
407
- function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = []) {
417
+ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = [], allowedScopesValue) {
408
418
  let enabledToolsRegex;
409
419
  if (enabledToolsPattern) {
410
420
  try {
@@ -417,6 +427,8 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
417
427
  let registeredCount = 0;
418
428
  let skippedCount = 0;
419
429
  let failedCount = 0;
430
+ const allowedScopes = parseAllowedScopes(allowedScopesValue);
431
+ const disabledByAllowedScopes = [];
420
432
  for (const tool of api.endpoints) {
421
433
  const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
422
434
  if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
@@ -437,6 +449,13 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
437
449
  skippedCount++;
438
450
  continue;
439
451
  }
452
+ const requiredScopes = getEndpointRequiredScopes(endpointConfig, orgMode);
453
+ const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(requiredScopes, allowedScopes);
454
+ if (missingScopes.length > 0) {
455
+ disabledByAllowedScopes.push({ toolName: tool.alias, missingScopes });
456
+ skippedCount++;
457
+ continue;
458
+ }
440
459
  const paramSchema = {};
441
460
  if (tool.parameters && tool.parameters.length > 0) {
442
461
  for (const param of tool.parameters) {
@@ -536,6 +555,11 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
536
555
  if (multiAccount) {
537
556
  logger.info('Multi-account mode: "account" parameter injected into all tool schemas');
538
557
  }
558
+ if (disabledByAllowedScopes.length > 0) {
559
+ logger.info(
560
+ `Allowed scopes disabled ${disabledByAllowedScopes.length} Graph tools: ${formatDisabledToolsForLog(disabledByAllowedScopes)}`
561
+ );
562
+ }
539
563
  const utilityCtx = {
540
564
  graphClient,
541
565
  authManager,
@@ -558,8 +582,9 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
558
582
  );
559
583
  return registeredCount;
560
584
  }
561
- function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex) {
585
+ function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex, allowedScopesValue, disabledByAllowedScopes = []) {
562
586
  const toolsMap = /* @__PURE__ */ new Map();
587
+ const allowedScopes = parseAllowedScopes(allowedScopesValue);
563
588
  for (const tool of api.endpoints) {
564
589
  const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
565
590
  if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
@@ -574,6 +599,14 @@ function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex) {
574
599
  if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
575
600
  continue;
576
601
  }
602
+ const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(
603
+ getEndpointRequiredScopes(endpointConfig, orgMode),
604
+ allowedScopes
605
+ );
606
+ if (missingScopes.length > 0) {
607
+ disabledByAllowedScopes.push({ toolName: tool.alias, missingScopes });
608
+ continue;
609
+ }
577
610
  toolsMap.set(tool.alias, { tool, config: endpointConfig });
578
611
  }
579
612
  return toolsMap;
@@ -635,7 +668,7 @@ function scoreDiscoveryQuery(query, index) {
635
668
  ranked.sort((a, b) => b.score - a.score);
636
669
  return ranked;
637
670
  }
638
- function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools) {
671
+ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools, allowedScopesValue) {
639
672
  let enabledToolsRegex;
640
673
  if (enabledTools) {
641
674
  try {
@@ -647,7 +680,19 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
647
680
  );
648
681
  }
649
682
  }
650
- const toolsRegistry = buildToolsRegistry(readOnly, orgMode, enabledToolsRegex);
683
+ const disabledByAllowedScopes = [];
684
+ const toolsRegistry = buildToolsRegistry(
685
+ readOnly,
686
+ orgMode,
687
+ enabledToolsRegex,
688
+ allowedScopesValue,
689
+ disabledByAllowedScopes
690
+ );
691
+ if (disabledByAllowedScopes.length > 0) {
692
+ logger.info(
693
+ `Discovery mode: allowed scopes disabled ${disabledByAllowedScopes.length} Graph tools: ${formatDisabledToolsForLog(disabledByAllowedScopes)}`
694
+ );
695
+ }
651
696
  const utilityTools = UTILITY_TOOLS.filter((u) => {
652
697
  if (readOnly && !u.readOnlyHint) return false;
653
698
  if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
package/dist/index.js CHANGED
@@ -2,8 +2,12 @@
2
2
  import "dotenv/config";
3
3
  import { parseArgs } from "./cli.js";
4
4
  import logger from "./logger.js";
5
- import AuthManager, { buildScopesFromEndpoints } from "./auth.js";
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 {
@@ -13,20 +17,44 @@ async function main() {
13
17
  logger.info("Organization mode enabled - including work account scopes");
14
18
  }
15
19
  const readOnly = args.readOnly || false;
16
- const scopes = buildScopesFromEndpoints(includeWorkScopes, args.enabledTools, readOnly);
20
+ const effectiveScopes = resolveAuthScopes(args);
17
21
  if (args.listPermissions) {
18
- const sorted = [...scopes].sort((a, b) => a.localeCompare(b));
22
+ const diagnostics = buildAllowedScopeDiagnostics(args);
19
23
  const mode = includeWorkScopes ? "org" : "personal";
20
24
  const filter = args.enabledTools ? args.enabledTools : void 0;
21
- console.log(JSON.stringify({ mode, readOnly, filter, permissions: sorted }, null, 2));
25
+ if (diagnostics.disabledTools.length > 0) {
26
+ console.error(
27
+ `Warning: allowed scopes disabled ${diagnostics.disabledTools.length} tools. Missing scopes: ${diagnostics.missingAllowedScopesForTools.join(", ")}`
28
+ );
29
+ }
30
+ console.log(
31
+ JSON.stringify(
32
+ {
33
+ mode,
34
+ readOnly,
35
+ filter,
36
+ ...diagnostics
37
+ },
38
+ null,
39
+ 2
40
+ )
41
+ );
22
42
  process.exit(0);
23
43
  }
24
- const authManager = await AuthManager.create(scopes);
44
+ const authManager = await AuthManager.create(effectiveScopes, {
45
+ expectedUsername: args.expectedUsername,
46
+ expectedHomeAccountId: args.expectedHomeAccountId
47
+ });
25
48
  await authManager.loadTokenCache();
26
49
  if (args.authBrowser) {
27
50
  authManager.setUseInteractiveAuth(true);
28
51
  logger.info("Browser-based interactive auth enabled");
29
52
  }
53
+ const expectedAccountWarning = getExpectedAccountInertWarning(args, authManager);
54
+ if (expectedAccountWarning) {
55
+ logger.warn(expectedAccountWarning);
56
+ console.error(expectedAccountWarning);
57
+ }
30
58
  if (args.login) {
31
59
  if (args.authBrowser) {
32
60
  await authManager.acquireTokenInteractive();
@@ -81,6 +109,9 @@ async function main() {
81
109
  }
82
110
  process.exit(0);
83
111
  }
112
+ if (shouldAssertExpectedAccountAtStartup(args, authManager)) {
113
+ await authManager.assertExpectedAccountAvailable();
114
+ }
84
115
  const server = new MicrosoftGraphServer(authManager, args);
85
116
  await server.initialize(version);
86
117
  await server.start();
package/dist/server.js CHANGED
@@ -8,7 +8,11 @@ import { registerAuthTools } from "./auth-tools.js";
8
8
  import { registerGraphTools, registerDiscoveryTools } from "./graph-tools.js";
9
9
  import { buildMcpServerInstructions } from "./mcp-instructions.js";
10
10
  import GraphClient from "./graph-client.js";
11
- import { buildScopesFromEndpoints } from "./auth.js";
11
+ import {
12
+ buildScopesFromEndpoints,
13
+ parseAllowedScopes,
14
+ resolveAuthScopes
15
+ } from "./auth.js";
12
16
  import { MicrosoftOAuthProvider } from "./oauth-provider.js";
13
17
  import {
14
18
  exchangeCodeForToken,
@@ -77,7 +81,8 @@ class MicrosoftGraphServer {
77
81
  this.authManager,
78
82
  this.multiAccount,
79
83
  this.accountNames,
80
- this.options.enabledTools
84
+ this.options.enabledTools,
85
+ this.options.allowedScopes
81
86
  );
82
87
  } else {
83
88
  registerGraphTools(
@@ -88,7 +93,8 @@ class MicrosoftGraphServer {
88
93
  this.options.orgMode,
89
94
  this.authManager,
90
95
  this.multiAccount,
91
- this.accountNames
96
+ this.accountNames,
97
+ this.options.allowedScopes
92
98
  );
93
99
  }
94
100
  return server;
@@ -170,11 +176,7 @@ class MicrosoftGraphServer {
170
176
  const protocol = req.secure ? "https" : "http";
171
177
  const requestOrigin = `${protocol}://${req.get("host")}`;
172
178
  const browserBase = publicBase ?? requestOrigin;
173
- const scopes = buildScopesFromEndpoints(
174
- this.options.orgMode,
175
- this.options.enabledTools,
176
- this.options.readOnly
177
- );
179
+ const scopes = resolveAuthScopes(this.options);
178
180
  const metadata = {
179
181
  issuer: browserBase,
180
182
  authorization_endpoint: `${browserBase}/authorize`,
@@ -195,11 +197,7 @@ class MicrosoftGraphServer {
195
197
  const protocol = req.secure ? "https" : "http";
196
198
  const requestOrigin = `${protocol}://${req.get("host")}`;
197
199
  const browserBase = publicBase ?? requestOrigin;
198
- const scopes = this.options.obo ? [`api://${this.secrets.clientId}/access_as_user`] : buildScopesFromEndpoints(
199
- this.options.orgMode,
200
- this.options.enabledTools,
201
- this.options.readOnly
202
- );
200
+ const scopes = this.options.obo ? [`api://${this.secrets.clientId}/access_as_user`] : resolveAuthScopes(this.options);
203
201
  res.json({
204
202
  resource: `${requestOrigin}/mcp`,
205
203
  authorization_servers: [browserBase],
@@ -304,8 +302,9 @@ class MicrosoftGraphServer {
304
302
  }
305
303
  }
306
304
  microsoftAuthUrl.searchParams.set("client_id", clientId);
305
+ const explicitAllowedScopes = parseAllowedScopes(this.options.allowedScopes);
307
306
  const clientScope = microsoftAuthUrl.searchParams.get("scope");
308
- const baseScopes = clientScope ? clientScope.split(/\s+/).filter(Boolean) : buildScopesFromEndpoints(
307
+ const baseScopes = explicitAllowedScopes !== void 0 ? resolveAuthScopes(this.options) : clientScope ? clientScope.split(/\s+/).filter(Boolean) : buildScopesFromEndpoints(
309
308
  this.options.orgMode,
310
309
  this.options.enabledTools,
311
310
  this.options.readOnly
@@ -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.108.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",