UNPKG

ms365-mcp-server

Version:

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

364 lines (363 loc) 13.1 kB
import { ConfidentialClientApplication } 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 { 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' ]; // Configuration directory and file paths const CONFIG_DIR = getConfigDirWithFallback(); const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json'); const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json'); /** * Microsoft 365 authentication manager class */ export class MS365Auth { constructor() { this.msalClient = null; this.credentials = null; 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 file or environment */ async loadCredentials() { try { // Try environment variables first if (process.env.MS365_CLIENT_ID && process.env.MS365_CLIENT_SECRET && 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' }; logger.log('Loaded MS365 credentials from environment variables'); return true; } // Try 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; } return false; } 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 */ initializeMsalClient() { if (!this.credentials) { throw new Error('Credentials not loaded'); } const config = { auth: { clientId: this.credentials.clientId, clientSecret: this.credentials.clientSecret, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}` }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (!containsPii) { logger.log(`MSAL: ${message}`); } }, piiLoggingEnabled: false, logLevel: 3 // Error level } } }; this.msalClient = new ConfidentialClientApplication(config); return this.msalClient; } /** * Load stored access token */ loadStoredToken() { try { if (fs.existsSync(TOKEN_FILE)) { const tokenData = fs.readFileSync(TOKEN_FILE, 'utf8'); return JSON.parse(tokenData); } } catch (error) { logger.error('Error loading stored token:', error); } return null; } /** * Save access token to file */ saveToken(token) { try { const tokenData = { accessToken: token.accessToken, refreshToken: '', // MSAL handles refresh tokens internally expiresOn: token.expiresOn?.getTime() || 0, account: token.account }; fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2)); logger.log('Saved MS365 access token'); } catch (error) { logger.error('Error saving token:', error); } } /** * Start local server for OAuth2 callback */ 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); }); }); } /** * Perform OAuth2 authentication flow */ async authenticate() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); try { // Generate authorization URL const authUrl = await msalClient.getAuthCodeUrl({ scopes: SCOPES, redirectUri: this.credentials.redirectUri, prompt: 'consent' }); logger.log('Opening browser for authentication...'); // Start callback server and open browser const [authCode] = await Promise.all([ this.startCallbackServer(), open(authUrl) ]); // Exchange code for token const tokenResponse = await msalClient.acquireTokenByCode({ code: authCode, scopes: SCOPES, redirectUri: this.credentials.redirectUri }); if (!tokenResponse) { throw new Error('Failed to acquire token'); } this.saveToken(tokenResponse); logger.log('MS365 authentication successful'); } catch (error) { logger.error('Authentication failed:', error); throw error; } } /** * Get authenticated Microsoft Graph client */ async getGraphClient() { const storedToken = this.loadStoredToken(); if (!storedToken) { throw new Error('No stored token found. Please authenticate first.'); } // Check if token is expired if (storedToken.expiresOn < Date.now()) { await this.refreshToken(); } const client = Client.init({ authProvider: (done) => { done(null, storedToken.accessToken); } }); return client; } /** * Refresh access token using refresh token */ async refreshToken() { const storedToken = this.loadStoredToken(); if (!storedToken?.account) { throw new Error('No account information available. Please re-authenticate.'); } if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); try { const tokenResponse = await msalClient.acquireTokenSilent({ scopes: SCOPES, account: storedToken.account }); if (!tokenResponse) { throw new Error('Failed to refresh token'); } this.saveToken(tokenResponse); logger.log('MS365 token refreshed successfully'); } catch (error) { logger.error('Token refresh failed:', error); throw error; } } /** * Check if user is authenticated */ async isAuthenticated() { const storedToken = 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 */ resetAuth() { try { if (fs.existsSync(TOKEN_FILE)) { fs.unlinkSync(TOKEN_FILE); logger.log('Cleared stored authentication tokens'); } } catch (error) { logger.error('Error clearing authentication data:', error); } } /** * Get authentication URL without opening browser */ async getAuthUrl() { if (!await this.loadCredentials()) { throw new Error('MS365 credentials not configured'); } const msalClient = this.initializeMsalClient(); const authUrl = await msalClient.getAuthCodeUrl({ scopes: SCOPES, redirectUri: this.credentials.redirectUri, prompt: 'consent' }); return authUrl; } /** * 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('You need to register an application in Azure Portal first:'); 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: '); const tenantId = await question('Enter your Tenant ID (or "common" for multi-tenant): '); const redirectUri = await question('Enter redirect URI (default: http://localhost:44001/oauth2callback): ') || 'http://localhost:44001/oauth2callback'; const credentials = { clientId: clientId.trim(), clientSecret: clientSecret.trim(), tenantId: tenantId.trim(), redirectUri: redirectUri.trim() }; await this.saveCredentials(credentials); console.log('\n✅ Credentials saved successfully!'); console.log('You can now run: ms365-mcp-server\n'); } finally { rl.close(); } } } export const ms365Auth = new MS365Auth();