@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 +61 -1
- package/dist/__tests__/graph-tools.test.js +98 -0
- package/dist/auth-tools.js +11 -2
- package/dist/auth.js +321 -17
- package/dist/cli.js +45 -1
- package/dist/graph-tools.js +49 -4
- package/dist/index.js +36 -5
- package/dist/server.js +13 -14
- package/dist/startup-pinning.js +40 -0
- package/docs/deployment.md +1 -0
- 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:
|
|
@@ -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
|
|
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;
|
package/dist/auth-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
145
|
+
getEndpointRequiredScopes(endpoint, includeWorkAccountScopes).forEach(
|
|
146
|
+
(scope) => scopesSet.add(scope)
|
|
147
|
+
);
|
|
121
148
|
});
|
|
122
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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(
|
|
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");
|
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,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, {
|
|
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
|
|
20
|
+
const effectiveScopes = resolveAuthScopes(args);
|
|
17
21
|
if (args.listPermissions) {
|
|
18
|
-
const
|
|
22
|
+
const diagnostics = buildAllowedScopeDiagnostics(args);
|
|
19
23
|
const mode = includeWorkScopes ? "org" : "personal";
|
|
20
24
|
const filter = args.enabledTools ? args.enabledTools : void 0;
|
|
21
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
@@ -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
|
+
};
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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",
|