@softeria/ms-365-mcp-server 0.109.0 → 0.111.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/.env.example +12 -1
- package/README.md +68 -0
- package/dist/auth-tools.js +11 -2
- package/dist/auth.js +179 -153
- package/dist/cli.js +33 -1
- package/dist/index.js +30 -2
- package/dist/startup-pinning.js +50 -0
- package/dist/token-cache-storage.js +336 -0
- package/docs/deployment.md +9 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -51,4 +51,15 @@ MS365_MCP_TENANT_ID=common
|
|
|
51
51
|
# - Azure CLI credentials (for local development)
|
|
52
52
|
# - Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
|
53
53
|
#
|
|
54
|
-
# See README.md for detailed Azure Key Vault setup instructions.
|
|
54
|
+
# See README.md for detailed Azure Key Vault setup instructions.
|
|
55
|
+
|
|
56
|
+
# -------------------------------------------------------------------
|
|
57
|
+
# External Auth Cache Storage (Optional)
|
|
58
|
+
# -------------------------------------------------------------------
|
|
59
|
+
# Headless local-MSAL deployments can store token-cache and selected-account
|
|
60
|
+
# metadata with a provider-neutral executable wrapper. The value is a real
|
|
61
|
+
# executable path, not a shell command string; put any provider-specific args
|
|
62
|
+
# inside the wrapper script.
|
|
63
|
+
#
|
|
64
|
+
# MS365_MCP_AUTH_CACHE_COMMAND=/path/to/ms365-auth-cache-store
|
|
65
|
+
# MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS=10000
|
package/README.md
CHANGED
|
@@ -467,6 +467,32 @@ npx @softeria/ms-365-mcp-server --list-accounts
|
|
|
467
467
|
- **100% backward compatible**: existing single-account setups work unchanged.
|
|
468
468
|
- The `account` parameter accepts email address (e.g. `user@outlook.com`) or MSAL `homeAccountId`.
|
|
469
469
|
|
|
470
|
+
### Strict Account Pinning
|
|
471
|
+
|
|
472
|
+
Headless stdio deployments can pin the local MSAL cache to one expected Microsoft account:
|
|
473
|
+
|
|
474
|
+
```bash
|
|
475
|
+
# Username matching is case-insensitive
|
|
476
|
+
MS365_MCP_EXPECTED_USERNAME=work@company.com npx @softeria/ms-365-mcp-server --login
|
|
477
|
+
|
|
478
|
+
# Or pin the exact MSAL homeAccountId shown by --list-accounts
|
|
479
|
+
npx @softeria/ms-365-mcp-server --expected-home-account-id <homeAccountId> --login
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Use `--list-accounts` to discover `homeAccountId` values. The MCP `list-accounts` tool intentionally hides account IDs, so use the CLI for exact ID pinning.
|
|
483
|
+
|
|
484
|
+
Pinning is opt-in and local-MSAL only:
|
|
485
|
+
|
|
486
|
+
- CLI values (`--expected-username`, `--expected-home-account-id`) take precedence over `MS365_MCP_EXPECTED_USERNAME` and `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`.
|
|
487
|
+
- Supplying an empty pin value fails at startup instead of being ignored.
|
|
488
|
+
- Username pins are compared case-insensitively; `homeAccountId` pins are exact.
|
|
489
|
+
- If both pins are set, they must resolve to the same cached account.
|
|
490
|
+
- Local stdio startup fails fast when the expected account is not in the token cache. Bootstrap by setting the pin, running `--login`, then starting the headless server.
|
|
491
|
+
- Device-code and browser logins reject a missing or mismatched account before persisting the selected account or token cache.
|
|
492
|
+
- Pinning collapses the effective MCP mode to single-account: the server does not advertise an `account` parameter and MCP instructions do not suggest account switching.
|
|
493
|
+
- `--http`, `--obo`, and `MS365_MCP_OAUTH_TOKEN` use request-provided tokens for Graph calls, so account pins are warning-only in those modes. If HTTP auth tools are enabled, the pin still applies to those local MSAL helper flows.
|
|
494
|
+
- `--logout` clears all cached accounts, including the pinned account. For surgical cleanup, prefer `--remove-account <id>`.
|
|
495
|
+
|
|
470
496
|
> **For MCP multiplexers (Legate, Governor):** Multi-account mode replaces the N-process pattern. Instead of spawning one server per account, a single instance handles all accounts via the `account` parameter, reducing tool duplication from N×110 to 110.
|
|
471
497
|
|
|
472
498
|
## Tool Presets
|
|
@@ -504,6 +530,8 @@ The following options can be used when running ms-365-mcp-server directly from t
|
|
|
504
530
|
--force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
|
|
505
531
|
--cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
|
|
506
532
|
--allowed-scopes <scopes> Limit exposed tools to Graph scopes covered by this allowlist
|
|
533
|
+
--expected-username <username> Require local MSAL auth to use this account username
|
|
534
|
+
--expected-home-account-id <id> Require local MSAL auth to use this exact homeAccountId
|
|
507
535
|
```
|
|
508
536
|
|
|
509
537
|
### Server Options
|
|
@@ -543,6 +571,10 @@ Environment variables:
|
|
|
543
571
|
- `MS365_MCP_KEYVAULT_URL`: Azure Key Vault URL for secrets management (see Azure Key Vault section)
|
|
544
572
|
- `MS365_MCP_TOKEN_CACHE_PATH`: Custom file path for MSAL token cache (see Token Storage below)
|
|
545
573
|
- `MS365_MCP_SELECTED_ACCOUNT_PATH`: Custom file path for selected account metadata (see Token Storage below)
|
|
574
|
+
- `MS365_MCP_AUTH_CACHE_COMMAND`: External executable wrapper for provider-neutral auth-cache storage (see Token Storage below)
|
|
575
|
+
- `MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS`: Per-invocation timeout for `MS365_MCP_AUTH_CACHE_COMMAND` (default: `10000`)
|
|
576
|
+
- `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
|
|
577
|
+
- `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
|
|
546
578
|
|
|
547
579
|
## Token Storage
|
|
548
580
|
|
|
@@ -563,6 +595,42 @@ Parent directories are created automatically. Files are written with `0600` perm
|
|
|
563
595
|
|
|
564
596
|
> **Hosted/sandboxed environments** (e.g. Anthropic Cowork): Set `MS365_MCP_TOKEN_CACHE_PATH` and `MS365_MCP_SELECTED_ACCOUNT_PATH` to a persistent mount so tokens survive between sessions.
|
|
565
597
|
|
|
598
|
+
### External auth-cache command
|
|
599
|
+
|
|
600
|
+
Headless local-MSAL deployments can replace the built-in keytar/file storage with a provider-neutral external command:
|
|
601
|
+
|
|
602
|
+
```bash
|
|
603
|
+
export MS365_MCP_AUTH_CACHE_COMMAND="/path/to/ms365-auth-cache-store"
|
|
604
|
+
export MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS=10000
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
When `MS365_MCP_AUTH_CACHE_COMMAND` is set for a local auth flow, the server uses only that command for the MSAL token cache and selected-account metadata. It does not fall back to keytar or local files. If the command path is missing, not executable on POSIX, exits non-zero, times out, or returns malformed data, auth-cache operations fail closed with a sanitized error message.
|
|
608
|
+
|
|
609
|
+
The value must be a real executable wrapper path. It is not a shell command string, and there is no companion args environment variable. Put any interpreter, region, profile, or provider-specific settings inside the wrapper. Windows users should point the variable at a wrapper executable or script that can be launched directly by Node without shell parsing.
|
|
610
|
+
|
|
611
|
+
The server invokes the wrapper with:
|
|
612
|
+
|
|
613
|
+
```text
|
|
614
|
+
$MS365_MCP_AUTH_CACHE_COMMAND load token-cache
|
|
615
|
+
$MS365_MCP_AUTH_CACHE_COMMAND save token-cache
|
|
616
|
+
$MS365_MCP_AUTH_CACHE_COMMAND delete token-cache
|
|
617
|
+
$MS365_MCP_AUTH_CACHE_COMMAND load selected-account
|
|
618
|
+
$MS365_MCP_AUTH_CACHE_COMMAND save selected-account
|
|
619
|
+
$MS365_MCP_AUTH_CACHE_COMMAND delete selected-account
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Protocol v1:
|
|
623
|
+
|
|
624
|
+
- `load <key>` reads no stdin. Exit `0` with `{"found":true,"value":"<stored envelope string>"}` when present. A miss is exit `0` with `{"found":false}` or empty stdout.
|
|
625
|
+
- `save <key>` receives `{"value":"<stamped envelope string>"}` on stdin and must exit `0` only after the value is durably committed. There are no fire-and-forget or coalesced saves in v1.
|
|
626
|
+
- `delete <key>` reads no stdin and exits `0` whether the key existed or not.
|
|
627
|
+
- `<key>` is `token-cache` or `selected-account`.
|
|
628
|
+
- Any non-zero exit is a storage error. Do not use exit code `2` for cache misses.
|
|
629
|
+
- Stderr is captured and truncated in sanitized errors. Stdin and stdout payloads are never logged by the server.
|
|
630
|
+
- Token-cache payloads can be large; wrappers should handle at least 256 KB values.
|
|
631
|
+
|
|
632
|
+
Normal stateless HTTP Graph requests do not use local auth-cache storage. In HTTP mode, command storage is skipped at startup and per request unless local auth tools are explicitly enabled or a local account command such as `--login`, `--verify-login`, `--list-accounts`, `--select-account`, or `--logout` is used.
|
|
633
|
+
|
|
566
634
|
## Azure Key Vault Integration
|
|
567
635
|
|
|
568
636
|
For production deployments, you can store secrets in Azure Key Vault instead of environment variables. This is particularly useful for Azure Container Apps with managed identity.
|
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
|
@@ -1,28 +1,19 @@
|
|
|
1
1
|
import { PublicClientApplication } from "@azure/msal-node";
|
|
2
2
|
import logger from "./logger.js";
|
|
3
|
-
import
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { getSecrets } from "./secrets.js";
|
|
7
7
|
import { getCloudEndpoints, getDefaultClientId } from "./cloud-config.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return keytar;
|
|
18
|
-
} catch (error) {
|
|
19
|
-
logger.info("keytar not available, using file-based credential storage");
|
|
20
|
-
keytar = void 0;
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return keytar;
|
|
25
|
-
}
|
|
8
|
+
import {
|
|
9
|
+
createTokenCacheStorage,
|
|
10
|
+
DefaultTokenCacheStorage,
|
|
11
|
+
getSelectedAccountPath,
|
|
12
|
+
getTokenCachePath,
|
|
13
|
+
pickNewest,
|
|
14
|
+
unwrapCache,
|
|
15
|
+
wrapCache
|
|
16
|
+
} from "./token-cache-storage.js";
|
|
26
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
27
18
|
const __dirname = path.dirname(__filename);
|
|
28
19
|
const endpointsData = JSON.parse(
|
|
@@ -31,48 +22,6 @@ const endpointsData = JSON.parse(
|
|
|
31
22
|
const endpoints = {
|
|
32
23
|
default: endpointsData
|
|
33
24
|
};
|
|
34
|
-
const SERVICE_NAME = "ms-365-mcp-server";
|
|
35
|
-
const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
|
|
36
|
-
const SELECTED_ACCOUNT_KEY = "selected-account";
|
|
37
|
-
const FALLBACK_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
-
const DEFAULT_TOKEN_CACHE_PATH = path.join(FALLBACK_DIR, "..", ".token-cache.json");
|
|
39
|
-
const DEFAULT_SELECTED_ACCOUNT_PATH = path.join(FALLBACK_DIR, "..", ".selected-account.json");
|
|
40
|
-
function getTokenCachePath() {
|
|
41
|
-
const envPath = process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim();
|
|
42
|
-
return envPath || DEFAULT_TOKEN_CACHE_PATH;
|
|
43
|
-
}
|
|
44
|
-
function getSelectedAccountPath() {
|
|
45
|
-
const envPath = process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim();
|
|
46
|
-
return envPath || DEFAULT_SELECTED_ACCOUNT_PATH;
|
|
47
|
-
}
|
|
48
|
-
function ensureParentDir(filePath) {
|
|
49
|
-
const dir = path.dirname(filePath);
|
|
50
|
-
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
51
|
-
}
|
|
52
|
-
function wrapCache(data) {
|
|
53
|
-
return JSON.stringify({ _cacheEnvelope: true, data, savedAt: Date.now() });
|
|
54
|
-
}
|
|
55
|
-
function unwrapCache(raw) {
|
|
56
|
-
try {
|
|
57
|
-
const parsed = JSON.parse(raw);
|
|
58
|
-
if (parsed._cacheEnvelope && typeof parsed.data === "string") {
|
|
59
|
-
return { data: parsed.data, savedAt: parsed.savedAt };
|
|
60
|
-
}
|
|
61
|
-
} catch {
|
|
62
|
-
}
|
|
63
|
-
return { data: raw };
|
|
64
|
-
}
|
|
65
|
-
function pickNewest(keytarRaw, fileRaw) {
|
|
66
|
-
if (!keytarRaw && !fileRaw) return void 0;
|
|
67
|
-
if (keytarRaw && !fileRaw) return unwrapCache(keytarRaw).data;
|
|
68
|
-
if (!keytarRaw && fileRaw) return unwrapCache(fileRaw).data;
|
|
69
|
-
const kt = unwrapCache(keytarRaw);
|
|
70
|
-
const file = unwrapCache(fileRaw);
|
|
71
|
-
if (kt.savedAt === void 0 && file.savedAt === void 0) return kt.data;
|
|
72
|
-
if (kt.savedAt !== void 0 && file.savedAt === void 0) return kt.data;
|
|
73
|
-
if (kt.savedAt === void 0 && file.savedAt !== void 0) return file.data;
|
|
74
|
-
return kt.savedAt >= file.savedAt ? kt.data : file.data;
|
|
75
|
-
}
|
|
76
25
|
function createMsalConfig(secrets) {
|
|
77
26
|
const cloudEndpoints = getCloudEndpoints(secrets.cloudType);
|
|
78
27
|
return {
|
|
@@ -124,7 +73,7 @@ function buildScopesFromEndpoints(includeWorkAccountScopes = false, enabledTools
|
|
|
124
73
|
try {
|
|
125
74
|
enabledToolsRegex = new RegExp(enabledToolsPattern, "i");
|
|
126
75
|
logger.info(`Building scopes with tool filter pattern: ${enabledToolsPattern}`);
|
|
127
|
-
} catch
|
|
76
|
+
} catch {
|
|
128
77
|
logger.error(
|
|
129
78
|
`Invalid tool filter regex pattern: ${enabledToolsPattern}. Building scopes without filter.`
|
|
130
79
|
);
|
|
@@ -288,7 +237,7 @@ function buildScopeDiagnostics(toolScopes, allowedScopesInput) {
|
|
|
288
237
|
};
|
|
289
238
|
}
|
|
290
239
|
class AuthManager {
|
|
291
|
-
constructor(config, scopes = []) {
|
|
240
|
+
constructor(config, scopes = [], expectedAccount, storage) {
|
|
292
241
|
logger.info(`And scopes are ${scopes.join(", ")}`, scopes);
|
|
293
242
|
this.config = config;
|
|
294
243
|
this.scopes = scopes;
|
|
@@ -297,6 +246,11 @@ class AuthManager {
|
|
|
297
246
|
this.tokenExpiry = null;
|
|
298
247
|
this.selectedAccountId = null;
|
|
299
248
|
this.useInteractiveAuth = false;
|
|
249
|
+
this.expectedUsername = this.normalizeExpectedUsername(expectedAccount?.expectedUsername);
|
|
250
|
+
this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
|
|
251
|
+
expectedAccount?.expectedHomeAccountId
|
|
252
|
+
);
|
|
253
|
+
this.storage = storage ?? new DefaultTokenCacheStorage();
|
|
300
254
|
const oauthTokenFromEnv = process.env.MS365_MCP_OAUTH_TOKEN;
|
|
301
255
|
this.oauthToken = oauthTokenFromEnv ?? null;
|
|
302
256
|
this.isOAuthMode = oauthTokenFromEnv != null;
|
|
@@ -305,111 +259,174 @@ class AuthManager {
|
|
|
305
259
|
* Creates an AuthManager instance with secrets loaded from the configured provider.
|
|
306
260
|
* Uses Key Vault if MS365_MCP_KEYVAULT_URL is set, otherwise environment variables.
|
|
307
261
|
*/
|
|
308
|
-
static async create(scopes = []) {
|
|
262
|
+
static async create(scopes = [], expectedAccount, options = {}) {
|
|
309
263
|
const secrets = await getSecrets();
|
|
310
264
|
const config = createMsalConfig(secrets);
|
|
311
|
-
|
|
265
|
+
const storage = options.storage ?? await createTokenCacheStorage({ allowCommandStorage: false, logProvider: true });
|
|
266
|
+
return new AuthManager(config, scopes, expectedAccount, storage);
|
|
312
267
|
}
|
|
313
268
|
async loadTokenCache() {
|
|
314
269
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (kt) {
|
|
319
|
-
keytarRaw = await kt.getPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT) ?? void 0;
|
|
320
|
-
}
|
|
321
|
-
} catch (keytarError) {
|
|
322
|
-
logger.warn(`Keychain access failed: ${keytarError.message}`);
|
|
323
|
-
}
|
|
324
|
-
let fileRaw;
|
|
325
|
-
const cachePath = getTokenCachePath();
|
|
326
|
-
if (existsSync(cachePath)) {
|
|
327
|
-
fileRaw = readFileSync(cachePath, "utf8");
|
|
328
|
-
}
|
|
329
|
-
const cacheData = pickNewest(keytarRaw, fileRaw);
|
|
330
|
-
if (cacheData) {
|
|
331
|
-
this.msalApp.getTokenCache().deserialize(cacheData);
|
|
270
|
+
const cacheRaw = await this.storage.load("token-cache");
|
|
271
|
+
if (cacheRaw) {
|
|
272
|
+
this.msalApp.getTokenCache().deserialize(unwrapCache(cacheRaw).data);
|
|
332
273
|
}
|
|
333
274
|
await this.loadSelectedAccount();
|
|
334
275
|
} catch (error) {
|
|
335
276
|
logger.error(`Error loading token cache: ${error.message}`);
|
|
277
|
+
if (this.storage.failClosed) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
336
280
|
}
|
|
337
281
|
}
|
|
338
282
|
async loadSelectedAccount() {
|
|
339
283
|
try {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
if (kt) {
|
|
344
|
-
keytarRaw = await kt.getPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY) ?? void 0;
|
|
345
|
-
}
|
|
346
|
-
} catch (keytarError) {
|
|
347
|
-
logger.warn(
|
|
348
|
-
`Keychain access failed for selected account: ${keytarError.message}`
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
let fileRaw;
|
|
352
|
-
const accountPath = getSelectedAccountPath();
|
|
353
|
-
if (existsSync(accountPath)) {
|
|
354
|
-
fileRaw = readFileSync(accountPath, "utf8");
|
|
355
|
-
}
|
|
356
|
-
const selectedAccountData = pickNewest(keytarRaw, fileRaw);
|
|
357
|
-
if (selectedAccountData) {
|
|
358
|
-
const parsed = JSON.parse(selectedAccountData);
|
|
284
|
+
const selectedAccountRaw = await this.storage.load("selected-account");
|
|
285
|
+
if (selectedAccountRaw) {
|
|
286
|
+
const parsed = JSON.parse(unwrapCache(selectedAccountRaw).data);
|
|
359
287
|
this.selectedAccountId = parsed.accountId;
|
|
360
288
|
logger.info(`Loaded selected account: ${this.selectedAccountId}`);
|
|
361
289
|
}
|
|
362
290
|
} catch (error) {
|
|
363
291
|
logger.error(`Error loading selected account: ${error.message}`);
|
|
292
|
+
if (this.storage.failClosed) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
364
295
|
}
|
|
365
296
|
}
|
|
366
297
|
async saveTokenCache() {
|
|
367
298
|
try {
|
|
368
299
|
const stamped = wrapCache(this.msalApp.getTokenCache().serialize());
|
|
369
|
-
|
|
370
|
-
const kt = await getKeytar();
|
|
371
|
-
if (kt) {
|
|
372
|
-
await kt.setPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT, stamped);
|
|
373
|
-
} else {
|
|
374
|
-
const cachePath = getTokenCachePath();
|
|
375
|
-
ensureParentDir(cachePath);
|
|
376
|
-
fs.writeFileSync(cachePath, stamped, { mode: 384 });
|
|
377
|
-
}
|
|
378
|
-
} catch (keytarError) {
|
|
379
|
-
logger.warn(
|
|
380
|
-
`Keychain save failed, falling back to file storage: ${keytarError.message}`
|
|
381
|
-
);
|
|
382
|
-
const cachePath = getTokenCachePath();
|
|
383
|
-
ensureParentDir(cachePath);
|
|
384
|
-
fs.writeFileSync(cachePath, stamped, { mode: 384 });
|
|
385
|
-
}
|
|
300
|
+
await this.storage.save("token-cache", stamped);
|
|
386
301
|
} catch (error) {
|
|
387
302
|
logger.error(`Error saving token cache: ${error.message}`);
|
|
303
|
+
if (this.storage.failClosed) {
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
388
306
|
}
|
|
389
307
|
}
|
|
390
308
|
async saveSelectedAccount() {
|
|
391
309
|
try {
|
|
392
310
|
const stamped = wrapCache(JSON.stringify({ accountId: this.selectedAccountId }));
|
|
393
|
-
|
|
394
|
-
const kt = await getKeytar();
|
|
395
|
-
if (kt) {
|
|
396
|
-
await kt.setPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY, stamped);
|
|
397
|
-
} else {
|
|
398
|
-
const accountPath = getSelectedAccountPath();
|
|
399
|
-
ensureParentDir(accountPath);
|
|
400
|
-
fs.writeFileSync(accountPath, stamped, { mode: 384 });
|
|
401
|
-
}
|
|
402
|
-
} catch (keytarError) {
|
|
403
|
-
logger.warn(
|
|
404
|
-
`Keychain save failed for selected account, falling back to file storage: ${keytarError.message}`
|
|
405
|
-
);
|
|
406
|
-
const accountPath = getSelectedAccountPath();
|
|
407
|
-
ensureParentDir(accountPath);
|
|
408
|
-
fs.writeFileSync(accountPath, stamped, { mode: 384 });
|
|
409
|
-
}
|
|
311
|
+
await this.storage.save("selected-account", stamped);
|
|
410
312
|
} catch (error) {
|
|
411
313
|
logger.error(`Error saving selected account: ${error.message}`);
|
|
314
|
+
if (this.storage.failClosed) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
normalizeExpectedUsername(value) {
|
|
320
|
+
if (value === void 0) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const trimmed = value.trim();
|
|
324
|
+
if (trimmed === "") {
|
|
325
|
+
throw new Error("Expected Microsoft account username was provided but is empty.");
|
|
326
|
+
}
|
|
327
|
+
return trimmed.toLowerCase();
|
|
328
|
+
}
|
|
329
|
+
normalizeExpectedHomeAccountId(value) {
|
|
330
|
+
if (value === void 0) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const trimmed = value.trim();
|
|
334
|
+
if (trimmed === "") {
|
|
335
|
+
throw new Error("Expected Microsoft account homeAccountId was provided but is empty.");
|
|
336
|
+
}
|
|
337
|
+
return trimmed;
|
|
338
|
+
}
|
|
339
|
+
hasExpectedAccount() {
|
|
340
|
+
return this.expectedUsername !== null || this.expectedHomeAccountId !== null;
|
|
341
|
+
}
|
|
342
|
+
expectedAccountLabel() {
|
|
343
|
+
const parts = [];
|
|
344
|
+
if (this.expectedUsername) {
|
|
345
|
+
parts.push(`username ${this.expectedUsername}`);
|
|
346
|
+
}
|
|
347
|
+
if (this.expectedHomeAccountId) {
|
|
348
|
+
parts.push(`homeAccountId ${this.expectedHomeAccountId}`);
|
|
349
|
+
}
|
|
350
|
+
return parts.join(" and ");
|
|
351
|
+
}
|
|
352
|
+
describeAccount(account) {
|
|
353
|
+
return account?.username || account?.name || "unknown";
|
|
354
|
+
}
|
|
355
|
+
describeCachedAccounts(accounts) {
|
|
356
|
+
if (accounts.length === 0) {
|
|
357
|
+
return "none";
|
|
358
|
+
}
|
|
359
|
+
return accounts.map((account) => this.describeAccount(account)).join(", ");
|
|
360
|
+
}
|
|
361
|
+
accountMatchesExpected(account) {
|
|
362
|
+
if (!this.hasExpectedAccount() || !account) {
|
|
363
|
+
return !this.hasExpectedAccount();
|
|
364
|
+
}
|
|
365
|
+
if (this.expectedUsername && account.username?.toLowerCase() !== this.expectedUsername) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (this.expectedHomeAccountId && account.homeAccountId !== this.expectedHomeAccountId) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
buildExpectedAccountMissingError(accounts) {
|
|
374
|
+
return new Error(
|
|
375
|
+
`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.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
resolveExpectedAccountFromAccounts(accounts) {
|
|
379
|
+
if (!this.hasExpectedAccount()) {
|
|
380
|
+
throw new Error("No expected Microsoft account is configured.");
|
|
381
|
+
}
|
|
382
|
+
const usernameMatch = this.expectedUsername ? accounts.find((account) => account.username?.toLowerCase() === this.expectedUsername) : void 0;
|
|
383
|
+
const homeAccountIdMatch = this.expectedHomeAccountId ? accounts.find((account) => account.homeAccountId === this.expectedHomeAccountId) : void 0;
|
|
384
|
+
if (this.expectedUsername && this.expectedHomeAccountId) {
|
|
385
|
+
if (!usernameMatch || !homeAccountIdMatch) {
|
|
386
|
+
throw this.buildExpectedAccountMissingError(accounts);
|
|
387
|
+
}
|
|
388
|
+
if (usernameMatch.homeAccountId !== homeAccountIdMatch.homeAccountId) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`Expected Microsoft account pins conflict: username ${this.expectedUsername} matched ${this.describeAccount(usernameMatch)}, but homeAccountId ${this.expectedHomeAccountId} matched ${this.describeAccount(homeAccountIdMatch)}.`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return usernameMatch;
|
|
394
|
+
}
|
|
395
|
+
const expectedAccount = usernameMatch ?? homeAccountIdMatch;
|
|
396
|
+
if (!expectedAccount) {
|
|
397
|
+
throw this.buildExpectedAccountMissingError(accounts);
|
|
398
|
+
}
|
|
399
|
+
return expectedAccount;
|
|
400
|
+
}
|
|
401
|
+
async assertExpectedAccountAvailable() {
|
|
402
|
+
if (!this.hasExpectedAccount()) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
|
|
406
|
+
this.resolveExpectedAccountFromAccounts(accounts);
|
|
407
|
+
}
|
|
408
|
+
async rejectUnexpectedLoginAccount(account) {
|
|
409
|
+
if (!this.hasExpectedAccount()) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (this.accountMatchesExpected(account)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
this.accessToken = null;
|
|
416
|
+
this.tokenExpiry = null;
|
|
417
|
+
if (account) {
|
|
418
|
+
try {
|
|
419
|
+
await this.msalApp.getTokenCache().removeAccount(account);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
logger.warn(`Failed to remove unexpected account from cache: ${error.message}`);
|
|
422
|
+
}
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Authenticated Microsoft account '${this.describeAccount(account)}' does not match expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
|
|
425
|
+
);
|
|
412
426
|
}
|
|
427
|
+
throw new Error(
|
|
428
|
+
`Microsoft login did not return an account. Expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
|
|
429
|
+
);
|
|
413
430
|
}
|
|
414
431
|
async setOAuthToken(token) {
|
|
415
432
|
this.oauthToken = token;
|
|
@@ -443,6 +460,9 @@ class AuthManager {
|
|
|
443
460
|
}
|
|
444
461
|
async getCurrentAccount() {
|
|
445
462
|
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
|
|
463
|
+
if (this.hasExpectedAccount()) {
|
|
464
|
+
return this.resolveExpectedAccountFromAccounts(accounts);
|
|
465
|
+
}
|
|
446
466
|
if (accounts.length === 0) {
|
|
447
467
|
return null;
|
|
448
468
|
}
|
|
@@ -480,6 +500,7 @@ class AuthManager {
|
|
|
480
500
|
logger.info("Device code login successful");
|
|
481
501
|
this.accessToken = response?.accessToken || null;
|
|
482
502
|
this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
|
|
503
|
+
await this.rejectUnexpectedLoginAccount(response?.account);
|
|
483
504
|
if (!this.selectedAccountId && response?.account) {
|
|
484
505
|
this.selectedAccountId = response.account.homeAccountId;
|
|
485
506
|
await this.saveSelectedAccount();
|
|
@@ -521,6 +542,7 @@ class AuthManager {
|
|
|
521
542
|
logger.info("Interactive browser login successful");
|
|
522
543
|
this.accessToken = response?.accessToken || null;
|
|
523
544
|
this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
|
|
545
|
+
await this.rejectUnexpectedLoginAccount(response?.account);
|
|
524
546
|
if (!this.selectedAccountId && response?.account) {
|
|
525
547
|
this.selectedAccountId = response.account.homeAccountId;
|
|
526
548
|
await this.saveSelectedAccount();
|
|
@@ -596,23 +618,8 @@ class AuthManager {
|
|
|
596
618
|
this.accessToken = null;
|
|
597
619
|
this.tokenExpiry = null;
|
|
598
620
|
this.selectedAccountId = null;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (kt) {
|
|
602
|
-
await kt.deletePassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
|
|
603
|
-
await kt.deletePassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY);
|
|
604
|
-
}
|
|
605
|
-
} catch (keytarError) {
|
|
606
|
-
logger.warn(`Keychain deletion failed: ${keytarError.message}`);
|
|
607
|
-
}
|
|
608
|
-
const cachePath = getTokenCachePath();
|
|
609
|
-
if (fs.existsSync(cachePath)) {
|
|
610
|
-
fs.unlinkSync(cachePath);
|
|
611
|
-
}
|
|
612
|
-
const accountPath = getSelectedAccountPath();
|
|
613
|
-
if (fs.existsSync(accountPath)) {
|
|
614
|
-
fs.unlinkSync(accountPath);
|
|
615
|
-
}
|
|
621
|
+
await this.storage.delete("token-cache");
|
|
622
|
+
await this.storage.delete("selected-account");
|
|
616
623
|
return true;
|
|
617
624
|
} catch (error) {
|
|
618
625
|
logger.error(`Error during logout: ${error.message}`);
|
|
@@ -625,6 +632,11 @@ class AuthManager {
|
|
|
625
632
|
}
|
|
626
633
|
async selectAccount(identifier) {
|
|
627
634
|
const account = await this.resolveAccount(identifier);
|
|
635
|
+
if (this.hasExpectedAccount() && !this.accountMatchesExpected(account)) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`Account '${identifier}' does not match expected Microsoft account '${this.expectedAccountLabel()}'.`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
628
640
|
this.selectedAccountId = account.homeAccountId;
|
|
629
641
|
await this.saveSelectedAccount();
|
|
630
642
|
this.accessToken = null;
|
|
@@ -686,6 +698,9 @@ class AuthManager {
|
|
|
686
698
|
* Used to decide whether to inject the `account` parameter into tool schemas.
|
|
687
699
|
*/
|
|
688
700
|
async isMultiAccount() {
|
|
701
|
+
if (this.hasExpectedAccount()) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
689
704
|
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
|
|
690
705
|
return accounts.length > 1;
|
|
691
706
|
}
|
|
@@ -706,7 +721,18 @@ class AuthManager {
|
|
|
706
721
|
return this.oauthToken;
|
|
707
722
|
}
|
|
708
723
|
let targetAccount = null;
|
|
709
|
-
if (
|
|
724
|
+
if (this.hasExpectedAccount()) {
|
|
725
|
+
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
|
|
726
|
+
targetAccount = this.resolveExpectedAccountFromAccounts(accounts);
|
|
727
|
+
if (identifier) {
|
|
728
|
+
const requestedAccount = await this.resolveAccount(identifier);
|
|
729
|
+
if (requestedAccount.homeAccountId !== targetAccount.homeAccountId) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`Account '${identifier}' does not match expected Microsoft account '${this.expectedAccountLabel()}'.`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
} else if (identifier) {
|
|
710
736
|
targetAccount = await this.resolveAccount(identifier);
|
|
711
737
|
} else {
|
|
712
738
|
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,13 @@ const packageJsonPath = path.join(__dirname, "..", "package.json");
|
|
|
8
8
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
9
9
|
const version = packageJson.version;
|
|
10
10
|
const program = new Command();
|
|
11
|
-
program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").version(version).option("-v", "Enable verbose logging").option("--login", "Login to Microsoft account").option("--logout", "Log out and clear saved credentials").option("--verify-login", "Verify login without starting the server").option("--list-accounts", "List all cached accounts").option("--select-account <accountId>", "Select a specific account by ID").option("--remove-account <accountId>", "Remove a specific account by ID").option(
|
|
11
|
+
program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").version(version).option("-v", "Enable verbose logging").option("--login", "Login to Microsoft account").option("--logout", "Log out and clear saved credentials").option("--verify-login", "Verify login without starting the server").option("--list-accounts", "List all cached accounts").option("--select-account <accountId>", "Select a specific account by ID").option("--remove-account <accountId>", "Remove a specific account by ID").option(
|
|
12
|
+
"--expected-username <username>",
|
|
13
|
+
"Require local MSAL authentication to use this Microsoft account username"
|
|
14
|
+
).option(
|
|
15
|
+
"--expected-home-account-id <id>",
|
|
16
|
+
"Require local MSAL authentication to use this exact MSAL homeAccountId"
|
|
17
|
+
).option("--read-only", "Start server in read-only mode, disabling write operations").option(
|
|
12
18
|
"--http [address]",
|
|
13
19
|
'Use Streamable HTTP transport instead of stdio. Format: [host:]port (e.g., "localhost:3000", ":3000", "3000"). Default: all interfaces on port 3000'
|
|
14
20
|
).option(
|
|
@@ -85,6 +91,32 @@ function parseArgs() {
|
|
|
85
91
|
);
|
|
86
92
|
process.exit(1);
|
|
87
93
|
}
|
|
94
|
+
if (options.expectedUsername === void 0 && process.env.MS365_MCP_EXPECTED_USERNAME !== void 0) {
|
|
95
|
+
options.expectedUsername = process.env.MS365_MCP_EXPECTED_USERNAME;
|
|
96
|
+
}
|
|
97
|
+
if (options.expectedHomeAccountId === void 0 && process.env.MS365_MCP_EXPECTED_HOME_ACCOUNT_ID !== void 0) {
|
|
98
|
+
options.expectedHomeAccountId = process.env.MS365_MCP_EXPECTED_HOME_ACCOUNT_ID;
|
|
99
|
+
}
|
|
100
|
+
if (options.expectedUsername !== void 0) {
|
|
101
|
+
const expectedUsername = String(options.expectedUsername).trim();
|
|
102
|
+
if (expectedUsername === "") {
|
|
103
|
+
console.error(
|
|
104
|
+
"Error: --expected-username / MS365_MCP_EXPECTED_USERNAME was provided but is empty. Provide a Microsoft account username, or omit it to allow any cached account."
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
options.expectedUsername = expectedUsername;
|
|
109
|
+
}
|
|
110
|
+
if (options.expectedHomeAccountId !== void 0) {
|
|
111
|
+
const expectedHomeAccountId = String(options.expectedHomeAccountId).trim();
|
|
112
|
+
if (expectedHomeAccountId === "") {
|
|
113
|
+
console.error(
|
|
114
|
+
"Error: --expected-home-account-id / MS365_MCP_EXPECTED_HOME_ACCOUNT_ID was provided but is empty. Provide an MSAL homeAccountId, or omit it to allow any cached account."
|
|
115
|
+
);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
options.expectedHomeAccountId = expectedHomeAccountId;
|
|
119
|
+
}
|
|
88
120
|
if (options.enabledTools) {
|
|
89
121
|
try {
|
|
90
122
|
new RegExp(options.enabledTools, "i");
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,12 @@ import { parseArgs } from "./cli.js";
|
|
|
4
4
|
import logger from "./logger.js";
|
|
5
5
|
import AuthManager, { buildAllowedScopeDiagnostics, resolveAuthScopes } from "./auth.js";
|
|
6
6
|
import MicrosoftGraphServer from "./server.js";
|
|
7
|
+
import {
|
|
8
|
+
getExpectedAccountInertWarning,
|
|
9
|
+
shouldAssertExpectedAccountAtStartup,
|
|
10
|
+
shouldUseLocalAuthStorage
|
|
11
|
+
} from "./startup-pinning.js";
|
|
12
|
+
import { createTokenCacheStorage } from "./token-cache-storage.js";
|
|
7
13
|
import { version } from "./version.js";
|
|
8
14
|
async function main() {
|
|
9
15
|
try {
|
|
@@ -37,12 +43,31 @@ async function main() {
|
|
|
37
43
|
);
|
|
38
44
|
process.exit(0);
|
|
39
45
|
}
|
|
40
|
-
const
|
|
41
|
-
await
|
|
46
|
+
const useLocalAuthStorage = shouldUseLocalAuthStorage(args);
|
|
47
|
+
const storage = await createTokenCacheStorage({
|
|
48
|
+
allowCommandStorage: useLocalAuthStorage,
|
|
49
|
+
logProvider: useLocalAuthStorage
|
|
50
|
+
});
|
|
51
|
+
const authManager = await AuthManager.create(
|
|
52
|
+
effectiveScopes,
|
|
53
|
+
{
|
|
54
|
+
expectedUsername: args.expectedUsername,
|
|
55
|
+
expectedHomeAccountId: args.expectedHomeAccountId
|
|
56
|
+
},
|
|
57
|
+
{ storage }
|
|
58
|
+
);
|
|
59
|
+
if (useLocalAuthStorage) {
|
|
60
|
+
await authManager.loadTokenCache();
|
|
61
|
+
}
|
|
42
62
|
if (args.authBrowser) {
|
|
43
63
|
authManager.setUseInteractiveAuth(true);
|
|
44
64
|
logger.info("Browser-based interactive auth enabled");
|
|
45
65
|
}
|
|
66
|
+
const expectedAccountWarning = getExpectedAccountInertWarning(args, authManager);
|
|
67
|
+
if (expectedAccountWarning) {
|
|
68
|
+
logger.warn(expectedAccountWarning);
|
|
69
|
+
console.error(expectedAccountWarning);
|
|
70
|
+
}
|
|
46
71
|
if (args.login) {
|
|
47
72
|
if (args.authBrowser) {
|
|
48
73
|
await authManager.acquireTokenInteractive();
|
|
@@ -97,6 +122,9 @@ async function main() {
|
|
|
97
122
|
}
|
|
98
123
|
process.exit(0);
|
|
99
124
|
}
|
|
125
|
+
if (shouldAssertExpectedAccountAtStartup(args, authManager)) {
|
|
126
|
+
await authManager.assertExpectedAccountAvailable();
|
|
127
|
+
}
|
|
100
128
|
const server = new MicrosoftGraphServer(authManager, args);
|
|
101
129
|
await server.initialize(version);
|
|
102
130
|
await server.start();
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
function shouldUseLocalAuthStorage(args) {
|
|
38
|
+
if (!args.http) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (args.enableAuthTools) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return LOCAL_ACCOUNT_COMMANDS.some((key) => Boolean(args[key]));
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
getExpectedAccountInertWarning,
|
|
48
|
+
shouldAssertExpectedAccountAtStartup,
|
|
49
|
+
shouldUseLocalAuthStorage
|
|
50
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs, { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import logger from "./logger.js";
|
|
6
|
+
const SERVICE_NAME = "ms-365-mcp-server";
|
|
7
|
+
const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
|
|
8
|
+
const SELECTED_ACCOUNT_KEY = "selected-account";
|
|
9
|
+
const AUTH_CACHE_COMMAND_ENV = "MS365_MCP_AUTH_CACHE_COMMAND";
|
|
10
|
+
const AUTH_CACHE_COMMAND_TIMEOUT_ENV = "MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS";
|
|
11
|
+
const DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS = 1e4;
|
|
12
|
+
const STDERR_LIMIT = 2048;
|
|
13
|
+
const COMMAND_KILL_GRACE_MS = 1e3;
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const FALLBACK_DIR = __dirname;
|
|
17
|
+
const DEFAULT_TOKEN_CACHE_PATH = path.join(FALLBACK_DIR, "..", ".token-cache.json");
|
|
18
|
+
const DEFAULT_SELECTED_ACCOUNT_PATH = path.join(FALLBACK_DIR, "..", ".selected-account.json");
|
|
19
|
+
let keytar = null;
|
|
20
|
+
async function getKeytar() {
|
|
21
|
+
if (keytar === void 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (keytar === null) {
|
|
25
|
+
try {
|
|
26
|
+
const mod = await import("keytar");
|
|
27
|
+
keytar = mod.default ?? mod;
|
|
28
|
+
return keytar;
|
|
29
|
+
} catch {
|
|
30
|
+
logger.info("keytar not available, using file-based credential storage");
|
|
31
|
+
keytar = void 0;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return keytar;
|
|
36
|
+
}
|
|
37
|
+
function wrapCache(data) {
|
|
38
|
+
return JSON.stringify({ _cacheEnvelope: true, data, savedAt: Date.now() });
|
|
39
|
+
}
|
|
40
|
+
function unwrapCache(raw) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (parsed._cacheEnvelope && typeof parsed.data === "string") {
|
|
44
|
+
return { data: parsed.data, savedAt: parsed.savedAt };
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
return { data: raw };
|
|
49
|
+
}
|
|
50
|
+
function pickNewest(keytarRaw, fileRaw) {
|
|
51
|
+
const newest = pickNewestRaw(keytarRaw, fileRaw);
|
|
52
|
+
return newest ? unwrapCache(newest).data : void 0;
|
|
53
|
+
}
|
|
54
|
+
function pickNewestRaw(keytarRaw, fileRaw) {
|
|
55
|
+
if (!keytarRaw && !fileRaw) return void 0;
|
|
56
|
+
if (keytarRaw && !fileRaw) return keytarRaw;
|
|
57
|
+
if (!keytarRaw && fileRaw) return fileRaw;
|
|
58
|
+
const kt = unwrapCache(keytarRaw);
|
|
59
|
+
const file = unwrapCache(fileRaw);
|
|
60
|
+
if (kt.savedAt === void 0 && file.savedAt === void 0) return keytarRaw;
|
|
61
|
+
if (kt.savedAt !== void 0 && file.savedAt === void 0) return keytarRaw;
|
|
62
|
+
if (kt.savedAt === void 0 && file.savedAt !== void 0) return fileRaw;
|
|
63
|
+
return kt.savedAt >= file.savedAt ? keytarRaw : fileRaw;
|
|
64
|
+
}
|
|
65
|
+
function getTokenCachePath() {
|
|
66
|
+
const envPath = process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim();
|
|
67
|
+
return envPath || DEFAULT_TOKEN_CACHE_PATH;
|
|
68
|
+
}
|
|
69
|
+
function getSelectedAccountPath() {
|
|
70
|
+
const envPath = process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim();
|
|
71
|
+
return envPath || DEFAULT_SELECTED_ACCOUNT_PATH;
|
|
72
|
+
}
|
|
73
|
+
function storageAccountForKey(key) {
|
|
74
|
+
assertValidKey(key);
|
|
75
|
+
return key === "token-cache" ? TOKEN_CACHE_ACCOUNT : SELECTED_ACCOUNT_KEY;
|
|
76
|
+
}
|
|
77
|
+
function filePathForKey(key) {
|
|
78
|
+
assertValidKey(key);
|
|
79
|
+
return key === "token-cache" ? getTokenCachePath() : getSelectedAccountPath();
|
|
80
|
+
}
|
|
81
|
+
function assertValidKey(key) {
|
|
82
|
+
if (key !== "token-cache" && key !== "selected-account") {
|
|
83
|
+
throw new Error(`Unknown auth cache storage key: ${String(key)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function ensureParentDir(filePath) {
|
|
87
|
+
const dir = path.dirname(filePath);
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
89
|
+
}
|
|
90
|
+
function writeFileAtomically(filePath, value) {
|
|
91
|
+
ensureParentDir(filePath);
|
|
92
|
+
const tempPath = path.join(
|
|
93
|
+
path.dirname(filePath),
|
|
94
|
+
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`
|
|
95
|
+
);
|
|
96
|
+
fs.writeFileSync(tempPath, value, { mode: 384 });
|
|
97
|
+
fs.renameSync(tempPath, filePath);
|
|
98
|
+
}
|
|
99
|
+
class DefaultTokenCacheStorage {
|
|
100
|
+
constructor() {
|
|
101
|
+
this.description = "default (keytar+file)";
|
|
102
|
+
this.failClosed = false;
|
|
103
|
+
}
|
|
104
|
+
async load(key) {
|
|
105
|
+
assertValidKey(key);
|
|
106
|
+
let keytarRaw;
|
|
107
|
+
try {
|
|
108
|
+
const kt = await getKeytar();
|
|
109
|
+
if (kt) {
|
|
110
|
+
keytarRaw = await kt.getPassword(SERVICE_NAME, storageAccountForKey(key)) ?? void 0;
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.warn(`Keychain access failed for ${key}: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
let fileRaw;
|
|
116
|
+
const cachePath = filePathForKey(key);
|
|
117
|
+
if (existsSync(cachePath)) {
|
|
118
|
+
fileRaw = readFileSync(cachePath, "utf8");
|
|
119
|
+
}
|
|
120
|
+
return pickNewestRaw(keytarRaw, fileRaw);
|
|
121
|
+
}
|
|
122
|
+
async save(key, value) {
|
|
123
|
+
assertValidKey(key);
|
|
124
|
+
try {
|
|
125
|
+
const kt = await getKeytar();
|
|
126
|
+
if (kt) {
|
|
127
|
+
await kt.setPassword(SERVICE_NAME, storageAccountForKey(key), value);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.warn(
|
|
132
|
+
`Keychain save failed for ${key}, falling back to file storage: ${error.message}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
writeFileAtomically(filePathForKey(key), value);
|
|
136
|
+
}
|
|
137
|
+
async delete(key) {
|
|
138
|
+
assertValidKey(key);
|
|
139
|
+
try {
|
|
140
|
+
const kt = await getKeytar();
|
|
141
|
+
if (kt) {
|
|
142
|
+
await kt.deletePassword(SERVICE_NAME, storageAccountForKey(key));
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.warn(`Keychain deletion failed for ${key}: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
const cachePath = filePathForKey(key);
|
|
148
|
+
try {
|
|
149
|
+
if (fs.existsSync(cachePath)) {
|
|
150
|
+
fs.unlinkSync(cachePath);
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
logger.warn(`File deletion failed for ${key}: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
class CommandTokenCacheStorage {
|
|
158
|
+
constructor(commandPath, timeoutMs = DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS, spawnCommand = spawn) {
|
|
159
|
+
this.commandPath = commandPath;
|
|
160
|
+
this.timeoutMs = timeoutMs;
|
|
161
|
+
this.spawnCommand = spawnCommand;
|
|
162
|
+
this.failClosed = true;
|
|
163
|
+
this.description = `command (${path.basename(commandPath)})`;
|
|
164
|
+
}
|
|
165
|
+
async load(key) {
|
|
166
|
+
assertValidKey(key);
|
|
167
|
+
const result = await this.invoke("load", key);
|
|
168
|
+
const trimmed = result.stdout.trim();
|
|
169
|
+
if (trimmed === "") {
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(trimmed);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`Auth cache command returned invalid JSON for load ${key}.`);
|
|
177
|
+
}
|
|
178
|
+
if (!parsed || typeof parsed !== "object") {
|
|
179
|
+
throw new Error(`Auth cache command returned invalid JSON shape for load ${key}.`);
|
|
180
|
+
}
|
|
181
|
+
const response = parsed;
|
|
182
|
+
if (response.found === false) {
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
if (response.found === true && typeof response.value === "string") {
|
|
186
|
+
return response.value;
|
|
187
|
+
}
|
|
188
|
+
throw new Error(`Auth cache command returned invalid load response for ${key}.`);
|
|
189
|
+
}
|
|
190
|
+
async save(key, value) {
|
|
191
|
+
assertValidKey(key);
|
|
192
|
+
await this.invoke("save", key, JSON.stringify({ value }));
|
|
193
|
+
}
|
|
194
|
+
async delete(key) {
|
|
195
|
+
assertValidKey(key);
|
|
196
|
+
await this.invoke("delete", key);
|
|
197
|
+
}
|
|
198
|
+
async invoke(operation, key, stdinPayload) {
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = await runCommand(
|
|
202
|
+
this.commandPath,
|
|
203
|
+
[operation, key],
|
|
204
|
+
stdinPayload,
|
|
205
|
+
this.timeoutMs,
|
|
206
|
+
this.spawnCommand
|
|
207
|
+
);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Auth cache command failed for ${operation} ${key}: ${error.message}`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (result.timedOut) {
|
|
214
|
+
throw new Error(`Auth cache command timed out for ${operation} ${key}.`);
|
|
215
|
+
}
|
|
216
|
+
if (result.exitCode !== 0) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Auth cache command failed for ${operation} ${key} (exit ${result.exitCode ?? `signal ${result.signal ?? "unknown"}`})${formatStderr(
|
|
219
|
+
result.stderr
|
|
220
|
+
)}.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function formatStderr(stderr) {
|
|
227
|
+
const trimmed = stderr.trim();
|
|
228
|
+
if (!trimmed) {
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
const truncated = trimmed.length > STDERR_LIMIT ? `${trimmed.slice(0, STDERR_LIMIT)}...` : trimmed;
|
|
232
|
+
return `: ${truncated}`;
|
|
233
|
+
}
|
|
234
|
+
function runCommand(commandPath, args, stdinPayload, timeoutMs, spawnCommand) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let child;
|
|
237
|
+
try {
|
|
238
|
+
child = spawnCommand(commandPath, args, { stdio: "pipe", shell: false });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
reject(new Error(`could not be started: ${error.message}`));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
let stdout = "";
|
|
244
|
+
let stderr = "";
|
|
245
|
+
let timedOut = false;
|
|
246
|
+
let killTimer;
|
|
247
|
+
const timeout = setTimeout(() => {
|
|
248
|
+
timedOut = true;
|
|
249
|
+
child.kill("SIGTERM");
|
|
250
|
+
killTimer = setTimeout(() => {
|
|
251
|
+
child.kill("SIGKILL");
|
|
252
|
+
}, COMMAND_KILL_GRACE_MS);
|
|
253
|
+
}, timeoutMs);
|
|
254
|
+
child.stdout.setEncoding("utf8");
|
|
255
|
+
child.stderr.setEncoding("utf8");
|
|
256
|
+
child.stdout.on("data", (chunk) => {
|
|
257
|
+
stdout += chunk;
|
|
258
|
+
});
|
|
259
|
+
child.stderr.on("data", (chunk) => {
|
|
260
|
+
stderr += chunk;
|
|
261
|
+
});
|
|
262
|
+
child.stdin.on("error", () => {
|
|
263
|
+
});
|
|
264
|
+
child.once("error", (error) => {
|
|
265
|
+
clearTimeout(timeout);
|
|
266
|
+
if (killTimer) clearTimeout(killTimer);
|
|
267
|
+
reject(new Error(`could not be started: ${error.message}`));
|
|
268
|
+
});
|
|
269
|
+
child.once("close", (exitCode, signal) => {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
if (killTimer) clearTimeout(killTimer);
|
|
272
|
+
resolve({ exitCode, signal, stdout, stderr, timedOut });
|
|
273
|
+
});
|
|
274
|
+
if (stdinPayload !== void 0) {
|
|
275
|
+
child.stdin.end(stdinPayload, "utf8");
|
|
276
|
+
} else {
|
|
277
|
+
child.stdin.end();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function parseTimeoutMs(value) {
|
|
282
|
+
if (value === void 0 || value.trim() === "") {
|
|
283
|
+
return DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS;
|
|
284
|
+
}
|
|
285
|
+
const parsed = Number(value);
|
|
286
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
287
|
+
throw new Error(`${AUTH_CACHE_COMMAND_TIMEOUT_ENV} must be a positive integer.`);
|
|
288
|
+
}
|
|
289
|
+
return parsed;
|
|
290
|
+
}
|
|
291
|
+
async function assertCommandUsable(commandPath) {
|
|
292
|
+
let stats;
|
|
293
|
+
try {
|
|
294
|
+
stats = await fs.promises.stat(commandPath);
|
|
295
|
+
} catch {
|
|
296
|
+
throw new Error(`${AUTH_CACHE_COMMAND_ENV} points to a path that does not exist.`);
|
|
297
|
+
}
|
|
298
|
+
if (!stats.isFile()) {
|
|
299
|
+
throw new Error(`${AUTH_CACHE_COMMAND_ENV} must point to an executable file.`);
|
|
300
|
+
}
|
|
301
|
+
if (process.platform !== "win32" && (stats.mode & 73) === 0) {
|
|
302
|
+
throw new Error(`${AUTH_CACHE_COMMAND_ENV} must point to an executable file.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function createTokenCacheStorage(options = {}) {
|
|
306
|
+
const allowCommandStorage = options.allowCommandStorage ?? true;
|
|
307
|
+
const configuredCommand = process.env[AUTH_CACHE_COMMAND_ENV];
|
|
308
|
+
let storage;
|
|
309
|
+
if (allowCommandStorage && configuredCommand !== void 0) {
|
|
310
|
+
const commandPath = configuredCommand.trim();
|
|
311
|
+
if (commandPath === "") {
|
|
312
|
+
throw new Error(`${AUTH_CACHE_COMMAND_ENV} was provided but is empty.`);
|
|
313
|
+
}
|
|
314
|
+
await assertCommandUsable(commandPath);
|
|
315
|
+
storage = new CommandTokenCacheStorage(
|
|
316
|
+
commandPath,
|
|
317
|
+
parseTimeoutMs(process.env[AUTH_CACHE_COMMAND_TIMEOUT_ENV])
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
storage = new DefaultTokenCacheStorage();
|
|
321
|
+
}
|
|
322
|
+
if (options.logProvider) {
|
|
323
|
+
logger.info(`Auth cache storage provider: ${storage.description}`);
|
|
324
|
+
}
|
|
325
|
+
return storage;
|
|
326
|
+
}
|
|
327
|
+
export {
|
|
328
|
+
CommandTokenCacheStorage,
|
|
329
|
+
DefaultTokenCacheStorage,
|
|
330
|
+
createTokenCacheStorage,
|
|
331
|
+
getSelectedAccountPath,
|
|
332
|
+
getTokenCachePath,
|
|
333
|
+
pickNewest,
|
|
334
|
+
unwrapCache,
|
|
335
|
+
wrapCache
|
|
336
|
+
};
|
package/docs/deployment.md
CHANGED
|
@@ -18,6 +18,14 @@ MCP Clients (Claude Desktop, Claude Code, Open WebUI, ...)
|
|
|
18
18
|
Microsoft Graph API
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Headless stdio auth-cache storage
|
|
22
|
+
|
|
23
|
+
Production HTTP deployments are stateless: normal Graph requests carry a per-user bearer token, including On-Behalf-Of (`--obo`) deployments, and the server does not store MSAL token state for those requests.
|
|
24
|
+
|
|
25
|
+
For headless stdio deployments that use local MSAL login (`--login`, `--verify-login`, auth tools, account selection, and regular stdio Graph calls), `MS365_MCP_AUTH_CACHE_COMMAND` can point at an external executable wrapper that stores the MSAL token cache and selected-account metadata in a deployment-approved backing store. The package only defines the provider-neutral command protocol; provider-specific scripts for AWS, Azure, GCP, Redis, databases, or other stores live outside this package.
|
|
26
|
+
|
|
27
|
+
In HTTP mode, `MS365_MCP_AUTH_CACHE_COMMAND` is skipped at startup and per Graph request unless local auth tools are explicitly enabled with `--enable-auth-tools` or a local account command such as `--login`, `--verify-login`, `--list-accounts`, `--select-account`, `--remove-account`, or `--logout` is invoked.
|
|
28
|
+
|
|
21
29
|
## Docker
|
|
22
30
|
|
|
23
31
|
A `Dockerfile` is included for containerized deployments:
|
|
@@ -194,6 +202,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
194
202
|
## Security Considerations
|
|
195
203
|
|
|
196
204
|
- **Stateless**: the server does not store tokens — each request carries the user's Bearer token
|
|
205
|
+
- **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
206
|
- **Admin consent**: grant tenant-wide consent to avoid per-user consent prompts
|
|
198
207
|
- **Managed identity**: use managed identity for Key Vault access (no secrets in environment variables)
|
|
199
208
|
- **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.111.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",
|