MCP server providing Microsoft Outlook integration via Graph API: - Email: list, search, read, send, thread reconstruction - Calendar: list, create, decline, cancel, delete events - Folders: list, create, move emails - Inbox rules: list, create, reorder - MSAL device code flow auth with persistent token cache - Test mode with mock data - Comprehensive README with setup, config, and tool reference 20 MCP tools across 5 modules. Node.js >= 14. MIT license.
166 lines
4.8 KiB
JavaScript
166 lines
4.8 KiB
JavaScript
/**
|
|
* Token management using @azure/msal-node
|
|
*/
|
|
const msal = require('@azure/msal-node');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const config = require('../config');
|
|
|
|
// Persist MSAL's internal token cache to disk so tokens survive process restarts.
|
|
const cachePlugin = {
|
|
beforeCacheAccess: async (cacheContext) => {
|
|
try {
|
|
if (fs.existsSync(config.AUTH_CONFIG.tokenStorePath)) {
|
|
cacheContext.tokenCache.deserialize(
|
|
fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8')
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error('[TokenManager] Cache read error:', e.message);
|
|
}
|
|
},
|
|
afterCacheAccess: async (cacheContext) => {
|
|
if (cacheContext.cacheHasChanged) {
|
|
try {
|
|
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
config.AUTH_CONFIG.tokenStorePath,
|
|
cacheContext.tokenCache.serialize()
|
|
);
|
|
console.error('[TokenManager] Cache saved to:', config.AUTH_CONFIG.tokenStorePath);
|
|
} catch (e) {
|
|
console.error('[TokenManager] Cache write error:', e.message);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Singleton PublicClientApplication — device code flow requires a public client.
|
|
// "Allow public client flows" must be enabled in the app registration.
|
|
let _pca = null;
|
|
function getPca() {
|
|
if (!_pca) {
|
|
_pca = new msal.PublicClientApplication({
|
|
auth: {
|
|
clientId: config.AUTH_CONFIG.clientId,
|
|
authority: 'https://login.microsoftonline.com/organizations'
|
|
},
|
|
cache: { cachePlugin },
|
|
system: {
|
|
loggerOptions: {
|
|
logLevel: msal.LogLevel.Warning,
|
|
piiLoggingEnabled: false,
|
|
loggerCallback: (level, message, containsPii) => {
|
|
if (!containsPii && config.DEBUG_MODE) {
|
|
console.error(`[MSAL] ${message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return _pca;
|
|
}
|
|
|
|
/**
|
|
* Returns the first cached account from MSAL's token cache, or null.
|
|
*/
|
|
async function getCachedAccount() {
|
|
try {
|
|
const accounts = await getPca().getTokenCache().getAllAccounts();
|
|
return accounts && accounts.length > 0 ? accounts[0] : null;
|
|
} catch (e) {
|
|
console.error('[TokenManager] getAllAccounts error:', e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a valid access token via MSAL silent flow (handles refresh automatically).
|
|
* Returns null if no cached account or silent acquire fails.
|
|
*/
|
|
async function getAccessToken() {
|
|
// Test mode shortcut
|
|
if (config.USE_TEST_MODE) {
|
|
try {
|
|
const raw = fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8');
|
|
const data = JSON.parse(raw);
|
|
if (data._testMode) return data.access_token;
|
|
} catch { /* fall through */ }
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const account = await getCachedAccount();
|
|
if (!account) return null;
|
|
const result = await getPca().acquireTokenSilent({
|
|
scopes: config.AUTH_CONFIG.scopes,
|
|
account
|
|
});
|
|
return result ? result.accessToken : null;
|
|
} catch (e) {
|
|
console.error('[TokenManager] Silent token acquire failed:', e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiates the device code flow.
|
|
* Returns { deviceCodeInfo, tokenPromise } immediately after the code is issued.
|
|
* tokenPromise resolves when the user completes sign-in; MSAL handles all polling.
|
|
*/
|
|
async function initiateDeviceCodeFlow() {
|
|
const pca = getPca();
|
|
|
|
let resolveDeviceCode, rejectDeviceCode;
|
|
const deviceCodeInfoPromise = new Promise((resolve, reject) => {
|
|
resolveDeviceCode = resolve;
|
|
rejectDeviceCode = reject;
|
|
});
|
|
|
|
const tokenPromise = pca.acquireTokenByDeviceCode({
|
|
scopes: config.AUTH_CONFIG.scopes,
|
|
deviceCodeCallback: (response) => {
|
|
console.error('[TokenManager] Device code issued:', response.userCode);
|
|
resolveDeviceCode(response);
|
|
}
|
|
}).catch((err) => {
|
|
// If the flow fails before the callback fires, reject the code promise too
|
|
rejectDeviceCode(err);
|
|
throw err;
|
|
});
|
|
|
|
const deviceCodeInfo = await deviceCodeInfoPromise;
|
|
return { deviceCodeInfo, tokenPromise };
|
|
}
|
|
|
|
/**
|
|
* Silent refresh — with MSAL this is handled transparently inside getAccessToken().
|
|
*/
|
|
async function refreshAccessToken() {
|
|
return getAccessToken();
|
|
}
|
|
|
|
/**
|
|
* Creates test tokens for USE_TEST_MODE.
|
|
*/
|
|
function createTestTokens() {
|
|
const testData = {
|
|
_testMode: true,
|
|
access_token: 'test_access_token_' + Date.now(),
|
|
expires_at: Date.now() + 3600 * 1000
|
|
};
|
|
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(config.AUTH_CONFIG.tokenStorePath, JSON.stringify(testData, null, 2));
|
|
}
|
|
|
|
module.exports = {
|
|
getPca,
|
|
getCachedAccount,
|
|
getAccessToken,
|
|
initiateDeviceCodeFlow,
|
|
refreshAccessToken,
|
|
createTestTokens
|
|
};
|