@softeria/ms-365-mcp-server 0.108.0 → 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 +31 -1
- package/dist/__tests__/graph-tools.test.js +98 -0
- package/dist/auth.js +179 -15
- package/dist/cli.js +12 -0
- package/dist/graph-tools.js +49 -4
- package/dist/index.js +21 -5
- package/dist/server.js +13 -14
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 =
|
|
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 =
|
|
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");
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
|
16
|
+
const effectiveScopes = resolveAuthScopes(args);
|
|
17
17
|
if (args.listPermissions) {
|
|
18
|
-
const
|
|
18
|
+
const diagnostics = buildAllowedScopeDiagnostics(args);
|
|
19
19
|
const mode = includeWorkScopes ? "org" : "personal";
|
|
20
20
|
const filter = args.enabledTools ? args.enabledTools : void 0;
|
|
21
|
-
|
|
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(
|
|
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 {
|
|
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 =
|
|
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`] :
|
|
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.
|
|
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",
|