ms365-mcp-server
Version:
Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support
615 lines (614 loc) ⢠25.1 kB
JavaScript
import { ConfidentialClientApplication, PublicClientApplication, CryptoProvider } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import { URL } from 'url';
import open from 'open';
import { logger } from './api.js';
import { getConfigDirWithFallback } from './config-dir.js';
// OAuth callback port
const CALLBACK_PORT = 44005;
// Built-in fallback client ID (Microsoft Graph CLI Tools)
const BUILTIN_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e';
const DEFAULT_TENANT_ID = 'common';
// Use existing config directory (~/.ms365-mcp/)
const CONFIG_DIR = getConfigDirWithFallback();
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json');
const MSAL_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json');
// Required scopes for Outlook operations
const OUTLOOK_SCOPES = [
'https://graph.microsoft.com/User.Read',
'https://graph.microsoft.com/Mail.Read',
'https://graph.microsoft.com/Mail.Send',
'https://graph.microsoft.com/Mail.ReadWrite',
'https://graph.microsoft.com/MailboxSettings.Read',
'https://graph.microsoft.com/Contacts.Read',
'offline_access'
];
// Token refresh buffer (10 minutes before expiry)
const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000;
/**
* Check if running in MCP context (non-interactive)
*/
function isInMcpContext() {
const args = process.argv.slice(2);
const isCliCommand = args.some(arg => ['--login', '--logout', '--verify-login', '--reset-auth', '--setup-auth'].includes(arg));
// If it's a CLI command, we're NOT in MCP context
if (isCliCommand)
return false;
// Check if stdin is not a TTY (piped input = MCP context)
// or if running via npx in a non-interactive way
return !process.stdin.isTTY || (process.env.npm_execpath?.includes('npx') ?? false);
}
/**
* Outlook OAuth2 Authentication Manager
* Implements OAuth redirect flow with local callback server
*/
export class OutlookAuth {
constructor() {
this.msalClient = null;
this.credentials = null;
this.callbackServer = null;
this.cryptoProvider = new CryptoProvider();
}
/**
* Ensure config directory exists
*/
ensureConfigDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
/**
* Load credentials from environment, stored file, or use fallback
*/
async loadCredentials() {
// Priority 1: Environment variables
if (process.env.OUTLOOK_CLIENT_ID && process.env.OUTLOOK_TENANT_ID) {
this.credentials = {
clientId: process.env.OUTLOOK_CLIENT_ID,
tenantId: process.env.OUTLOOK_TENANT_ID,
clientSecret: process.env.OUTLOOK_CLIENT_SECRET,
redirectUri: process.env.OUTLOOK_REDIRECT_URI || `http://localhost:${CALLBACK_PORT}/oauth2callback`,
authType: process.env.OUTLOOK_CLIENT_SECRET ? 'redirect' : 'device'
};
logger.log('Loaded credentials from environment variables');
return this.credentials;
}
// Priority 2: Stored credentials file (~/.ms365-mcp/credentials.json)
try {
if (fs.existsSync(CREDENTIALS_FILE)) {
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
this.credentials = JSON.parse(data);
logger.log('Loaded credentials from stored file');
return this.credentials;
}
}
catch (error) {
logger.error('Error loading credentials file:', error);
}
// Priority 3: Built-in fallback (no setup required)
this.credentials = {
clientId: BUILTIN_CLIENT_ID,
tenantId: DEFAULT_TENANT_ID,
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
authType: 'redirect'
};
logger.log('Using built-in fallback credentials');
return this.credentials;
}
/**
* Initialize MSAL client with persistent cache
*/
async initializeMsalClient() {
if (this.msalClient) {
return this.msalClient;
}
if (!this.credentials) {
await this.loadCredentials();
}
const isConfidential = !!this.credentials.clientSecret;
// Create persistent token cache plugin using existing ~/.ms365-mcp/ location
const cachePlugin = {
beforeCacheAccess: async (cacheContext) => {
try {
if (fs.existsSync(MSAL_CACHE_FILE)) {
const cacheData = fs.readFileSync(MSAL_CACHE_FILE, 'utf8');
cacheContext.tokenCache.deserialize(cacheData);
}
}
catch (error) {
logger.error('Error loading MSAL cache:', error);
}
},
afterCacheAccess: async (cacheContext) => {
try {
if (cacheContext.cacheHasChanged) {
this.ensureConfigDir();
const cacheData = cacheContext.tokenCache.serialize();
fs.writeFileSync(MSAL_CACHE_FILE, cacheData);
}
}
catch (error) {
logger.error('Error saving MSAL cache:', error);
}
}
};
const config = {
auth: {
clientId: this.credentials.clientId,
authority: `https://login.microsoftonline.com/${this.credentials.tenantId}`,
...(isConfidential && { clientSecret: this.credentials.clientSecret })
},
cache: {
cachePlugin
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (!containsPii) {
logger.log(`MSAL: ${message}`);
}
},
piiLoggingEnabled: false,
logLevel: 3 // Error level
}
}
};
if (isConfidential) {
this.msalClient = new ConfidentialClientApplication(config);
}
else {
this.msalClient = new PublicClientApplication(config);
}
logger.log(`Initialized ${isConfidential ? 'Confidential' : 'Public'} MSAL client`);
return this.msalClient;
}
/**
* Start local callback server for OAuth redirect
*/
startCallbackServer(expectedState) {
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
if (!req.url?.startsWith('/oauth2callback')) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Outlook MCP OAuth2</h1><p>Waiting for authentication...</p></body></html>');
return;
}
try {
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Handle errors from OAuth provider
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`<html><body><h1>Authentication Error</h1><p>${error}: ${errorDescription || 'Unknown error'}</p></body></html>`);
this.closeCallbackServer();
reject(new Error(`OAuth error: ${error} - ${errorDescription}`));
return;
}
// Validate state parameter (CSRF protection)
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Authentication Error</h1><p>Invalid state parameter. Possible CSRF attack.</p></body></html>');
this.closeCallbackServer();
reject(new Error('Invalid state parameter - possible CSRF attack'));
return;
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Authentication Error</h1><p>No authorization code received.</p></body></html>');
this.closeCallbackServer();
reject(new Error('No authorization code received'));
return;
}
// Exchange code for tokens
const msalClient = await this.initializeMsalClient();
const tokenResponse = await msalClient.acquireTokenByCode({
code,
scopes: OUTLOOK_SCOPES,
redirectUri: this.credentials.redirectUri
});
if (!tokenResponse) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Authentication Error</h1><p>Failed to exchange code for tokens.</p></body></html>');
this.closeCallbackServer();
reject(new Error('Failed to exchange authorization code for tokens'));
return;
}
// Success!
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div style="background: white; padding: 40px; border-radius: 10px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h1 style="color: #22c55e; margin-bottom: 10px;">ā Authentication Successful!</h1>
<p style="color: #666;">You can close this window and return to the terminal.</p>
</div>
</body>
</html>
`);
this.closeCallbackServer();
resolve(tokenResponse);
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`);
this.closeCallbackServer();
reject(error);
}
});
server.on('error', (err) => {
reject(new Error(`Failed to start callback server: ${err.message}`));
});
server.listen(CALLBACK_PORT, () => {
logger.log(`OAuth callback server listening on port ${CALLBACK_PORT}`);
});
this.callbackServer = server;
// Timeout after 5 minutes
setTimeout(() => {
if (this.callbackServer) {
this.closeCallbackServer();
reject(new Error('Authentication timed out after 5 minutes'));
}
}, 5 * 60 * 1000);
});
}
/**
* Close callback server
*/
closeCallbackServer() {
if (this.callbackServer) {
this.callbackServer.close();
this.callbackServer = null;
logger.log('Closed OAuth callback server');
}
}
/**
* Generate authorization URL with PKCE and state
*/
async generateAuthUrl() {
const msalClient = await this.initializeMsalClient();
// Generate state for CSRF protection
const state = this.cryptoProvider.createNewGuid();
const authUrl = await msalClient.getAuthCodeUrl({
scopes: OUTLOOK_SCOPES,
redirectUri: this.credentials.redirectUri,
state,
prompt: 'select_account'
});
return { authUrl, state };
}
/**
* Main authentication entry point
* Handles both CLI and MCP contexts
*/
async authenticate() {
await this.loadCredentials();
// Check if we already have valid tokens
const existingToken = await this.getValidToken();
if (existingToken) {
logger.log('Using existing valid token');
return existingToken;
}
const { authUrl, state } = await this.generateAuthUrl();
if (isInMcpContext()) {
// In MCP context - throw error with auth URL for client to handle
const error = new Error('Authentication required');
error.authUrl = authUrl;
error.code = 'AUTH_REQUIRED';
throw error;
}
// CLI context - open browser and wait for callback
console.log('\nš Microsoft 365 Authentication');
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
console.log('Opening browser for authentication...');
console.log(`\nIf browser doesn't open, visit:\n${authUrl}\n`);
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
// Start callback server first, then open browser
const tokenPromise = this.startCallbackServer(state);
try {
await open(authUrl);
}
catch (error) {
console.log('Could not open browser automatically. Please visit the URL above.');
}
const tokenResponse = await tokenPromise;
// Save tokens
await this.saveTokens(tokenResponse);
console.log('\nā
Authentication successful!\n');
return tokenResponse;
}
/**
* Save tokens to existing storage location (~/.ms365-mcp/token.json)
*/
async saveTokens(tokenResponse) {
const tokens = {
accessToken: tokenResponse.accessToken,
refreshToken: undefined, // MSAL handles refresh tokens via cache
expiresOn: tokenResponse.expiresOn?.getTime() || Date.now() + 3600000,
account: tokenResponse.account ? {
username: tokenResponse.account.username,
homeAccountId: tokenResponse.account.homeAccountId,
environment: tokenResponse.account.environment,
tenantId: tokenResponse.account.tenantId,
localAccountId: tokenResponse.account.localAccountId
} : null,
authType: 'redirect'
};
try {
this.ensureConfigDir();
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
logger.log(`Tokens saved to ${TOKEN_FILE}`);
}
catch (error) {
logger.error('Error saving tokens:', error);
throw error;
}
}
/**
* Load tokens from existing storage location (~/.ms365-mcp/token.json)
*/
async loadStoredTokens() {
try {
if (!fs.existsSync(TOKEN_FILE)) {
return null;
}
const data = fs.readFileSync(TOKEN_FILE, 'utf8');
return JSON.parse(data);
}
catch (error) {
logger.error('Error loading tokens:', error);
return null;
}
}
/**
* Clear tokens from storage
*/
async clearTokens() {
try {
if (fs.existsSync(TOKEN_FILE)) {
fs.unlinkSync(TOKEN_FILE);
}
if (fs.existsSync(MSAL_CACHE_FILE)) {
fs.unlinkSync(MSAL_CACHE_FILE);
}
}
catch (error) {
logger.error('Error clearing tokens:', error);
}
}
/**
* Get valid token (checks expiry with buffer)
*/
async getValidToken() {
const storedTokens = await this.loadStoredTokens();
if (!storedTokens) {
return null;
}
// Check if token expires within buffer time
const now = Date.now();
if (storedTokens.expiresOn > now + TOKEN_REFRESH_BUFFER_MS) {
// Token is still valid
return {
accessToken: storedTokens.accessToken,
expiresOn: new Date(storedTokens.expiresOn),
account: storedTokens.account,
scopes: OUTLOOK_SCOPES,
authority: `https://login.microsoftonline.com/${this.credentials?.tenantId || DEFAULT_TENANT_ID}`,
uniqueId: storedTokens.account?.localAccountId || '',
tenantId: storedTokens.account?.tenantId || '',
idToken: '',
idTokenClaims: {},
fromCache: true,
tokenType: 'Bearer',
correlationId: ''
};
}
// Token expired or expiring soon - try to refresh
logger.log('Token expired or expiring soon, attempting refresh...');
return await this.refreshToken();
}
/**
* Refresh token using MSAL silent acquisition
*/
async refreshToken() {
try {
const storedTokens = await this.loadStoredTokens();
if (!storedTokens?.account) {
logger.log('No account info for token refresh');
return null;
}
await this.loadCredentials();
const msalClient = await this.initializeMsalClient();
// Try silent token acquisition
const tokenResponse = await msalClient.acquireTokenSilent({
scopes: OUTLOOK_SCOPES,
account: storedTokens.account
});
if (tokenResponse) {
await this.saveTokens(tokenResponse);
logger.log('Token refreshed successfully');
return tokenResponse;
}
return null;
}
catch (error) {
logger.error('Token refresh failed:', error.message);
// If refresh fails, clear tokens so user can re-authenticate
if (error.errorCode === 'invalid_grant' ||
error.errorCode === 'interaction_required' ||
error.errorCode === 'consent_required') {
await this.clearTokens();
}
return null;
}
}
/**
* Get authenticated Microsoft Graph client
*/
async getGraphClient() {
const token = await this.getValidToken();
if (!token) {
// Try to authenticate
const newToken = await this.authenticate();
return Client.init({
authProvider: (done) => {
done(null, newToken.accessToken);
}
});
}
return Client.init({
authProvider: (done) => {
done(null, token.accessToken);
}
});
}
/**
* Check if user is authenticated
*/
async isAuthenticated() {
const token = await this.getValidToken();
return token !== null;
}
/**
* Get authentication status
*/
async getAuthenticationStatus() {
const storedTokens = await this.loadStoredTokens();
await this.loadCredentials();
if (!storedTokens) {
return {
authenticated: false,
clientId: this.credentials?.clientId,
tenantId: this.credentials?.tenantId
};
}
const now = Date.now();
const isValid = storedTokens.expiresOn > now;
const expiresIn = Math.max(0, Math.floor((storedTokens.expiresOn - now) / 1000 / 60));
return {
authenticated: isValid,
username: storedTokens.account?.username,
expiresAt: new Date(storedTokens.expiresOn).toLocaleString(),
expiresIn: expiresIn,
clientId: this.credentials?.clientId,
tenantId: this.credentials?.tenantId
};
}
/**
* Logout - clear all stored credentials and tokens
*/
async logout() {
this.closeCallbackServer();
await this.clearTokens();
// Also clear credentials file
try {
if (fs.existsSync(CREDENTIALS_FILE)) {
fs.unlinkSync(CREDENTIALS_FILE);
}
}
catch (error) {
logger.error('Error clearing credentials:', error);
}
this.msalClient = null;
this.credentials = null;
logger.log('Logged out successfully');
}
/**
* Reset auth - clear everything
*/
async resetAuth() {
await this.logout();
console.log('ā
All authentication data cleared');
}
/**
* Get current user info
*/
async getCurrentUser() {
const storedTokens = await this.loadStoredTokens();
if (storedTokens?.account?.username) {
return storedTokens.account.username;
}
return null;
}
/**
* Save credentials to storage
*/
async saveCredentials(credentials) {
try {
this.ensureConfigDir();
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
logger.log(`Credentials saved to ${CREDENTIALS_FILE}`);
}
catch (error) {
logger.error('Error saving credentials:', error);
throw error;
}
}
/**
* Interactive credential setup
*/
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š§ Outlook MCP Server Credential Setup\n');
console.log('Choose setup method:');
console.log('1. Use built-in credentials (Recommended - no Azure setup needed)');
console.log('2. Use custom Azure App credentials\n');
const choice = await question('Enter choice (1 or 2): ');
if (choice === '1') {
const credentials = {
clientId: BUILTIN_CLIENT_ID,
tenantId: DEFAULT_TENANT_ID,
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
authType: 'redirect'
};
await this.saveCredentials(credentials);
console.log('\nā
Configured with built-in credentials!');
console.log('Run: ms365-mcp-server --login to authenticate\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:${CALLBACK_PORT}/oauth2callback`);
console.log('5. Grant required API permissions for Microsoft Graph\n');
const clientId = await question('Enter Client ID: ');
const tenantId = await question('Enter Tenant ID (or "common"): ');
const clientSecret = await question('Enter Client Secret (optional, press Enter to skip): ');
const credentials = {
clientId: clientId.trim(),
tenantId: tenantId.trim() || DEFAULT_TENANT_ID,
clientSecret: clientSecret.trim() || undefined,
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
authType: 'redirect'
};
await this.saveCredentials(credentials);
console.log('\nā
Credentials saved!');
console.log('Run: ms365-mcp-server --login to authenticate\n');
}
else {
console.log('Invalid choice. Setup cancelled.');
}
}
finally {
rl.close();
}
}
}
export const outlookAuth = new OutlookAuth();