Microsoft Todo MCP Service
by jhirono
Verified
- todoMCP
- src
// Authentication server for Microsoft Todo MCP service
import dotenv from 'dotenv';
import express from 'express';
import fs from 'fs';
import { join } from 'path';
import { ConfidentialClientApplication } from '@azure/msal-node';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Initialize environment variables
dotenv.config();
console.log('Environment loaded');
console.log('CLIENT_ID:', process.env.CLIENT_ID ? 'Present (hidden)' : 'Missing');
console.log('CLIENT_SECRET:', process.env.CLIENT_SECRET ? 'Present (hidden)' : 'Missing');
console.log('TENANT_ID:', process.env.TENANT_ID ? process.env.TENANT_ID : 'Not specified, using "organizations" (multi-tenant)');
console.log('REDIRECT_URI:', process.env.REDIRECT_URI || `http://localhost:3000/callback`);
// Get current file directory in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const port = 3000;
const TOKEN_FILE_PATH = join(process.cwd(), 'tokens.json');
// Determine the tenant ID to use:
// - 'common' for both organization accounts and personal accounts
// - 'organizations' for organization accounts only (multi-tenant)
// - 'consumers' for personal accounts only
// - A specific tenant ID for a single organization
const tenantId = process.env.TENANT_ID || 'organizations';
// Display authentication type
if (tenantId === 'common') {
console.log('Authentication type: Both organization and personal accounts (common)');
} else if (tenantId === 'organizations') {
console.log('Authentication type: Organizations only (multi-tenant)');
} else if (tenantId === 'consumers') {
console.log('Authentication type: Personal accounts only');
console.log('WARNING: Microsoft To Do API has limitations for personal accounts (MailboxNotEnabledForRESTAPI error may occur)');
} else {
console.log(`Authentication type: Single tenant (${tenantId})`);
}
// MSAL configuration for delegated permissions
const msalConfig = {
auth: {
clientId: process.env.CLIENT_ID,
authority: `https://login.microsoftonline.com/${tenantId}`,
clientSecret: process.env.CLIENT_SECRET,
},
system: {
loggerOptions: {
loggerCallback(loglevel, message, containsPii) {
console.log(`MSAL Log: ${message}`);
},
piiLoggingEnabled: true
}
},
cache: {
cachePlugin: {
beforeCacheAccess: async (cacheContext) => {
console.log('Cache access requested:', cacheContext);
return null;
},
afterCacheAccess: async (cacheContext) => {
console.log('Cache access completed:', cacheContext);
return null;
}
}
}
};
console.log('MSAL config created');
// Task-related permission scopes
const scopes = [
'offline_access', // Put offline_access first to ensure it's not dropped
'openid', // Add openid scope
'profile', // Add profile scope
'Tasks.Read',
'Tasks.Read.Shared',
'Tasks.ReadWrite',
'Tasks.ReadWrite.Shared',
'User.Read'
];
// Create MSAL application
const cca = new ConfidentialClientApplication(msalConfig);
console.log('MSAL application created');
// Setup a test route to check if server is working
app.get('/test', (req, res) => {
res.send('Auth server is running correctly');
});
// Helper function to refresh an access token
async function refreshAccessToken() {
try {
// Get account info from the token cache
const tokenCache = cca.getTokenCache();
const accounts = await tokenCache.getAllAccounts();
if (accounts.length === 0) {
console.log('No accounts found in the token cache');
return { success: false, error: 'No accounts found in token cache' };
}
// Get the first account (we should have only one in this scenario)
const account = accounts[0];
console.log('Found account in token cache:', {
username: account.username,
localAccountId: account.localAccountId,
tenantId: account.tenantId
});
// Create a silent request using the account
const silentRequest = {
account: account,
scopes: scopes,
forceRefresh: true
};
console.log('Attempting to acquire token silently...');
const response = await cca.acquireTokenSilent(silentRequest);
console.log('Token refreshed successfully');
return {
success: true,
response: response,
accessToken: response.accessToken,
expiresAt: Date.now() + ((response.expiresIn || 3600) * 1000) - (5 * 60 * 1000)
};
} catch (error) {
console.error('Error refreshing token silently:', error);
return {
success: false,
error: error
};
}
}
// Update refresh endpoint to use acquireTokenSilent
app.get('/refresh', async (req, res) => {
try {
const result = await refreshAccessToken();
if (result.success) {
// Save updated token data
const tokenData = {
accessToken: result.accessToken,
expiresAt: result.expiresAt,
tokenType: result.response.tokenType,
scopes: result.response.scopes
};
// Save updated token data
fs.writeFileSync(TOKEN_FILE_PATH, JSON.stringify(tokenData, null, 2), 'utf8');
res.json({
success: true,
message: 'Token refreshed successfully',
expiresAt: new Date(result.expiresAt).toISOString()
});
} else {
// If silent refresh fails, redirect to login
console.log('Silent token refresh failed, redirecting to login');
res.json({
success: false,
message: 'Token refresh failed, please login again',
redirectUrl: '/'
});
}
} catch (error) {
console.error('Error in refresh route:', error);
res.status(500).send(`Error refreshing token: ${error.message}`);
}
});
// Add a client credentials flow endpoint
app.get('/silentLogin', async (req, res) => {
try {
console.log('Silent login endpoint accessed');
// Client credentials flow requires different scopes format
// We use resource/.default pattern here
const clientCredentialRequest = {
scopes: ['https://graph.microsoft.com/.default'],
skipCache: true // Force request to go to the server
};
console.log('Attempting client credentials flow with scopes:', clientCredentialRequest.scopes);
const response = await cca.acquireTokenByClientCredential(clientCredentialRequest);
console.log('Client credentials response received', {
hasAccessToken: !!response.accessToken,
tokenType: response.tokenType,
expiresOn: response.expiresOn,
scopes: response.scopes
});
// Get token cache after successful client credentials flow
const tokenCache = cca.getTokenCache();
const serializedCache = await tokenCache.serialize();
const cacheJson = JSON.parse(serializedCache);
console.log('Token cache after client credentials flow:', {
hasRefreshTokens: !!cacheJson.RefreshTokens,
hasRefreshToken: !!cacheJson.RefreshToken,
cacheKeys: Object.keys(cacheJson)
});
// Check if we have any tokens that look like refresh tokens
let refreshTokenFound = false;
for (const key in cacheJson) {
if (key.toLowerCase().includes('refresh')) {
refreshTokenFound = true;
console.log(`Found potential refresh token section: ${key}`);
}
}
if (!refreshTokenFound) {
console.log('No refresh token sections found in cache after client credentials flow');
}
// Client credentials flow won't typically have a refresh token
// since it's an app-only flow with no user context
res.json({
success: true,
message: 'Client credentials flow completed',
accessTokenPresent: !!response.accessToken,
expiresOn: response.expiresOn
});
} catch (error) {
console.error('Error in silent login:', error);
res.status(500).send(`Error in silent login: ${error.message}`);
}
});
// Setup the auth flow
app.get('/', (req, res) => {
console.log('Root route accessed, generating auth URL...');
// Display information about potential issues for personal accounts
let authInfo = `
<html>
<head>
<title>Microsoft To Do MCP Authentication</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
.container { max-width: 800px; margin: 0 auto; }
.warning { background-color: #fff3cd; border: 1px solid #ffeeba; padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.primary-button { background-color: #0078d4; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<div class="container">
<h1>Microsoft To Do MCP Authentication</h1>
`;
// Add warning for personal accounts
if (tenantId === 'consumers' || tenantId === 'common') {
authInfo += `
<div class="warning">
<h3>⚠️ Important Note for Personal Microsoft Accounts</h3>
<p>The Microsoft Graph API has limitations for personal Microsoft accounts (outlook.com, hotmail.com, live.com, etc.).
The To Do API is primarily designed for Microsoft 365 business accounts, not personal accounts.</p>
<p>If you use a personal Microsoft account, you may encounter a <strong>"MailboxNotEnabledForRESTAPI"</strong> error.
This is a Microsoft service limitation, not an issue with this application's code or authentication setup.</p>
</div>
`;
}
authInfo += `
<p>Click the button below to authenticate with Microsoft and grant access to your To Do tasks.</p>
<button class="primary-button" onclick="window.location.href='/auth'">Sign in with Microsoft</button>
</div>
</body>
</html>
`;
res.send(authInfo);
});
// Setup the actual auth route
app.get('/auth', (req, res) => {
console.log('Auth route accessed, generating auth URL...');
const authCodeUrlParameters = {
scopes: scopes,
redirectUri: process.env.REDIRECT_URI || `http://localhost:${port}/callback`,
prompt: 'consent', // Use only consent to force refresh token
responseMode: 'query',
};
console.log('Auth parameters:', {
scopes: scopes,
redirectUri: process.env.REDIRECT_URI || `http://localhost:${port}/callback`,
prompt: 'consent',
responseMode: 'query',
});
cca.getAuthCodeUrl(authCodeUrlParameters)
.then((response) => {
console.log('Auth URL generated, redirecting to:', response.substring(0, 80) + '...');
res.redirect(response);
})
.catch((error) => {
console.error('Error getting auth code URL:', error);
res.status(500).send(`Error generating authentication URL: ${JSON.stringify(error)}`);
});
});
// Handle the callback from Microsoft login
app.get('/callback', (req, res) => {
console.log('Callback route accessed');
console.log('Query parameters:', {
code: req.query.code ? 'Present (hidden)' : 'Missing',
state: req.query.state ? 'Present' : 'Missing',
error: req.query.error || 'None',
error_description: req.query.error_description || 'None'
});
const tokenRequest = {
code: req.query.code,
scopes: scopes,
redirectUri: process.env.REDIRECT_URI || `http://localhost:${port}/callback`,
};
console.log('Token request parameters:', {
scopes: scopes,
redirectUri: process.env.REDIRECT_URI || `http://localhost:${port}/callback`,
});
cca.acquireTokenByCode(tokenRequest)
.then(async (response) => {
try {
// Log full response structure (without sensitive values)
console.log('Token response structure:', {
keys: Object.keys(response),
hasAccessToken: !!response.accessToken,
hasRefreshToken: !!response.refreshToken,
hasIdToken: !!response.idToken,
tokenType: response.tokenType,
expiresIn: response.expiresIn,
expiresOn: response.expiresOn,
scopes: response.scopes,
account: response.account ? {
username: response.account.username,
tenantId: response.account.tenantId,
localAccountId: response.account.localAccountId
} : null
});
// Get refresh token from token cache
const tokenCache = cca.getTokenCache();
const serializedCache = await tokenCache.serialize();
const cacheJson = JSON.parse(serializedCache);
// Log the full cache structure for debugging (excluding sensitive values)
console.log('Full token cache structure keys:', Object.keys(cacheJson));
if (cacheJson.RefreshToken) {
console.log('RefreshToken keys in cache:', Object.keys(cacheJson.RefreshToken));
} else if (cacheJson.RefreshTokens) {
console.log('RefreshTokens keys in cache:', Object.keys(cacheJson.RefreshTokens));
}
// Try different ways to get the refresh token
let refreshToken = null;
// Method 1: Check RefreshTokens (plural)
if (cacheJson.RefreshTokens && Object.keys(cacheJson.RefreshTokens).length > 0) {
const refreshTokenKeys = Object.keys(cacheJson.RefreshTokens);
refreshToken = cacheJson.RefreshTokens[refreshTokenKeys[0]].secret;
console.log('Refresh token found using RefreshTokens collection');
}
// Method 2: Check RefreshToken (singular)
else if (cacheJson.RefreshToken && Object.keys(cacheJson.RefreshToken).length > 0) {
const refreshTokenKeys = Object.keys(cacheJson.RefreshToken);
refreshToken = cacheJson.RefreshToken[refreshTokenKeys[0]].secret;
console.log('Refresh token found using RefreshToken collection');
}
// Method 3: Look for any key with "refresh" in it
else {
for (const cacheSection in cacheJson) {
if (cacheSection.toLowerCase().includes('refresh') && typeof cacheJson[cacheSection] === 'object') {
for (const key in cacheJson[cacheSection]) {
if (cacheJson[cacheSection][key] && cacheJson[cacheSection][key].secret) {
refreshToken = cacheJson[cacheSection][key].secret;
console.log(`Refresh token found in ${cacheSection}.${key}`);
break;
}
}
if (refreshToken) break;
}
}
}
if (!refreshToken) {
console.log('Could not find refresh token in token cache');
}
// Calculate token expiration (make sure it's never null)
const expiresInSeconds = response.expiresIn || 3600;
const expiresAt = Date.now() + (expiresInSeconds * 1000) - (5 * 60 * 1000);
console.log('Token expiration details:', {
expiresInSeconds,
expiresAt: new Date(expiresAt).toLocaleString(),
currentTime: new Date().toLocaleString()
});
// Store tokens
const tokenData = {
accessToken: response.accessToken,
refreshToken: refreshToken || '',
expiresAt: expiresAt,
tokenType: response.tokenType,
scopes: response.scopes
};
fs.writeFileSync(TOKEN_FILE_PATH, JSON.stringify(tokenData, null, 2), 'utf8');
console.log('Authentication successful! Token saved to:', TOKEN_FILE_PATH);
console.log('Refresh token obtained:', refreshToken ? 'Yes' : 'No');
// Format token display with safety checks
const accessTokenDisplay = response.accessToken ?
`${response.accessToken.substring(0, 15)}...${response.accessToken.substring(response.accessToken.length - 5)}` :
'Not provided';
const refreshTokenDisplay = refreshToken ?
`${refreshToken.substring(0, 10)}...${refreshToken.substring(refreshToken.length - 5)}` :
'Not provided';
// Check if the account is a personal account
const isPersonalAccount = response.account &&
(response.account.username.includes('@outlook.com') ||
response.account.username.includes('@hotmail.com') ||
response.account.username.includes('@live.com') ||
response.account.username.includes('@msn.com'));
let warningMessage = '';
if (isPersonalAccount) {
warningMessage = `
<div class="warning">
<h3>⚠️ Important Note for Personal Microsoft Accounts</h3>
<p>You are signed in with a personal Microsoft account (${response.account.username}).</p>
<p>The Microsoft Graph API has limitations for personal Microsoft accounts. The To Do API is primarily designed for Microsoft 365 business accounts, not personal accounts.</p>
<p>You may encounter a <strong>"MailboxNotEnabledForRESTAPI"</strong> error when trying to access To Do tasks. This is a Microsoft service limitation, not an issue with this application's code or authentication setup.</p>
</div>
`;
}
res.send(`
<html>
<head>
<title>Authentication Successful</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
.container { max-width: 800px; margin: 0 auto; }
.success { background-color: #d4edda; border: 1px solid #c3e6cb; padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.warning { background-color: #fff3cd; border: 1px solid #ffeeba; padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.token-details { background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 20px; }
.debug-info { margin-top: 30px; border-top: 1px solid #dee2e6; padding-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="success">
<h1>Authentication Successful!</h1>
<p>You can now close this window and use the Microsoft Todo MCP service.</p>
</div>
${warningMessage}
<div class="token-details">
<h3>Token Details:</h3>
<ul>
<li>Access Token: ${accessTokenDisplay}</li>
<li>Refresh Token: ${refreshTokenDisplay}</li>
<li>Token Type: ${response.tokenType || 'Not provided'}</li>
<li>Scopes: ${response.scopes ? response.scopes.join(', ') : 'Not provided'}</li>
<li>Expires: ${new Date(expiresAt).toLocaleString()}</li>
</ul>
</div>
<div class="debug-info">
<h3>Debug Information:</h3>
<pre>${JSON.stringify({
hasRefreshToken: !!refreshToken,
tokenType: response.tokenType,
scopes: response.scopes,
cacheHasRefreshTokens: cacheJson.RefreshTokens && Object.keys(cacheJson.RefreshTokens).length > 0
}, null, 2)}</pre>
</div>
</div>
</body>
</html>
`);
} catch (error) {
console.error('Error saving token:', error);
res.status(500).send(`Error saving token: ${error.message}`);
}
})
.catch((error) => {
console.error('Token acquisition error:', {
errorCode: error.errorCode,
errorMessage: error.errorMessage,
subError: error.subError,
correlationId: error.correlationId,
stack: error.stack
});
res.status(500).send(`Error acquiring token: ${JSON.stringify(error)}`);
});
});
// Start the server
app.listen(port, () => {
console.log(`Auth server running at http://localhost:${port}`);
console.log('Open your browser and navigate to the URL above to authenticate.');
console.log('Or try http://localhost:3000/test to verify the server is running.');
});