@softeria/ms-365-mcp-server 0.110.0 → 0.112.1
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 +38 -0
- package/dist/auth.js +38 -152
- package/dist/cli.js +6 -0
- package/dist/generated/client.js +10 -12
- package/dist/index.js +18 -5
- package/dist/lib/microsoft-auth.js +73 -8
- package/dist/server.js +25 -8
- package/dist/startup-pinning.js +11 -1
- package/dist/token-cache-storage.js +336 -0
- package/docs/deployment.md +8 -0
- package/package.json +3 -3
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
|
@@ -571,6 +571,8 @@ Environment variables:
|
|
|
571
571
|
- `MS365_MCP_KEYVAULT_URL`: Azure Key Vault URL for secrets management (see Azure Key Vault section)
|
|
572
572
|
- `MS365_MCP_TOKEN_CACHE_PATH`: Custom file path for MSAL token cache (see Token Storage below)
|
|
573
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`)
|
|
574
576
|
- `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
|
|
575
577
|
- `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
|
|
576
578
|
|
|
@@ -593,6 +595,42 @@ Parent directories are created automatically. Files are written with `0600` perm
|
|
|
593
595
|
|
|
594
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.
|
|
595
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
|
+
|
|
596
634
|
## Azure Key Vault Integration
|
|
597
635
|
|
|
598
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.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 = [], expectedAccount) {
|
|
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;
|
|
@@ -301,6 +250,7 @@ class AuthManager {
|
|
|
301
250
|
this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
|
|
302
251
|
expectedAccount?.expectedHomeAccountId
|
|
303
252
|
);
|
|
253
|
+
this.storage = storage ?? new DefaultTokenCacheStorage();
|
|
304
254
|
const oauthTokenFromEnv = process.env.MS365_MCP_OAUTH_TOKEN;
|
|
305
255
|
this.oauthToken = oauthTokenFromEnv ?? null;
|
|
306
256
|
this.isOAuthMode = oauthTokenFromEnv != null;
|
|
@@ -309,110 +259,61 @@ class AuthManager {
|
|
|
309
259
|
* Creates an AuthManager instance with secrets loaded from the configured provider.
|
|
310
260
|
* Uses Key Vault if MS365_MCP_KEYVAULT_URL is set, otherwise environment variables.
|
|
311
261
|
*/
|
|
312
|
-
static async create(scopes = [], expectedAccount) {
|
|
262
|
+
static async create(scopes = [], expectedAccount, options = {}) {
|
|
313
263
|
const secrets = await getSecrets();
|
|
314
264
|
const config = createMsalConfig(secrets);
|
|
315
|
-
|
|
265
|
+
const storage = options.storage ?? await createTokenCacheStorage({ allowCommandStorage: false, logProvider: true });
|
|
266
|
+
return new AuthManager(config, scopes, expectedAccount, storage);
|
|
316
267
|
}
|
|
317
268
|
async loadTokenCache() {
|
|
318
269
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (kt) {
|
|
323
|
-
keytarRaw = await kt.getPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT) ?? void 0;
|
|
324
|
-
}
|
|
325
|
-
} catch (keytarError) {
|
|
326
|
-
logger.warn(`Keychain access failed: ${keytarError.message}`);
|
|
327
|
-
}
|
|
328
|
-
let fileRaw;
|
|
329
|
-
const cachePath = getTokenCachePath();
|
|
330
|
-
if (existsSync(cachePath)) {
|
|
331
|
-
fileRaw = readFileSync(cachePath, "utf8");
|
|
332
|
-
}
|
|
333
|
-
const cacheData = pickNewest(keytarRaw, fileRaw);
|
|
334
|
-
if (cacheData) {
|
|
335
|
-
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);
|
|
336
273
|
}
|
|
337
274
|
await this.loadSelectedAccount();
|
|
338
275
|
} catch (error) {
|
|
339
276
|
logger.error(`Error loading token cache: ${error.message}`);
|
|
277
|
+
if (this.storage.failClosed) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
340
280
|
}
|
|
341
281
|
}
|
|
342
282
|
async loadSelectedAccount() {
|
|
343
283
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
if (kt) {
|
|
348
|
-
keytarRaw = await kt.getPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY) ?? void 0;
|
|
349
|
-
}
|
|
350
|
-
} catch (keytarError) {
|
|
351
|
-
logger.warn(
|
|
352
|
-
`Keychain access failed for selected account: ${keytarError.message}`
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
let fileRaw;
|
|
356
|
-
const accountPath = getSelectedAccountPath();
|
|
357
|
-
if (existsSync(accountPath)) {
|
|
358
|
-
fileRaw = readFileSync(accountPath, "utf8");
|
|
359
|
-
}
|
|
360
|
-
const selectedAccountData = pickNewest(keytarRaw, fileRaw);
|
|
361
|
-
if (selectedAccountData) {
|
|
362
|
-
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);
|
|
363
287
|
this.selectedAccountId = parsed.accountId;
|
|
364
288
|
logger.info(`Loaded selected account: ${this.selectedAccountId}`);
|
|
365
289
|
}
|
|
366
290
|
} catch (error) {
|
|
367
291
|
logger.error(`Error loading selected account: ${error.message}`);
|
|
292
|
+
if (this.storage.failClosed) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
368
295
|
}
|
|
369
296
|
}
|
|
370
297
|
async saveTokenCache() {
|
|
371
298
|
try {
|
|
372
299
|
const stamped = wrapCache(this.msalApp.getTokenCache().serialize());
|
|
373
|
-
|
|
374
|
-
const kt = await getKeytar();
|
|
375
|
-
if (kt) {
|
|
376
|
-
await kt.setPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT, stamped);
|
|
377
|
-
} else {
|
|
378
|
-
const cachePath = getTokenCachePath();
|
|
379
|
-
ensureParentDir(cachePath);
|
|
380
|
-
fs.writeFileSync(cachePath, stamped, { mode: 384 });
|
|
381
|
-
}
|
|
382
|
-
} catch (keytarError) {
|
|
383
|
-
logger.warn(
|
|
384
|
-
`Keychain save failed, falling back to file storage: ${keytarError.message}`
|
|
385
|
-
);
|
|
386
|
-
const cachePath = getTokenCachePath();
|
|
387
|
-
ensureParentDir(cachePath);
|
|
388
|
-
fs.writeFileSync(cachePath, stamped, { mode: 384 });
|
|
389
|
-
}
|
|
300
|
+
await this.storage.save("token-cache", stamped);
|
|
390
301
|
} catch (error) {
|
|
391
302
|
logger.error(`Error saving token cache: ${error.message}`);
|
|
303
|
+
if (this.storage.failClosed) {
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
392
306
|
}
|
|
393
307
|
}
|
|
394
308
|
async saveSelectedAccount() {
|
|
395
309
|
try {
|
|
396
310
|
const stamped = wrapCache(JSON.stringify({ accountId: this.selectedAccountId }));
|
|
397
|
-
|
|
398
|
-
const kt = await getKeytar();
|
|
399
|
-
if (kt) {
|
|
400
|
-
await kt.setPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY, stamped);
|
|
401
|
-
} else {
|
|
402
|
-
const accountPath = getSelectedAccountPath();
|
|
403
|
-
ensureParentDir(accountPath);
|
|
404
|
-
fs.writeFileSync(accountPath, stamped, { mode: 384 });
|
|
405
|
-
}
|
|
406
|
-
} catch (keytarError) {
|
|
407
|
-
logger.warn(
|
|
408
|
-
`Keychain save failed for selected account, falling back to file storage: ${keytarError.message}`
|
|
409
|
-
);
|
|
410
|
-
const accountPath = getSelectedAccountPath();
|
|
411
|
-
ensureParentDir(accountPath);
|
|
412
|
-
fs.writeFileSync(accountPath, stamped, { mode: 384 });
|
|
413
|
-
}
|
|
311
|
+
await this.storage.save("selected-account", stamped);
|
|
414
312
|
} catch (error) {
|
|
415
313
|
logger.error(`Error saving selected account: ${error.message}`);
|
|
314
|
+
if (this.storage.failClosed) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
416
317
|
}
|
|
417
318
|
}
|
|
418
319
|
normalizeExpectedUsername(value) {
|
|
@@ -717,23 +618,8 @@ class AuthManager {
|
|
|
717
618
|
this.accessToken = null;
|
|
718
619
|
this.tokenExpiry = null;
|
|
719
620
|
this.selectedAccountId = null;
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (kt) {
|
|
723
|
-
await kt.deletePassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
|
|
724
|
-
await kt.deletePassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY);
|
|
725
|
-
}
|
|
726
|
-
} catch (keytarError) {
|
|
727
|
-
logger.warn(`Keychain deletion failed: ${keytarError.message}`);
|
|
728
|
-
}
|
|
729
|
-
const cachePath = getTokenCachePath();
|
|
730
|
-
if (fs.existsSync(cachePath)) {
|
|
731
|
-
fs.unlinkSync(cachePath);
|
|
732
|
-
}
|
|
733
|
-
const accountPath = getSelectedAccountPath();
|
|
734
|
-
if (fs.existsSync(accountPath)) {
|
|
735
|
-
fs.unlinkSync(accountPath);
|
|
736
|
-
}
|
|
621
|
+
await this.storage.delete("token-cache");
|
|
622
|
+
await this.storage.delete("selected-account");
|
|
737
623
|
return true;
|
|
738
624
|
} catch (error) {
|
|
739
625
|
logger.error(`Error during logout: ${error.message}`);
|
package/dist/cli.js
CHANGED
|
@@ -47,6 +47,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
47
47
|
).option(
|
|
48
48
|
"--obo",
|
|
49
49
|
"Enable On-Behalf-Of token exchange in HTTP mode. Exchanges the incoming bearer token for a Graph API token using the OBO flow. Requires MS365_MCP_CLIENT_SECRET."
|
|
50
|
+
).option(
|
|
51
|
+
"--trust-proxy-auth",
|
|
52
|
+
"In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
|
|
50
53
|
).addOption(
|
|
51
54
|
// DEPRECATED: kept only so existing deployments that set --base-url or
|
|
52
55
|
// MS365_MCP_BASE_URL do not crash at startup. Use --public-url /
|
|
@@ -149,6 +152,9 @@ function parseArgs() {
|
|
|
149
152
|
if (process.env.MS365_MCP_OBO === "true" || process.env.MS365_MCP_OBO === "1") {
|
|
150
153
|
options.obo = true;
|
|
151
154
|
}
|
|
155
|
+
if (process.env.MS365_MCP_TRUST_PROXY_AUTH === "true" || process.env.MS365_MCP_TRUST_PROXY_AUTH === "1") {
|
|
156
|
+
options.trustProxyAuth = true;
|
|
157
|
+
}
|
|
152
158
|
if (options.cloud) {
|
|
153
159
|
process.env.MS365_MCP_CLOUD_TYPE = options.cloud;
|
|
154
160
|
}
|
package/dist/generated/client.js
CHANGED
|
@@ -381,7 +381,9 @@ const microsoft_graph_chat = z.object({
|
|
|
381
381
|
onlineMeetingInfo: microsoft_graph_teamworkOnlineMeetingInfo.optional(),
|
|
382
382
|
originalCreatedDateTime: z.string().regex(
|
|
383
383
|
/^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
|
|
384
|
-
).datetime({ offset: true }).
|
|
384
|
+
).datetime({ offset: true }).describe(
|
|
385
|
+
"Timestamp of the original creation time for the chat. The value is null if the chat never entered migration mode."
|
|
386
|
+
).nullish(),
|
|
385
387
|
tenantId: z.string().describe("The identifier of the tenant in which the chat was created. Read-only.").nullish(),
|
|
386
388
|
topic: z.string().describe("(Optional) Subject or topic for the chat. Only available for group chats.").nullish(),
|
|
387
389
|
viewpoint: microsoft_graph_chatViewpoint.optional(),
|
|
@@ -1480,6 +1482,7 @@ const microsoft_graph_group = z.object({
|
|
|
1480
1482
|
hideFromOutlookClients: z.boolean().describe(
|
|
1481
1483
|
"True if the group isn't displayed in Outlook clients, such as Outlook for Windows and Outlook on the web; otherwise, false. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
|
|
1482
1484
|
).nullish(),
|
|
1485
|
+
infoCatalogs: z.array(z.string()).optional(),
|
|
1483
1486
|
isArchived: z.boolean().describe(
|
|
1484
1487
|
"When a group is associated with a team, this property determines whether the team is in read-only mode.To read this property, use the /group/{groupId}/team endpoint or the Get team API. To update this property, use the archiveTeam and unarchiveTeam APIs."
|
|
1485
1488
|
).nullish(),
|
|
@@ -1504,9 +1507,6 @@ const microsoft_graph_group = z.object({
|
|
|
1504
1507
|
).nullish(),
|
|
1505
1508
|
membershipRule: z.string().describe(
|
|
1506
1509
|
"The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
|
|
1507
|
-
).nullish(),
|
|
1508
|
-
membershipRuleProcessingState: z.string().describe(
|
|
1509
|
-
"Indicates whether the dynamic membership processing is on or paused. Possible values are On or Paused. Returned by default. Supports $filter (eq, ne, not, in)."
|
|
1510
1510
|
).nullish()
|
|
1511
1511
|
}).passthrough().passthrough();
|
|
1512
1512
|
const microsoft_graph_groupCollectionResponse = z.object({
|
|
@@ -2759,7 +2759,9 @@ const microsoft_graph_channel = z.lazy(
|
|
|
2759
2759
|
migrationMode: microsoft_graph_migrationMode.optional(),
|
|
2760
2760
|
originalCreatedDateTime: z.string().regex(
|
|
2761
2761
|
/^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
|
|
2762
|
-
).datetime({ offset: true }).
|
|
2762
|
+
).datetime({ offset: true }).describe(
|
|
2763
|
+
"Timestamp of the original creation time for the channel. The value is null if the channel never entered migration mode."
|
|
2764
|
+
).nullish(),
|
|
2763
2765
|
summary: microsoft_graph_channelSummary.optional(),
|
|
2764
2766
|
tenantId: z.string().describe("The ID of the Microsoft Entra tenant.").nullish(),
|
|
2765
2767
|
webUrl: z.string().describe(
|
|
@@ -2854,7 +2856,7 @@ const microsoft_graph_team = z.lazy(
|
|
|
2854
2856
|
).nullish(),
|
|
2855
2857
|
allChannels: z.array(microsoft_graph_channel).describe("List of channels either hosted in or shared with the team (incoming channels).").optional(),
|
|
2856
2858
|
channels: z.array(microsoft_graph_channel).describe("The collection of channels and messages associated with the team.").optional(),
|
|
2857
|
-
group: microsoft_graph_group.describe("[Note: Simplified from
|
|
2859
|
+
group: microsoft_graph_group.describe("[Note: Simplified from 75 properties to 25 most common ones]").optional(),
|
|
2858
2860
|
incomingChannels: z.array(microsoft_graph_channel).describe("List of channels shared with the team.").optional(),
|
|
2859
2861
|
installedApps: z.array(microsoft_graph_teamsAppInstallation).describe("The apps installed in this team.").optional(),
|
|
2860
2862
|
members: z.array(microsoft_graph_conversationMember).describe("Members and owners of the team.").optional(),
|
|
@@ -6675,6 +6677,7 @@ You can search within a folder hierarchy, a whole drive, or files shared with th
|
|
|
6675
6677
|
hideFromOutlookClients: z.boolean().describe(
|
|
6676
6678
|
"True if the group isn't displayed in Outlook clients, such as Outlook for Windows and Outlook on the web; otherwise, false. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
|
|
6677
6679
|
).nullish(),
|
|
6680
|
+
infoCatalogs: z.array(z.string()).optional(),
|
|
6678
6681
|
isArchived: z.boolean().describe(
|
|
6679
6682
|
"When a group is associated with a team, this property determines whether the team is in read-only mode.To read this property, use the /group/{groupId}/team endpoint or the Get team API. To update this property, use the archiveTeam and unarchiveTeam APIs."
|
|
6680
6683
|
).nullish(),
|
|
@@ -6699,9 +6702,6 @@ You can search within a folder hierarchy, a whole drive, or files shared with th
|
|
|
6699
6702
|
).nullish(),
|
|
6700
6703
|
membershipRule: z.string().describe(
|
|
6701
6704
|
"The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
|
|
6702
|
-
).nullish(),
|
|
6703
|
-
membershipRuleProcessingState: z.string().describe(
|
|
6704
|
-
"Indicates whether the dynamic membership processing is on or paused. Possible values are On or Paused. Returned by default. Supports $filter (eq, ne, not, in)."
|
|
6705
6705
|
).nullish()
|
|
6706
6706
|
}).passthrough().passthrough()
|
|
6707
6707
|
}
|
|
@@ -6790,6 +6790,7 @@ You can create or update the following types of group: By default, this operatio
|
|
|
6790
6790
|
hideFromOutlookClients: z.boolean().describe(
|
|
6791
6791
|
"True if the group isn't displayed in Outlook clients, such as Outlook for Windows and Outlook on the web; otherwise, false. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
|
|
6792
6792
|
).nullish(),
|
|
6793
|
+
infoCatalogs: z.array(z.string()).optional(),
|
|
6793
6794
|
isArchived: z.boolean().describe(
|
|
6794
6795
|
"When a group is associated with a team, this property determines whether the team is in read-only mode.To read this property, use the /group/{groupId}/team endpoint or the Get team API. To update this property, use the archiveTeam and unarchiveTeam APIs."
|
|
6795
6796
|
).nullish(),
|
|
@@ -6814,9 +6815,6 @@ You can create or update the following types of group: By default, this operatio
|
|
|
6814
6815
|
).nullish(),
|
|
6815
6816
|
membershipRule: z.string().describe(
|
|
6816
6817
|
"The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
|
|
6817
|
-
).nullish(),
|
|
6818
|
-
membershipRuleProcessingState: z.string().describe(
|
|
6819
|
-
"Indicates whether the dynamic membership processing is on or paused. Possible values are On or Paused. Returned by default. Supports $filter (eq, ne, not, in)."
|
|
6820
6818
|
).nullish()
|
|
6821
6819
|
}).passthrough().passthrough()
|
|
6822
6820
|
}
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,10 @@ import AuthManager, { buildAllowedScopeDiagnostics, resolveAuthScopes } from "./
|
|
|
6
6
|
import MicrosoftGraphServer from "./server.js";
|
|
7
7
|
import {
|
|
8
8
|
getExpectedAccountInertWarning,
|
|
9
|
-
shouldAssertExpectedAccountAtStartup
|
|
9
|
+
shouldAssertExpectedAccountAtStartup,
|
|
10
|
+
shouldUseLocalAuthStorage
|
|
10
11
|
} from "./startup-pinning.js";
|
|
12
|
+
import { createTokenCacheStorage } from "./token-cache-storage.js";
|
|
11
13
|
import { version } from "./version.js";
|
|
12
14
|
async function main() {
|
|
13
15
|
try {
|
|
@@ -41,11 +43,22 @@ async function main() {
|
|
|
41
43
|
);
|
|
42
44
|
process.exit(0);
|
|
43
45
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const useLocalAuthStorage = shouldUseLocalAuthStorage(args);
|
|
47
|
+
const storage = await createTokenCacheStorage({
|
|
48
|
+
allowCommandStorage: useLocalAuthStorage,
|
|
49
|
+
logProvider: useLocalAuthStorage
|
|
47
50
|
});
|
|
48
|
-
await
|
|
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
|
+
}
|
|
49
62
|
if (args.authBrowser) {
|
|
50
63
|
authManager.setUseInteractiveAuth(true);
|
|
51
64
|
logger.info("Browser-based interactive auth enabled");
|
|
@@ -17,7 +17,11 @@ function isJwtExpired(token) {
|
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
const microsoftBearerTokenAuthMiddleware = (req, res, next) => {
|
|
20
|
+
const microsoftBearerTokenAuthMiddleware = (opts = {}) => (req, res, next) => {
|
|
21
|
+
if (opts.trustProxyAuth) {
|
|
22
|
+
next();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
21
25
|
const authHeader = req.headers.authorization;
|
|
22
26
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
23
27
|
res.status(401).set(
|
|
@@ -40,6 +44,43 @@ const microsoftBearerTokenAuthMiddleware = (req, res, next) => {
|
|
|
40
44
|
req.microsoftAuth = { accessToken };
|
|
41
45
|
next();
|
|
42
46
|
};
|
|
47
|
+
class OAuthUpstreamError extends Error {
|
|
48
|
+
constructor(status, raw, body) {
|
|
49
|
+
const suffix = body.error_description ? ` - ${body.error_description}` : "";
|
|
50
|
+
super(`OAuth upstream error: ${body.error}${suffix}`);
|
|
51
|
+
this.name = "OAuthUpstreamError";
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.body = body;
|
|
54
|
+
this.raw = raw;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function parseUpstreamOAuthError(raw) {
|
|
58
|
+
try {
|
|
59
|
+
const json = JSON.parse(raw);
|
|
60
|
+
if (json !== null && typeof json === "object" && typeof json.error === "string") {
|
|
61
|
+
return json;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function toOAuthErrorResponse(error) {
|
|
68
|
+
if (error instanceof OAuthUpstreamError) {
|
|
69
|
+
const body = {
|
|
70
|
+
error: error.body.error
|
|
71
|
+
};
|
|
72
|
+
if (error.body.error_description) body.error_description = error.body.error_description;
|
|
73
|
+
if (error.body.suberror) body.suberror = error.body.suberror;
|
|
74
|
+
return { status: 400, body };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
status: 500,
|
|
78
|
+
body: {
|
|
79
|
+
error: "server_error",
|
|
80
|
+
error_description: "Internal server error during token exchange"
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
43
84
|
async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, tenantId = "common", codeVerifier, cloudType = "global") {
|
|
44
85
|
const cloudEndpoints = getCloudEndpoints(cloudType);
|
|
45
86
|
const params = new URLSearchParams({
|
|
@@ -62,9 +103,20 @@ async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, t
|
|
|
62
103
|
body: params
|
|
63
104
|
});
|
|
64
105
|
if (!response.ok) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
const raw = await response.text();
|
|
107
|
+
const parsed = parseUpstreamOAuthError(raw);
|
|
108
|
+
if (parsed) {
|
|
109
|
+
logger.warn(`Token endpoint upstream OAuth error: ${parsed.error}`, {
|
|
110
|
+
status: response.status,
|
|
111
|
+
error: parsed.error,
|
|
112
|
+
suberror: parsed.suberror,
|
|
113
|
+
error_codes: parsed.error_codes,
|
|
114
|
+
correlation_id: parsed.correlation_id
|
|
115
|
+
});
|
|
116
|
+
throw new OAuthUpstreamError(response.status, raw, parsed);
|
|
117
|
+
}
|
|
118
|
+
logger.error(`Failed to exchange code for token: ${raw}`);
|
|
119
|
+
throw new Error(`Failed to exchange code for token: ${raw}`);
|
|
68
120
|
}
|
|
69
121
|
return response.json();
|
|
70
122
|
}
|
|
@@ -86,14 +138,27 @@ async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId
|
|
|
86
138
|
body: params
|
|
87
139
|
});
|
|
88
140
|
if (!response.ok) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
141
|
+
const raw = await response.text();
|
|
142
|
+
const parsed = parseUpstreamOAuthError(raw);
|
|
143
|
+
if (parsed) {
|
|
144
|
+
logger.warn(`Token endpoint upstream OAuth error: ${parsed.error}`, {
|
|
145
|
+
status: response.status,
|
|
146
|
+
error: parsed.error,
|
|
147
|
+
suberror: parsed.suberror,
|
|
148
|
+
error_codes: parsed.error_codes,
|
|
149
|
+
correlation_id: parsed.correlation_id
|
|
150
|
+
});
|
|
151
|
+
throw new OAuthUpstreamError(response.status, raw, parsed);
|
|
152
|
+
}
|
|
153
|
+
logger.error(`Failed to refresh token: ${raw}`);
|
|
154
|
+
throw new Error(`Failed to refresh token: ${raw}`);
|
|
92
155
|
}
|
|
93
156
|
return response.json();
|
|
94
157
|
}
|
|
95
158
|
export {
|
|
159
|
+
OAuthUpstreamError,
|
|
96
160
|
exchangeCodeForToken,
|
|
97
161
|
microsoftBearerTokenAuthMiddleware,
|
|
98
|
-
refreshAccessToken
|
|
162
|
+
refreshAccessToken,
|
|
163
|
+
toOAuthErrorResponse
|
|
99
164
|
};
|
package/dist/server.js
CHANGED
|
@@ -17,7 +17,9 @@ import { MicrosoftOAuthProvider } from "./oauth-provider.js";
|
|
|
17
17
|
import {
|
|
18
18
|
exchangeCodeForToken,
|
|
19
19
|
microsoftBearerTokenAuthMiddleware,
|
|
20
|
-
|
|
20
|
+
OAuthUpstreamError,
|
|
21
|
+
refreshAccessToken,
|
|
22
|
+
toOAuthErrorResponse
|
|
21
23
|
} from "./lib/microsoft-auth.js";
|
|
22
24
|
import { isAllowedRedirectUri, parseAllowlist } from "./lib/redirect-uri-validation.js";
|
|
23
25
|
import { getSecrets } from "./secrets.js";
|
|
@@ -123,6 +125,11 @@ class MicrosoftGraphServer {
|
|
|
123
125
|
"--obo requires MS365_MCP_CLIENT_SECRET to be set (confidential client required for On-Behalf-Of flow)."
|
|
124
126
|
);
|
|
125
127
|
}
|
|
128
|
+
if (this.options.trustProxyAuth) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"--obo cannot be combined with --trust-proxy-auth: the proxy-auth pass-through skips the incoming bearer token that OBO would exchange."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
126
133
|
this.oboClient = new OboClient(this.secrets);
|
|
127
134
|
logger.info("On-Behalf-Of (OBO) flow enabled");
|
|
128
135
|
}
|
|
@@ -399,11 +406,18 @@ class MicrosoftGraphServer {
|
|
|
399
406
|
});
|
|
400
407
|
}
|
|
401
408
|
} catch (error) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
409
|
+
if (error instanceof OAuthUpstreamError) {
|
|
410
|
+
logger.warn("Token endpoint: upstream OAuth error surfaced to client", {
|
|
411
|
+
upstream_status: error.status,
|
|
412
|
+
error: error.body.error,
|
|
413
|
+
suberror: error.body.suberror,
|
|
414
|
+
error_codes: error.body.error_codes
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
logger.error("Token endpoint error:", error);
|
|
418
|
+
}
|
|
419
|
+
const { status, body } = toOAuthErrorResponse(error);
|
|
420
|
+
res.status(status).json(body);
|
|
407
421
|
}
|
|
408
422
|
});
|
|
409
423
|
app.use(
|
|
@@ -412,9 +426,12 @@ class MicrosoftGraphServer {
|
|
|
412
426
|
issuerUrl: new URL(publicBase ?? `http://localhost:${port}`)
|
|
413
427
|
})
|
|
414
428
|
);
|
|
429
|
+
const mcpAuth = microsoftBearerTokenAuthMiddleware({
|
|
430
|
+
trustProxyAuth: this.options.trustProxyAuth
|
|
431
|
+
});
|
|
415
432
|
app.get(
|
|
416
433
|
"/mcp",
|
|
417
|
-
|
|
434
|
+
mcpAuth,
|
|
418
435
|
async (req, res) => {
|
|
419
436
|
const handler = async () => {
|
|
420
437
|
const server = this.createMcpServer();
|
|
@@ -456,7 +473,7 @@ class MicrosoftGraphServer {
|
|
|
456
473
|
);
|
|
457
474
|
app.post(
|
|
458
475
|
"/mcp",
|
|
459
|
-
|
|
476
|
+
mcpAuth,
|
|
460
477
|
async (req, res) => {
|
|
461
478
|
const handler = async () => {
|
|
462
479
|
const server = this.createMcpServer();
|
package/dist/startup-pinning.js
CHANGED
|
@@ -34,7 +34,17 @@ function shouldAssertExpectedAccountAtStartup(args, authManager) {
|
|
|
34
34
|
}
|
|
35
35
|
return !LOCAL_ACCOUNT_COMMANDS.some((key) => Boolean(args[key]));
|
|
36
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
|
+
}
|
|
37
46
|
export {
|
|
38
47
|
getExpectedAccountInertWarning,
|
|
39
|
-
shouldAssertExpectedAccountAtStartup
|
|
48
|
+
shouldAssertExpectedAccountAtStartup,
|
|
49
|
+
shouldUseLocalAuthStorage
|
|
40
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:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softeria/ms-365-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.1",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@azure/msal-node": "^
|
|
36
|
+
"@azure/msal-node": "^5.2.2",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
38
|
"@toon-format/toon": "^0.8.0",
|
|
39
39
|
"commander": "^11.1.0",
|
|
@@ -76,6 +76,6 @@
|
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
|
79
|
-
"url": "https://github.com/
|
|
79
|
+
"url": "https://github.com/Softeria/ms-365-mcp-server.git"
|
|
80
80
|
}
|
|
81
81
|
}
|