outlook-mcp
Version:
Comprehensive MCP server for Claude to access Microsoft Outlook and Teams via Microsoft Graph API - including Email, Calendar, Contacts, Tasks, Teams, Chats, and Online Meetings
277 lines (254 loc) • 11 kB
JavaScript
const fs = require('fs').promises;
const path = require('path');
const https = require('https');
const querystring = require('querystring');
class TokenStorage {
constructor(config) {
this.config = {
tokenStorePath: path.join(process.env.HOME || process.env.USERPROFILE, '.outlook-mcp-tokens.json'),
clientId: process.env.MS_CLIENT_ID,
clientSecret: process.env.MS_CLIENT_SECRET,
redirectUri: process.env.MS_REDIRECT_URI || 'http://localhost:3333/auth/callback',
scopes: (process.env.MS_SCOPES || 'offline_access User.Read Mail.Read').split(' '),
tokenEndpoint: process.env.MS_TOKEN_ENDPOINT || 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
refreshTokenBuffer: 5 * 60 * 1000, // 5 minutes buffer for token refresh
...config // Allow overriding default config
};
this.tokens = null;
this._loadPromise = null;
this._refreshPromise = null;
if (!this.config.clientId || !this.config.clientSecret) {
console.warn("TokenStorage: MS_CLIENT_ID or MS_CLIENT_SECRET is not configured. Token operations might fail.");
}
}
async _loadTokensFromFile() {
try {
const tokenData = await fs.readFile(this.config.tokenStorePath, 'utf8');
this.tokens = JSON.parse(tokenData);
console.log('Tokens loaded from file.');
return this.tokens;
} catch (error) {
if (error.code === 'ENOENT') {
console.log('Token file not found. No tokens loaded.');
} else {
console.error('Error loading token cache:', error);
}
this.tokens = null;
return null;
}
}
async _saveTokensToFile() {
if (!this.tokens) {
console.warn('No tokens to save.');
return false;
}
try {
await fs.writeFile(this.config.tokenStorePath, JSON.stringify(this.tokens, null, 2));
console.log('Tokens saved successfully.');
// return true; // No longer returning boolean, will throw on error.
} catch (error) {
console.error('Error saving token cache:', error);
throw error; // Propagate the error
}
}
async getTokens() {
if (this.tokens) {
return this.tokens;
}
if (!this._loadPromise) {
this._loadPromise = this._loadTokensFromFile().finally(() => {
this._loadPromise = null; // Reset promise once completed
});
}
return this._loadPromise;
}
getExpiryTime() {
return this.tokens && this.tokens.expires_at ? this.tokens.expires_at : 0;
}
isTokenExpired() {
if (!this.tokens || !this.tokens.expires_at) {
return true; // No token or no expiry means it's effectively expired or invalid
}
// Check if current time is past expiry time, considering a buffer
return Date.now() >= (this.tokens.expires_at - this.config.refreshTokenBuffer);
}
async getValidAccessToken() {
await this.getTokens(); // Ensure tokens are loaded
if (!this.tokens || !this.tokens.access_token) {
console.log('No access token available.');
return null;
}
if (this.isTokenExpired()) {
console.log('Access token expired or nearing expiration. Attempting refresh.');
if (this.tokens.refresh_token) {
try {
return await this.refreshAccessToken();
} catch (refreshError) {
console.error('Failed to refresh access token:', refreshError);
this.tokens = null; // Invalidate tokens on refresh failure
await this._saveTokensToFile(); // Persist invalidation
return null;
}
} else {
console.warn('No refresh token available. Cannot refresh access token.');
this.tokens = null; // Invalidate tokens as they are expired and cannot be refreshed
await this._saveTokensToFile(); // Persist invalidation
return null;
}
}
return this.tokens.access_token;
}
async refreshAccessToken() {
if (!this.tokens || !this.tokens.refresh_token) {
throw new Error('No refresh token available to refresh the access token.');
}
// Prevent multiple concurrent refresh attempts
if (this._refreshPromise) {
console.log("Refresh already in progress, returning existing promise.");
return this._refreshPromise.then(tokens => tokens.access_token);
}
console.log('Attempting to refresh access token...');
const postData = querystring.stringify({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
grant_type: 'refresh_token',
refresh_token: this.tokens.refresh_token,
scope: this.config.scopes.join(' ')
});
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
this._refreshPromise = new Promise((resolve, reject) => {
const req = https.request(this.config.tokenEndpoint, requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', async () => {
try {
const responseBody = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) {
this.tokens.access_token = responseBody.access_token;
// Microsoft Graph API refresh tokens may or may not return a new refresh_token
if (responseBody.refresh_token) {
this.tokens.refresh_token = responseBody.refresh_token;
}
this.tokens.expires_in = responseBody.expires_in;
this.tokens.expires_at = Date.now() + (responseBody.expires_in * 1000);
try {
await this._saveTokensToFile();
console.log('Access token refreshed and saved successfully.');
resolve(this.tokens);
} catch (saveError) {
console.error('Failed to save refreshed tokens:', saveError);
// Even if save fails, tokens are updated in memory.
// Depending on desired strictness, could reject here.
// For now, resolve with in-memory tokens but log critical error.
// Or, to be stricter and align with re-throwing:
reject(new Error(`Access token refreshed but failed to save: ${saveError.message}`));
}
} else {
console.error('Error refreshing token:', responseBody);
reject(new Error(responseBody.error_description || `Token refresh failed with status ${res.statusCode}`));
}
} catch (e) { // Catch any error during parsing or saving
console.error('Error processing refresh token response or saving tokens:', e);
reject(e);
} finally {
this._refreshPromise = null; // Clear promise after completion
}
});
});
req.on('error', (error) => {
console.error('HTTP error during token refresh:', error);
reject(error);
this._refreshPromise = null; // Clear promise on error
});
req.write(postData);
req.end();
});
return this._refreshPromise.then(tokens => tokens.access_token);
}
async exchangeCodeForTokens(authCode) {
if (!this.config.clientId || !this.config.clientSecret) {
throw new Error("Client ID or Client Secret is not configured. Cannot exchange code for tokens.");
}
console.log('Exchanging authorization code for tokens...');
const postData = querystring.stringify({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
grant_type: 'authorization_code',
code: authCode,
redirect_uri: this.config.redirectUri,
scope: this.config.scopes.join(' ')
});
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(this.config.tokenEndpoint, requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', async () => {
try {
const responseBody = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) {
this.tokens = {
access_token: responseBody.access_token,
refresh_token: responseBody.refresh_token,
expires_in: responseBody.expires_in,
expires_at: Date.now() + (responseBody.expires_in * 1000),
scope: responseBody.scope,
token_type: responseBody.token_type
};
try {
await this._saveTokensToFile();
console.log('Tokens exchanged and saved successfully.');
resolve(this.tokens);
} catch (saveError) {
console.error('Failed to save exchanged tokens:', saveError);
// Similar to refresh, tokens are in memory but not persisted.
// Rejecting to indicate the operation wasn't fully successful.
reject(new Error(`Tokens exchanged but failed to save: ${saveError.message}`));
}
} else {
console.error('Error exchanging code for tokens:', responseBody);
reject(new Error(responseBody.error_description || `Token exchange failed with status ${res.statusCode}`));
}
} catch (e) { // Catch any error during parsing or saving
console.error('Error processing token exchange response or saving tokens:', e, "Raw data:", data);
reject(new Error(`Error processing token response: ${e.message}. Response data: ${data}`));
}
});
});
req.on('error', (error) => {
console.error('HTTP error during code exchange:', error);
reject(error);
});
req.write(postData);
req.end();
});
}
// Utility to clear tokens, e.g., for logout or forcing re-auth
async clearTokens() {
this.tokens = null;
try {
await fs.unlink(this.config.tokenStorePath);
console.log('Token file deleted successfully.');
} catch (error) {
if (error.code === 'ENOENT') {
console.log('Token file not found, nothing to delete.');
} else {
console.error('Error deleting token file:', error);
}
}
}
}
module.exports = TokenStorage;
// Adding a newline at the end of the file as requested by Gemini Code Assist