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
JavaScript
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();