@softeria/ms-365-mcp-server 0.107.2 → 0.109.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:
@@ -469,11 +498,12 @@ The following options can be used when running ms-365-mcp-server directly from t
469
498
  --login Login using device code flow
470
499
  --logout Log out and clear saved credentials
471
500
  --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)
501
+ --list-permissions List required Graph API permissions and exit (respects --org-mode, --preset, --enabled-tools, --allowed-scopes)
473
502
  --org-mode Enable organization/work mode from start (includes Teams, SharePoint, etc.)
474
503
  --work-mode Alias for --org-mode
475
504
  --force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
476
505
  --cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
506
+ --allowed-scopes <scopes> Limit exposed tools to Graph scopes covered by this allowlist
477
507
  ```
478
508
 
479
509
  ### Server Options
@@ -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;
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
- }
121
- });
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
- }
145
+ getEndpointRequiredScopes(endpoint, includeWorkAccountScopes).forEach(
146
+ (scope) => scopesSet.add(scope)
147
+ );
126
148
  });
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 = []) {
135
292
  logger.info(`And scopes are ${scopes.join(", ")}`, scopes);
136
293
  this.config = config;
137
294
  this.scopes = scopes;
@@ -148,7 +305,7 @@ class AuthManager {
148
305
  * Creates an AuthManager instance with secrets loaded from the configured provider.
149
306
  * Uses Key Vault if MS365_MCP_KEYVAULT_URL is set, otherwise environment variables.
150
307
  */
151
- static async create(scopes = buildScopesFromEndpoints()) {
308
+ static async create(scopes = []) {
152
309
  const secrets = await getSecrets();
153
310
  const config = createMsalConfig(secrets);
154
311
  return new AuthManager(config, scopes);
@@ -587,11 +744,18 @@ class AuthManager {
587
744
  }
588
745
  var auth_default = AuthManager;
589
746
  export {
747
+ buildAllowedScopeDiagnostics,
748
+ buildScopeDiagnostics,
590
749
  buildScopesFromEndpoints,
750
+ collapseScopeHierarchy,
591
751
  auth_default as default,
752
+ getEndpointRequiredScopes,
753
+ getMissingAllowedScopes,
592
754
  getSelectedAccountPath,
593
755
  getTokenCachePath,
756
+ parseAllowedScopes,
594
757
  pickNewest,
758
+ resolveAuthScopes,
595
759
  unwrapCache,
596
760
  wrapCache
597
761
  };
package/dist/cli.js CHANGED
@@ -17,6 +17,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
17
17
  ).option(
18
18
  "--enabled-tools <pattern>",
19
19
  'Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)'
20
+ ).option(
21
+ "--allowed-scopes <scopes>",
22
+ "Limit exposed tools to Graph scopes covered by this whitespace-separated allowlist"
20
23
  ).option(
21
24
  "--preset <names>",
22
25
  "Use preset tool categories (comma-separated). Available: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, all"
@@ -73,6 +76,15 @@ function parseArgs() {
73
76
  if (process.env.ENABLED_TOOLS) {
74
77
  options.enabledTools = process.env.ENABLED_TOOLS;
75
78
  }
79
+ if (options.allowedScopes === void 0 && process.env.MS365_MCP_ALLOWED_SCOPES !== void 0) {
80
+ options.allowedScopes = process.env.MS365_MCP_ALLOWED_SCOPES;
81
+ }
82
+ if (options.allowedScopes !== void 0 && options.allowedScopes.trim() === "") {
83
+ console.error(
84
+ "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."
85
+ );
86
+ process.exit(1);
87
+ }
76
88
  if (options.enabledTools) {
77
89
  try {
78
90
  new RegExp(options.enabledTools, "i");
@@ -620,7 +620,7 @@
620
620
  "isExcelOp": true,
621
621
  "scopes": ["Files.ReadWrite"],
622
622
  "skipEncoding": ["address"],
623
- "llmTip": "Apply font/fill/borders/alignment/wrapText/columnWidth/rowHeight to a specific range. Required path param 'address' (e.g. 'A1:E5' or 'Sheet1!A1:E5'). Body: { font: {bold,color,size,italic,name,underline}, fill: {color}, borders: [{sideIndex,style,color,weight}], horizontalAlignment, verticalAlignment, wrapText, columnWidth, rowHeight }."
623
+ "llmTip": "Apply rangeFormat properties to a specific range. Required path param 'address' (e.g. 'A1:E5' or 'Sheet1!A1:E5'). Body: { horizontalAlignment, verticalAlignment, wrapText, columnWidth, rowHeight }. Note: font, fill, and borders are sub-resources on rangeFormat — set them via /format/font, /format/fill, and /format/borders/{sideIndex} respectively, not on this endpoint."
624
624
  },
625
625
  {
626
626
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/sort",
@@ -704,7 +704,7 @@
704
704
  "method": "get",
705
705
  "toolName": "get-excel-used-range",
706
706
  "isExcelOp": true,
707
- "scopes": ["Files.Read"],
707
+ "scopes": ["Files.ReadWrite"],
708
708
  "llmTip": "Get the smallest range that encompasses any cells with values or formatting on the worksheet. Returns address, values, formulas, numberFormat, rowCount, columnCount. Use this to discover the populated bounds of a sheet before reading or appending — avoids guessing how far data extends. Optional $select to trim the response."
709
709
  },
710
710
  {
@@ -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,7 +2,7 @@
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
7
  import { version } from "./version.js";
8
8
  async function main() {
@@ -13,15 +13,31 @@ async function main() {
13
13
  logger.info("Organization mode enabled - including work account scopes");
14
14
  }
15
15
  const readOnly = args.readOnly || false;
16
- const scopes = buildScopesFromEndpoints(includeWorkScopes, args.enabledTools, readOnly);
16
+ const effectiveScopes = resolveAuthScopes(args);
17
17
  if (args.listPermissions) {
18
- const sorted = [...scopes].sort((a, b) => a.localeCompare(b));
18
+ const diagnostics = buildAllowedScopeDiagnostics(args);
19
19
  const mode = includeWorkScopes ? "org" : "personal";
20
20
  const filter = args.enabledTools ? args.enabledTools : void 0;
21
- console.log(JSON.stringify({ mode, readOnly, filter, permissions: sorted }, null, 2));
21
+ if (diagnostics.disabledTools.length > 0) {
22
+ console.error(
23
+ `Warning: allowed scopes disabled ${diagnostics.disabledTools.length} tools. Missing scopes: ${diagnostics.missingAllowedScopesForTools.join(", ")}`
24
+ );
25
+ }
26
+ console.log(
27
+ JSON.stringify(
28
+ {
29
+ mode,
30
+ readOnly,
31
+ filter,
32
+ ...diagnostics
33
+ },
34
+ null,
35
+ 2
36
+ )
37
+ );
22
38
  process.exit(0);
23
39
  }
24
- const authManager = await AuthManager.create(scopes);
40
+ const authManager = await AuthManager.create(effectiveScopes);
25
41
  await authManager.loadTokenCache();
26
42
  if (args.authBrowser) {
27
43
  authManager.setUseInteractiveAuth(true);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.107.2",
3
+ "version": "0.109.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",
@@ -620,7 +620,7 @@
620
620
  "isExcelOp": true,
621
621
  "scopes": ["Files.ReadWrite"],
622
622
  "skipEncoding": ["address"],
623
- "llmTip": "Apply font/fill/borders/alignment/wrapText/columnWidth/rowHeight to a specific range. Required path param 'address' (e.g. 'A1:E5' or 'Sheet1!A1:E5'). Body: { font: {bold,color,size,italic,name,underline}, fill: {color}, borders: [{sideIndex,style,color,weight}], horizontalAlignment, verticalAlignment, wrapText, columnWidth, rowHeight }."
623
+ "llmTip": "Apply rangeFormat properties to a specific range. Required path param 'address' (e.g. 'A1:E5' or 'Sheet1!A1:E5'). Body: { horizontalAlignment, verticalAlignment, wrapText, columnWidth, rowHeight }. Note: font, fill, and borders are sub-resources on rangeFormat — set them via /format/font, /format/fill, and /format/borders/{sideIndex} respectively, not on this endpoint."
624
624
  },
625
625
  {
626
626
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range()/sort",
@@ -704,7 +704,7 @@
704
704
  "method": "get",
705
705
  "toolName": "get-excel-used-range",
706
706
  "isExcelOp": true,
707
- "scopes": ["Files.Read"],
707
+ "scopes": ["Files.ReadWrite"],
708
708
  "llmTip": "Get the smallest range that encompasses any cells with values or formatting on the worksheet. Returns address, values, formulas, numberFormat, rowCount, columnCount. Use this to discover the populated bounds of a sheet before reading or appending — avoids guessing how far data extends. Optional $select to trim the response."
709
709
  },
710
710
  {