UNPKG

ms365-mcp-server

Version:

Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support

871 lines (870 loc) • 35.8 kB
import { ConfidentialClientApplication, PublicClientApplication } from '@azure/msal-node'; import { Client } from '@microsoft/microsoft-graph-client'; import * as fs from 'fs'; import * as path from 'path'; import open from 'open'; import { createServer } from 'http'; import { URL } from 'url'; import { logger } from './api.js'; import { credentialStore } from './credential-store.js'; import { getConfigDirWithFallback } from './config-dir.js'; // Scopes required for Microsoft 365 operations const SCOPES = [ 'https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send', 'https://graph.microsoft.com/MailboxSettings.Read', 'https://graph.microsoft.com/Contacts.Read', 'https://graph.microsoft.com/User.Read', 'offline_access' ]; // Built-in application for easier setup (similar to Softeria's approach) const BUILTIN_CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; // Microsoft Graph Command Line Tools const DEFAULT_TENANT_ID = "common"; // Configuration directory and file paths const CONFIG_DIR = getConfigDirWithFallback(); const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json'); const DEVICE_CODE_FILE = path.join(CONFIG_DIR, 'device-code.json'); const TOKEN_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json'); /** * Enhanced Microsoft 365 authentication manager with device code flow support */ export class EnhancedMS365Auth { constructor(authMethod = 'auto') { this.msalClient = null; this.credentials = null; this.preferredAuthMethod = 'auto'; this.pendingAuth = null; this.preferredAuthMethod = authMethod; this.ensureConfigDir(); } /** * Ensure configuration directory exists */ ensureConfigDir() { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); logger.log('Created MS365 MCP configuration directory'); } } /** * Load credentials from environment, file, or use built-in app */ async loadCredentials() { try { // Method 1: Environment variables (highest priority) if (process.env.MS365_CLIENT_ID && process.env.MS365_TENANT_ID) { this.credentials = { clientId: process.env.MS365_CLIENT_ID, clientSecret: process.env.MS365_CLIENT_SECRET, tenantId: process.env.MS365_TENANT_ID, redirectUri: process.env.MS365_REDIRECT_URI || 'http://localhost:44001/oauth2callback', authType: process.env.MS365_CLIENT_SECRET ? 'redirect' : 'device' }; logger.log('Loaded MS365 credentials from environment variables'); return true; } // Method 2: Credentials file if (fs.existsSync(CREDENTIALS_FILE)) { const credentialsData = fs.readFileSync(CREDENTIALS_FILE, 'utf8'); this.credentials = JSON.parse(credentialsData); logger.log('Loaded MS365 credentials from file'); return true; } // Method 3: Built-in application (fallback) this.credentials = { clientId: BUILTIN_CLIENT_ID, tenantId: DEFAULT_TENANT_ID, authType: 'device' }; logger.log('Using built-in MS365 application with device code flow'); return true; } catch (error) { logger.error('Error loading MS365 credentials:', error); return false; } } /** * Save credentials to file */ async saveCredentials(credentials) { try { fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2)); logger.log('Saved MS365 credentials to file'); } catch (error) { logger.error('Error saving MS365 credentials:', error); throw new Error('Failed to save credentials'); } } /** * Initialize MSAL client based on auth type with persistent token cache */ initializeMsalClient() { if (!this.credentials) { throw new Error('Credentials not loaded'); } // Return existing client if already initialized with same credentials if (this.msalClient) { return this.msalClient; } const isConfidential = this.credentials.clientSecret && this.credentials.authType === 'redirect'; // Create persistent token cache const cachePlugin = { beforeCacheAccess: async (cacheContext) => { try { if (fs.existsSync(TOKEN_CACHE_FILE)) { const cacheData = fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'); cacheContext.tokenCache.deserialize(cacheData); } } catch (error) { logger.error('Error loading MSAL token cache:', error); } }, afterCacheAccess: async (cacheContext) => { try { if (cacheContext.cacheHasChanged) { const cacheData = cacheContext.tokenCache.serialize(); fs.writeFileSync(TOKEN_CACHE_FILE, cacheData); } } catch (error) { logger.error('Error saving MSAL token cache:', error); } } }; if (isConfidential) { // Confidential client for redirect-based auth const config = { auth: { clientId: this.credentials.clientId, clientSecret: this.credentials.clientSecret, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}` }, cache: { cachePlugin }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (!containsPii) { logger.log(`MSAL: ${message}`); } }, piiLoggingEnabled: false, logLevel: 3 } } }; this.msalClient = new ConfidentialClientApplication(config); } else { // Public client for device code flow const config = { auth: { clientId: this.credentials.clientId, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}` }, cache: { cachePlugin }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (!containsPii) { logger.log(`MSAL: ${message}`); } }, piiLoggingEnabled: false, logLevel: 3 } } }; this.msalClient = new PublicClientApplication(config); } logger.log('Initialized MSAL client with persistent token cache'); return this.msalClient; } /** * Device code flow authentication */ async authenticateWithDeviceCode() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); const deviceCodeRequest = { scopes: SCOPES, deviceCodeCallback: (response) => { // Display the device code to the user on stderr to avoid JSON-RPC conflicts console.error('\nšŸ” Microsoft 365 Authentication Required'); console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.error(`šŸ“± Please visit: ${response.verificationUri}`); console.error(`šŸ”‘ Enter this code: ${response.userCode}`); console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.error('ā³ Waiting for authentication...\n'); logger.log(`Device code authentication: ${response.verificationUri} - ${response.userCode}`); } }; try { const tokenResponse = await msalClient.acquireTokenByDeviceCode(deviceCodeRequest); if (!tokenResponse) { throw new Error('Failed to acquire token via device code'); } await this.saveToken(tokenResponse, 'device'); logger.log('MS365 device code authentication successful'); console.error('āœ… Authentication successful!\n'); return tokenResponse; } catch (error) { logger.error('Device code authentication failed:', error); throw error; } } /** * Redirect-based authentication (original method) */ async authenticateWithRedirect() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } if (!this.credentials?.clientSecret) { throw new Error('Client secret required for redirect authentication'); } const msalClient = this.initializeMsalClient(); try { const authUrl = await msalClient.getAuthCodeUrl({ scopes: SCOPES, redirectUri: this.credentials.redirectUri, prompt: 'consent' }); logger.log('Opening browser for authentication...'); const [authCode] = await Promise.all([ this.startCallbackServer(), open(authUrl) ]); const tokenResponse = await msalClient.acquireTokenByCode({ code: authCode, scopes: SCOPES, redirectUri: this.credentials.redirectUri }); if (!tokenResponse) { throw new Error('Failed to acquire token'); } await this.saveToken(tokenResponse, 'redirect'); logger.log('MS365 redirect authentication successful'); return tokenResponse; } catch (error) { logger.error('Redirect authentication failed:', error); throw error; } } /** * Smart authentication that chooses the best method */ async authenticate() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const authType = this.determineAuthType(); if (authType === 'device') { return await this.authenticateWithDeviceCode(); } else { return await this.authenticateWithRedirect(); } } /** * Determine the best authentication type */ determineAuthType() { if (this.preferredAuthMethod === 'device') { return 'device'; } if (this.preferredAuthMethod === 'redirect') { if (!this.credentials?.clientSecret) { logger.log('No client secret available, falling back to device code flow'); return 'device'; } return 'redirect'; } // Auto mode: prefer device code for simplicity, redirect if client secret is available if (this.credentials?.clientSecret && this.credentials?.redirectUri) { return 'redirect'; } return 'device'; } /** * Save token using secure credential store (simplified single account) */ async saveToken(token, authType) { try { const tokenData = { accessToken: token.accessToken, refreshToken: '', // MSAL manages refresh tokens internally expiresOn: token.expiresOn?.getTime() || 0, account: token.account, authType: authType }; // Always use a single account key for simplicity await credentialStore.setCredentials('ms365-user', tokenData); logger.log(`Saved MS365 access token securely (expires: ${new Date(tokenData.expiresOn).toLocaleString()})`); } catch (error) { logger.error('Error saving token:', error); } } /** * Load stored token using secure credential store (simplified single account) */ async loadStoredToken() { try { return await credentialStore.getCredentials('ms365-user'); } catch (error) { logger.error('Error loading stored token:', error); return null; } } /** * Start local server for OAuth2 callback (redirect auth) */ startCallbackServer() { return new Promise((resolve, reject) => { const server = createServer((req, res) => { if (req.url?.startsWith('/oauth2callback')) { const url = new URL(req.url, 'http://localhost:44001'); const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); if (error) { res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`); server.close(); reject(new Error(`OAuth2 error: ${error}`)); return; } if (code) { res.end(`<html><body><h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`); server.close(); resolve(code); return; } res.end('<html><body><h1>Invalid Request</h1></body></html>'); } else { res.end('<html><body><h1>MS365 MCP Server OAuth2</h1><p>Waiting for authentication...</p></body></html>'); } }); server.listen(44001, () => { logger.log('OAuth2 callback server started on port 44001'); }); server.on('error', (err) => { reject(err); }); }); } /** * Get authenticated Microsoft Graph client */ async getGraphClient() { let storedToken = await this.loadStoredToken(); if (!storedToken) { throw new Error('No stored token found. Please authenticate first.'); } // Check if token is expired if (storedToken.expiresOn < Date.now()) { logger.log('Access token expired, refreshing...'); await this.refreshToken(); // Reload the token after refresh storedToken = await this.loadStoredToken(); if (!storedToken) { throw new Error('Failed to refresh token. Please re-authenticate.'); } } const client = Client.init({ authProvider: (done) => { done(null, storedToken.accessToken); } }); return client; } /** * Get token expiration information for proactive refresh */ async getTokenExpirationInfo() { try { const storedToken = await this.loadStoredToken(); if (!storedToken) { return { expiresInMinutes: 0, needsRefresh: true }; } const now = Date.now(); const expiresInMs = storedToken.expiresOn - now; const expiresInMinutes = Math.floor(expiresInMs / (1000 * 60)); return { expiresInMinutes: Math.max(0, expiresInMinutes), needsRefresh: expiresInMinutes < 5 // Refresh if expiring within 5 minutes }; } catch (error) { logger.error('Error getting token expiration info:', error); return { expiresInMinutes: 0, needsRefresh: true }; } } /** * Refresh token if needed (proactive refresh) */ async refreshTokenIfNeeded() { try { const tokenInfo = await this.getTokenExpirationInfo(); if (tokenInfo.needsRefresh) { logger.log('Proactively refreshing token to prevent interruption...'); await this.refreshToken(); return true; } return false; } catch (error) { logger.error('Proactive token refresh failed:', error); return false; } } /** * Enhanced refresh token with better error handling */ async refreshToken() { const storedToken = await this.loadStoredToken(); if (!storedToken?.account) { throw new Error('No account information available. Please re-authenticate using: authenticate_with_device_code'); } if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); try { // Try to get all accounts from MSAL cache first (only available on PublicClientApplication) let accountToUse = storedToken.account; if (msalClient instanceof PublicClientApplication) { const accounts = await msalClient.getAllAccounts(); // If we have accounts in MSAL cache, use the first one that matches if (accounts.length > 0) { const matchingAccount = accounts.find((acc) => acc.username === storedToken.account?.username || acc.homeAccountId === storedToken.account?.homeAccountId); if (matchingAccount) { accountToUse = matchingAccount; logger.log('Using account from MSAL cache for token refresh'); } } } const tokenResponse = await msalClient.acquireTokenSilent({ scopes: SCOPES, account: accountToUse }); if (!tokenResponse) { throw new Error('Failed to refresh token - please re-authenticate using: authenticate_with_device_code'); } await this.saveToken(tokenResponse, storedToken.authType); logger.log('MS365 token refreshed successfully'); } catch (error) { // Enhanced error handling with user-friendly messages if (error.errorCode === 'invalid_grant' || error.errorCode === 'interaction_required') { throw new Error('Authentication has expired. Please re-authenticate using the "authenticate_with_device_code" tool.'); } else if (error.errorCode === 'consent_required') { throw new Error('Additional consent required. Please re-authenticate using the "authenticate_with_device_code" tool.'); } else if (error.errorCode === 'no_account_in_silent_request') { throw new Error('No account found in token cache. Please re-authenticate using the "authenticate_with_device_code" tool.'); } else { logger.error('Token refresh failed:', error); throw new Error(`Token refresh failed: ${error.message}. Please re-authenticate using the "authenticate_with_device_code" tool.`); } } } /** * Check if user is authenticated */ async isAuthenticated() { const storedToken = await this.loadStoredToken(); if (!storedToken) { return false; } // If token is expired, try to refresh if (storedToken.expiresOn < Date.now()) { try { await this.refreshToken(); return true; } catch (error) { logger.error('Token refresh failed during authentication check:', error); return false; } } return true; } /** * Check if credentials are configured */ async isConfigured() { return await this.loadCredentials(); } /** * Clear stored authentication data */ async resetAuth() { try { await credentialStore.deleteCredentials('ms365-user'); await this.clearDeviceCodeState(); // Clear MSAL token cache if (fs.existsSync(TOKEN_CACHE_FILE)) { fs.unlinkSync(TOKEN_CACHE_FILE); logger.log('Cleared MSAL token cache'); } // Reset MSAL client instance to force re-initialization this.msalClient = null; logger.log('Cleared stored authentication tokens'); } catch (error) { logger.error('Error clearing authentication data:', error); } } /** * Save device code state to file */ async saveDeviceCodeState(state) { try { fs.writeFileSync(DEVICE_CODE_FILE, JSON.stringify(state, null, 2)); logger.log('Saved device code state to file'); } catch (error) { logger.error('Error saving device code state:', error); throw new Error('Failed to save device code state'); } } /** * Load device code state from file */ async loadDeviceCodeState() { try { if (!fs.existsSync(DEVICE_CODE_FILE)) { return null; } const stateData = fs.readFileSync(DEVICE_CODE_FILE, 'utf8'); const state = JSON.parse(stateData); // Check if device code has expired const now = Date.now(); const elapsed = (now - state.startTime) / 1000; if (elapsed > state.expiresIn) { // Device code has expired, clean up await this.clearDeviceCodeState(); return null; } return state; } catch (error) { logger.error('Error loading device code state:', error); return null; } } /** * Clear device code state file */ async clearDeviceCodeState() { try { if (fs.existsSync(DEVICE_CODE_FILE)) { fs.unlinkSync(DEVICE_CODE_FILE); logger.log('Cleared device code state file'); } } catch (error) { logger.error('Error clearing device code state:', error); } } /** * Complete device code authentication using saved state */ async completeDeviceCodeAuth() { const deviceCodeState = await this.loadDeviceCodeState(); if (!deviceCodeState) { return false; // No pending device code authentication } if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } try { // Use the raw MSAL token endpoint to check if authentication completed const tokenUrl = `https://login.microsoftonline.com/${this.credentials.tenantId}/oauth2/v2.0/token`; const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCodeState.deviceCode, client_id: this.credentials.clientId, }), }); const result = await response.json(); if (response.ok && result.access_token) { // Authentication completed successfully // Microsoft's token response doesn't include account info, so we need to get it from the token let username = 'authenticated-user'; try { // Try to decode the access token to get user info const tokenParts = result.access_token.split('.'); if (tokenParts.length >= 2) { const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); username = payload.upn || payload.unique_name || payload.preferred_username || 'authenticated-user'; } } catch (decodeError) { logger.log('Could not decode token for username, using default'); } const tokenResponse = { accessToken: result.access_token, refreshToken: result.refresh_token || '', expiresOn: new Date(Date.now() + (result.expires_in * 1000)), account: { username: username, homeAccountId: `${username}.${this.credentials.tenantId}`, environment: 'login.microsoftonline.com', tenantId: this.credentials.tenantId, localAccountId: username } }; await this.saveToken(tokenResponse, 'device'); await this.clearDeviceCodeState(); logger.log('MS365 device code authentication completed successfully'); return true; } else if (result.error === 'authorization_pending') { // Still waiting for user to complete authentication return false; } else if (result.error === 'expired_token') { // Device code has expired await this.clearDeviceCodeState(); return false; } else { // Other error - don't clear device code state, just return false logger.error('Device code authentication error:', result); return false; } } catch (error) { logger.error('Error completing device code authentication:', error); // Don't clear device code state on network/other errors return false; } } /** * Get pending device code info from saved state */ async getPendingDeviceCodeInfo() { const deviceCodeState = await this.loadDeviceCodeState(); if (!deviceCodeState) { return null; } return { verificationUri: deviceCodeState.verificationUri, userCode: deviceCodeState.userCode, message: deviceCodeState.message }; } /** * Get authentication URL for device code flow */ async getDeviceCodeInfo() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); return new Promise((resolve, reject) => { const deviceCodeRequest = { scopes: SCOPES, deviceCodeCallback: (response) => { resolve({ verificationUri: response.verificationUri, userCode: response.userCode, message: response.message }); } }; // This will trigger the callback immediately without completing auth msalClient.acquireTokenByDeviceCode(deviceCodeRequest).catch(reject); }); } /** * Start device code authentication and return device code info immediately */ async startDeviceCodeAuth() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); return new Promise((resolve, reject) => { const deviceCodeRequest = { scopes: SCOPES, deviceCodeCallback: async (response) => { const deviceCodeInfo = { verificationUri: response.verificationUri, userCode: response.userCode, message: response.message }; // Save device code state for later completion const deviceCodeState = { deviceCode: response.deviceCode, userCode: response.userCode, verificationUri: response.verificationUri, expiresIn: response.expiresIn, startTime: Date.now(), message: response.message }; try { await this.saveDeviceCodeState(deviceCodeState); logger.log(`Device code authentication started: ${response.verificationUri} - ${response.userCode}`); // Return device code info immediately resolve(deviceCodeInfo); } catch (error) { reject(error); } } }; // Start the device code flow - we need this to run to get the device code // The callback will resolve our promise, and we'll handle the auth later msalClient.acquireTokenByDeviceCode(deviceCodeRequest).then((result) => { // If this completes immediately (unlikely but possible), save the token if (result) { this.saveToken(result, 'device').catch(() => { // Ignore save errors here since we primarily care about getting the device code }); } }).catch((error) => { // Only reject if we haven't already resolved with device code info // The most common case is that the user hasn't completed auth yet if (error.errorCode !== 'user_cancelled' && error.errorCode !== 'authorization_pending') { logger.error('Device code flow error:', error); } }); }); } /** * Wait for pending device code authentication to complete */ async waitForDeviceCodeAuth() { if (!this.pendingAuth) { throw new Error('No pending device code authentication. Call startDeviceCodeAuth first.'); } return await this.pendingAuth.authPromise; } /** * Check if there's a pending device code authentication */ hasPendingAuth() { return this.pendingAuth !== null; } /** * Setup credentials interactively */ async setupCredentials() { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = (prompt) => { return new Promise((resolve) => { rl.question(prompt, resolve); }); }; try { console.log('\nšŸ”§ MS365 MCP Server Credential Setup\n'); console.log('Choose authentication method:'); console.log('1. Device Code Flow (Recommended - no app registration needed)'); console.log('2. Custom Azure App (Advanced - requires app registration)\n'); const choice = await question('Enter your choice (1 or 2): '); if (choice === '1') { // Use built-in app with device code flow const credentials = { clientId: BUILTIN_CLIENT_ID, tenantId: DEFAULT_TENANT_ID, authType: 'device' }; await this.saveCredentials(credentials); console.log('\nāœ… Configured for device code authentication!'); console.log('Run: ms365-mcp-server to start the server\n'); } else if (choice === '2') { console.log('\nCustom Azure App Setup:'); console.log('1. Go to https://portal.azure.com'); console.log('2. Navigate to Azure Active Directory > App registrations'); console.log('3. Click "New registration"'); console.log('4. Set redirect URI to: http://localhost:44001/oauth2callback'); console.log('5. Grant required API permissions for Microsoft Graph\n'); const clientId = await question('Enter your Client ID: '); const clientSecret = await question('Enter your Client Secret (optional for device flow): '); const tenantId = await question('Enter your Tenant ID (or "common" for multi-tenant): '); const authType = clientSecret ? 'redirect' : 'device'; const credentials = { clientId: clientId.trim(), clientSecret: clientSecret.trim() || undefined, tenantId: tenantId.trim(), redirectUri: 'http://localhost:44001/oauth2callback', authType: authType }; await this.saveCredentials(credentials); console.log('\nāœ… Credentials saved successfully!'); console.log('Run: ms365-mcp-server to start the server\n'); } else { console.log('Invalid choice. Setup cancelled.'); } } finally { rl.close(); } } /** * Get storage method information */ getStorageInfo() { return { method: credentialStore.getStorageMethod(), location: credentialStore.getStorageLocation() }; } /** * Get current authenticated user (secure - only your own info) */ async getCurrentUser() { const storedToken = await this.loadStoredToken(); if (storedToken && storedToken.expiresOn > Date.now()) { return storedToken.account?.username || 'authenticated-user'; } return null; } /** * Get authentication URL without opening browser (redirect flow) */ async getAuthUrl() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } if (!this.credentials?.clientSecret) { throw new Error('Client secret required for redirect authentication. Use device code flow instead.'); } const msalClient = this.initializeMsalClient(); const authUrl = await msalClient.getAuthCodeUrl({ scopes: SCOPES, redirectUri: this.credentials.redirectUri, prompt: 'consent' }); return authUrl; } } export const enhancedMS365Auth = new EnhancedMS365Auth();