UNPKG

msteams-mcp-server

Version:

Microsoft Teams MCP Server - Complete Teams integration for Claude Desktop and MCP clients with secure OAuth2 authentication and comprehensive team management

821 lines (820 loc) • 34.3 kB
import { ConfidentialClientApplication, PublicClientApplication } from '@azure/msal-node'; import { Client } from '@microsoft/microsoft-graph-client'; import fs from 'fs'; import path from 'path'; import os from 'os'; import open from 'open'; import { createServer } from 'http'; import { URL } from 'url'; import { logger } from './api.js'; import { credentialStore } from './credential-store.js'; // Scopes required for Microsoft Teams operations const TEAMS_SCOPES = [ 'https://graph.microsoft.com/User.Read', 'https://graph.microsoft.com/Team.ReadBasic.All', 'https://graph.microsoft.com/Channel.ReadBasic.All', 'https://graph.microsoft.com/ChannelMessage.Send', 'https://graph.microsoft.com/ChannelMessage.ReadWrite', 'https://graph.microsoft.com/Chat.ReadWrite', 'https://graph.microsoft.com/ChatMessage.Send', 'https://graph.microsoft.com/ChatMessage.read', 'https://graph.microsoft.com/ChannelMessage.Read.All', 'offline_access' ]; // Built-in application for easier setup (Microsoft Graph Command Line Tools) const BUILTIN_CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; const DEFAULT_TENANT_ID = "common"; // Function to find the actual config directory function findConfigDir() { const possiblePaths = [ process.env.MSTEAMS_CONFIG_DIR, // Environment override (highest priority) path.join(os.homedir(), '.msteams-mcp'), // Default path '/home/siya/.msteams-mcp', // Fallback for server deployments path.join('/home', process.env.USER || 'siya', '.msteams-mcp') // Dynamic user fallback ].filter(Boolean); // Remove undefined values for (const configPath of possiblePaths) { if (fs.existsSync(configPath)) { logger.log(`Found config directory at: ${configPath}`); return configPath; } } // If no existing directory found, use the default const defaultPath = path.join(os.homedir(), '.msteams-mcp'); logger.log(`No existing config directory found, using default: ${defaultPath}`); return defaultPath; } // Configuration directory and file paths const CONFIG_DIR = findConfigDir(); const TOKEN_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json'); // Add utility to detect MCP context function isInMcpContext() { // Check if we're running CLI commands (should use interactive flow) const args = process.argv.slice(2); const isCliCommand = args.some(arg => arg === '--login' || arg === '--logout' || arg === '--verify-login' || arg === '--reset-auth' || arg === '--setup-auth' || arg === '--check-permissions' || arg === '--admin-consent-help' || arg === '--azure-setup'); // If running CLI commands, always use interactive flow if (isCliCommand) { return false; } // Detect if we're running in MCP context by checking if stdin is not a TTY // and we're not in an interactive terminal session return !process.stdin.isTTY || (process.env.npm_execpath?.includes('npx') ?? false); } // Add utility function for safe console output function safeConsoleLog(message) { if (isInMcpContext()) { // In MCP context, redirect to stderr to avoid breaking JSON-RPC protocol console.error(message); } else { // In interactive mode, use normal stdout console.log(message); } } /** * Microsoft Teams authentication manager with OAuth redirect flow */ export class TeamsAuth { msalClient = null; credentials = null; preferredAuthMethod = 'redirect'; callbackResolve = null; callbackServer = null; pendingAuthUrl = null; constructor(authMethod = 'redirect') { this.preferredAuthMethod = authMethod; this.ensureConfigDir(); } /** * Ensure configuration directory exists */ ensureConfigDir() { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); logger.log(`Created config directory: ${CONFIG_DIR}`); } } /** * Load authentication credentials */ async loadCredentials() { try { // Method 1: Environment variables (highest priority) if (process.env.TEAMS_CLIENT_ID && process.env.TEAMS_TENANT_ID) { this.credentials = { clientId: process.env.TEAMS_CLIENT_ID, clientSecret: process.env.TEAMS_CLIENT_SECRET, tenantId: process.env.TEAMS_TENANT_ID, redirectUri: process.env.TEAMS_REDIRECT_URI || 'http://localhost:44005/oauth2callback', // Always use redirect flow authType: 'redirect' }; logger.log('Loaded Teams credentials from environment variables'); // Save environment credentials to storage for future use await this.saveCredentials(this.credentials); return true; } // Method 2: Stored credentials file const storedCredentials = await credentialStore.getCredentials(); if (storedCredentials) { this.credentials = storedCredentials; logger.log('Loaded Teams credentials from stored file'); return true; } // Method 3: Built-in application (fallback with limited scopes) this.credentials = { clientId: BUILTIN_CLIENT_ID, tenantId: DEFAULT_TENANT_ID, redirectUri: 'http://localhost:44005/oauth2callback', authType: 'redirect' }; logger.log('Using built-in Teams application with redirect flow'); // Save built-in credentials to storage try { await this.saveCredentials(this.credentials); logger.log('Built-in credentials saved to storage'); } catch (error) { logger.error('Failed to save built-in credentials:', error); } return true; } catch (error) { logger.error('Failed to load credentials:', error); return false; } } /** * Save authentication credentials */ async saveCredentials(credentials) { try { await credentialStore.storeCredentials(credentials); logger.log('Credentials saved successfully'); } catch (error) { logger.error('Failed to save credentials:', error); throw error; } } /** * Get MSAL cache plugin for persistent token storage */ getMsalCachePlugin() { const cachePlugin = { beforeCacheAccess: async (cacheContext) => { try { if (fs.existsSync(TOKEN_CACHE_FILE)) { const cacheData = fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'); cacheContext.cacheHasChanged = true; cacheContext.tokenCache.deserialize(cacheData); logger.log('MSAL cache loaded from file'); } } catch (error) { logger.error('Failed to load MSAL cache:', error); } }, afterCacheAccess: async (cacheContext) => { try { if (cacheContext.cacheHasChanged) { const cacheData = cacheContext.tokenCache.serialize(); fs.writeFileSync(TOKEN_CACHE_FILE, cacheData, { mode: 0o600 }); logger.log('MSAL cache saved to file'); } } catch (error) { logger.error('Failed to save MSAL cache:', error); } } }; return cachePlugin; } /** * Initialize MSAL client */ initializeMsalClient() { if (!this.credentials) { throw new Error('No credentials loaded'); } const clientConfig = { auth: { clientId: this.credentials.clientId, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}` }, cache: { cachePlugin: this.getMsalCachePlugin() } }; // Use PublicClientApplication when no client secret is available (built-in client) if (!this.credentials.clientSecret) { this.msalClient = new PublicClientApplication(clientConfig); logger.log('MSAL PublicClientApplication initialized (no client secret)'); } else { // Add client secret for ConfidentialClientApplication clientConfig.auth.clientSecret = this.credentials.clientSecret; this.msalClient = new ConfidentialClientApplication(clientConfig); logger.log('MSAL ConfidentialClientApplication initialized (with client secret)'); } return this.msalClient; } /** * Get required scopes for Teams operations */ getScopes() { return TEAMS_SCOPES; } /** * Get a valid token from storage, automatically refreshing if needed */ async getValidToken() { try { const storedTokens = await credentialStore.getTokens(); if (!storedTokens) { logger.log('No stored tokens found'); return null; } // Enhanced logging for debugging const currentTime = Date.now(); const tokenExpiry = storedTokens.expiresOn; const timeUntilExpiry = tokenExpiry - currentTime; logger.log(`Current time: ${new Date(currentTime).toISOString()}`); logger.log(`Token expires: ${new Date(tokenExpiry).toISOString()}`); logger.log(`Time until expiry: ${Math.round(timeUntilExpiry / 1000 / 60)} minutes`); // Add buffer time (10 minutes) to trigger refresh early const bufferTime = 10 * 60 * 1000; // 10 minutes in milliseconds if (storedTokens && (storedTokens.expiresOn - bufferTime) > currentTime) { logger.log('Found valid stored token (with 10min buffer)'); return { accessToken: storedTokens.accessToken, account: storedTokens.account, expiresOn: new Date(storedTokens.expiresOn), scopes: this.getScopes(), tokenType: 'Bearer' }; } // Token is expired or expiring soon - try to refresh it logger.log('Token is expired or expiring soon, attempting refresh...'); const refreshedToken = await this.refreshToken(storedTokens); if (refreshedToken) { logger.log('Token refreshed successfully'); return refreshedToken; } logger.log('Failed to refresh token, returning null'); return null; } catch (error) { logger.error('Failed to get valid token:', error); return null; } } /** * Refresh an expired or expiring token using the refresh token */ async refreshToken(storedTokens) { try { if (!storedTokens.refreshToken) { logger.log('No refresh token available'); return null; } await this.loadCredentials(); if (!this.credentials) { logger.error('No credentials available for token refresh'); return null; } const client = this.initializeMsalClient(); logger.log('Attempting to refresh token using refresh token...'); // Use MSAL refresh token method const refreshRequest = { refreshToken: storedTokens.refreshToken, scopes: this.getScopes(), account: storedTokens.account }; const response = await client.acquireTokenByRefreshToken(refreshRequest); if (response) { logger.log('Token refresh successful'); // Save the new tokens await this.saveToken(response, 'redirect'); return response; } else { logger.warn('Token refresh returned no response'); return null; } } catch (error) { logger.error('Token refresh failed:', error); // If refresh fails, try silent token acquisition as fallback try { logger.log('Attempting silent token acquisition as fallback...'); const silentToken = await this.acquireTokenSilently(storedTokens); if (silentToken) { logger.log('Silent token acquisition successful'); return silentToken; } } catch (silentError) { logger.error('Silent token acquisition also failed:', silentError); } return null; } } /** * Acquire token silently using cached account information */ async acquireTokenSilently(storedTokens) { try { await this.loadCredentials(); if (!this.credentials) { return null; } const client = this.initializeMsalClient(); if (!storedTokens.account) { logger.log('No account information available for silent acquisition'); return null; } const silentRequest = { scopes: this.getScopes(), account: storedTokens.account }; const response = await client.acquireTokenSilent(silentRequest); if (response) { logger.log('Silent token acquisition successful'); await this.saveToken(response, 'redirect'); return response; } return null; } catch (error) { logger.log('Silent token acquisition failed (this is expected when tokens are fully expired)'); return null; } } /** * Save authentication token */ async saveToken(result, authType) { try { const tokens = { accessToken: result.accessToken, refreshToken: result.refreshToken || '', expiresOn: result.expiresOn ? result.expiresOn.getTime() : Date.now() + (60 * 60 * 1000), // 1 hour default account: result.account, authType: authType }; // Enhanced logging for token saving logger.log(`Saving token - Access token length: ${result.accessToken.length}`); logger.log(`Refresh token available: ${!!result.refreshToken}`); logger.log(`Token expires at: ${new Date(tokens.expiresOn).toISOString()}`); logger.log(`Account info available: ${!!result.account}`); await credentialStore.storeTokens(tokens); logger.log('Authentication token saved successfully'); } catch (error) { logger.error('Failed to save authentication token:', error); throw error; } } /** * Check if authentication is already in progress */ isAuthenticationInProgress() { return this.callbackServer !== null || this.pendingAuthUrl !== null; } /** * Clean up authentication state */ cleanupAuthState() { this.pendingAuthUrl = null; this.callbackResolve = null; if (this.callbackServer) { this.callbackServer.close(() => { logger.log('Callback server closed during cleanup'); }); this.callbackServer = null; } } /** * Get pending authentication URL if authentication is in progress */ getPendingAuthUrl() { return this.pendingAuthUrl; } /** * Main authentication method using OAuth redirect flow */ async authenticate() { logger.log('authenticate() method started'); await this.loadCredentials(); logger.log('loadCredentials() completed'); // Try to get existing valid token first const existingToken = await this.getValidToken(); logger.log(`getValidToken() completed - found token: ${!!existingToken}`); if (existingToken) { logger.log('Using existing valid token'); return existingToken; } // In MCP context, handle authentication differently if (isInMcpContext()) { logger.log('MCP context detected - using MCP authentication flow'); return await this.authenticateForMcp(); } // Always use OAuth redirect flow for interactive mode logger.log('Starting OAuth redirect authentication'); return await this.authenticateWithRedirect(); } /** * Authenticate for MCP context - start server if needed and return auth URL */ async authenticateForMcp() { // First check if authentication was completed in background const existingToken = await this.getValidToken(); if (existingToken) { logger.log('Found valid token from background authentication completion'); this.cleanupAuthState(); // Clean up any stale state return existingToken; } // In server environments, if we have credentials but no valid token, // try to use the stored token anyway if it exists (even if close to expiry) try { const storedTokens = await credentialStore.getTokens(); if (storedTokens && storedTokens.accessToken) { logger.log('Attempting to use stored token despite potential expiry (server fallback)'); const fallbackToken = { accessToken: storedTokens.accessToken, account: storedTokens.account, expiresOn: new Date(storedTokens.expiresOn), scopes: this.getScopes(), tokenType: 'Bearer' }; // Test the token by making a simple Graph API call try { const testUrl = 'https://graph.microsoft.com/v1.0/me'; const testResponse = await fetch(testUrl, { headers: { 'Authorization': `Bearer ${storedTokens.accessToken}`, 'Content-Type': 'application/json' } }); if (testResponse.ok) { logger.log('Stored token is still valid despite timestamp check'); return fallbackToken; } else { logger.log(`Token test failed with status: ${testResponse.status}`); } } catch (testError) { logger.error('Token validation test failed:', testError); } } } catch (error) { logger.error('Error during token fallback check:', error); } // Check if authentication is already in progress if (this.isAuthenticationInProgress()) { logger.log('Authentication already in progress, returning existing auth URL'); const existingAuthUrl = this.getPendingAuthUrl(); if (existingAuthUrl) { const redirectError = new Error(`OAuth authentication required: ${existingAuthUrl}`); redirectError.authUrl = existingAuthUrl; redirectError.redirectUri = this.credentials.redirectUri; throw redirectError; } } // Only try to generate new auth URL if we can actually handle the redirect if (!this.credentials) { throw new Error('No credentials available for authentication'); } const client = this.initializeMsalClient(); try { // Start callback server if not already running if (!this.callbackServer) { logger.log('Starting callback server for MCP authentication...'); await this.startCallbackServer(); logger.log('Callback server started'); } // Generate authentication URL const authCodeUrlParameters = { scopes: this.getScopes(), redirectUri: this.credentials.redirectUri, state: process.env.USER_ID || 'localhost' }; logger.log('Getting authorization URL from MSAL...'); const authUrl = await client.getAuthCodeUrl(authCodeUrlParameters); logger.log(`Authorization URL generated: ${authUrl}`); // Store the pending auth URL this.pendingAuthUrl = authUrl; // In MCP context, throw the auth URL so the MCP handler can return it to the user const redirectError = new Error(`OAuth authentication required: ${authUrl}`); redirectError.authUrl = authUrl; redirectError.redirectUri = this.credentials.redirectUri; throw redirectError; } catch (error) { logger.error('MCP authentication failed:', error); // Don't cleanup on OAuth redirect errors as they're expected if (!error.authUrl) { this.cleanupAuthState(); } throw error; } } /** * Authenticate using OAuth redirect flow */ async authenticateWithRedirect() { const client = this.initializeMsalClient(); try { // Start callback server (non-blocking) logger.log('Starting callback server...'); await this.startCallbackServer(); logger.log('Callback server started, generating authorization URL...'); // Get authorization URL const authCodeUrlParameters = { scopes: this.getScopes(), redirectUri: this.credentials.redirectUri, state: process.env.USER_ID || 'localhost' }; logger.log('Getting authorization URL from MSAL...'); const authUrl = await client.getAuthCodeUrl(authCodeUrlParameters); logger.log(`Authorization URL generated: ${authUrl}`); logger.log(`Opening authentication URL: ${authUrl}`); // In MCP context, return the URL to the user instead of trying to open browser if (isInMcpContext()) { // Create a custom error that contains the authorization URL const redirectError = new Error(`OAuth authentication required: ${authUrl}`); redirectError.authUrl = authUrl; redirectError.redirectUri = this.credentials.redirectUri; throw redirectError; } // For interactive sessions, show the URL and optionally open browser safeConsoleLog(`\nšŸ” Microsoft Teams Authentication Required`); safeConsoleLog(`\n🌐 Please complete authentication:`); safeConsoleLog(`1. Visit: ${authUrl}`); safeConsoleLog(`2. Sign in with your Microsoft account`); safeConsoleLog(`3. Complete the authorization process`); safeConsoleLog(`\nšŸ’” Direct link: ${authUrl}`); safeConsoleLog(`\nā³ Waiting for authentication completion...`); // Try to open browser automatically try { await open(authUrl); logger.log('Browser opened successfully'); } catch (error) { logger.warn(`Failed to open browser automatically: ${error}`); } // Wait for callback const authCode = await this.waitForCallback(); logger.log(`Received authorization code: ${authCode.substring(0, 20)}...`); // Exchange code for tokens const response = await client.acquireTokenByCode({ scopes: this.getScopes(), redirectUri: this.credentials.redirectUri, code: authCode }); if (response) { safeConsoleLog('āœ… Authentication successful!'); // Save credentials and token await this.saveCredentials(this.credentials); await this.saveToken(response, 'redirect'); return response; } else { throw new Error('Authentication failed: No response received'); } } catch (error) { logger.error('Redirect authentication failed:', error); // Don't cleanup on OAuth redirect errors as they're expected if (!error.authUrl) { this.cleanupAuthState(); } throw error; } } /** * Start callback server for redirect flow (non-blocking) */ startCallbackServer() { return new Promise((resolve, reject) => { // Check if server is already running if (this.callbackServer) { logger.log('Callback server already running, reusing existing instance'); resolve(); return; } const server = createServer((req, res) => { if (req.url?.startsWith('/oauth2callback')) { const url = new URL(req.url, 'http://localhost'); const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); const state = url.searchParams.get('state'); // Validate state parameter const expectedState = process.env.USER_ID || 'localhost'; if (state !== expectedState) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(`<h1>Authentication Error</h1><p>Invalid state parameter. Expected: ${expectedState}, Received: ${state}</p>`); if (this.callbackResolve) { this.callbackResolve.reject(new Error(`Invalid state parameter: expected ${expectedState}, received ${state}`)); } server.close(() => { this.callbackServer = null; this.pendingAuthUrl = null; }); return; } if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(`<h1>Authentication Error</h1><p>${error}</p>`); if (this.callbackResolve) { this.callbackResolve.reject(new Error(`Authentication error: ${error}`)); } server.close(() => { this.callbackServer = null; this.pendingAuthUrl = null; }); return; } if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<h1>Authentication Successful!</h1><p>You can close this window.</p>'); logger.log(`Received authorization code: ${code.substring(0, 20)}...`); // Resolve the callback promise with the authorization code if (this.callbackResolve) { this.callbackResolve.resolve(code); } else { // If no callback promise waiting, process the code immediately (background completion) this.processRedirectCallback(code).catch(error => { logger.error('Error processing redirect callback in background:', error); }); } server.close(() => { this.callbackServer = null; this.pendingAuthUrl = null; logger.log('Callback server closed after authentication'); }); } else { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<h1>Authentication Error</h1><p>No authorization code received</p>'); if (this.callbackResolve) { this.callbackResolve.reject(new Error('No authorization code received')); } server.close(() => { this.callbackServer = null; this.pendingAuthUrl = null; }); } } else { res.writeHead(404, { 'Content-Type': 'text/html' }); res.end('<h1>Not Found</h1>'); } }); server.on('error', (error) => { logger.error('Callback server error:', error); // Clean up server reference on error this.callbackServer = null; // If port is already in use, try to reuse existing connection if (error.code === 'EADDRINUSE') { logger.log('Port 44005 already in use, assuming existing server is available'); resolve(); return; } reject(error); }); // Store server reference for cleanup this.callbackServer = server; server.listen(44005, 'localhost', () => { logger.log(`Callback server started on port 44005`); resolve(); }); }); } /** * Wait for OAuth callback */ waitForCallback() { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.callbackResolve = null; reject(new Error('Authentication timeout')); }, 300000); // 5 minutes // Store resolve/reject functions for callback server to use this.callbackResolve = { resolve: (code) => { clearTimeout(timeout); this.callbackResolve = null; resolve(code); }, reject: (error) => { clearTimeout(timeout); this.callbackResolve = null; reject(error); } }; }); } /** * Process redirect callback in background */ async processRedirectCallback(code) { try { const client = this.initializeMsalClient(); const response = await client.acquireTokenByCode({ scopes: this.getScopes(), redirectUri: this.credentials.redirectUri, code: code }); if (response) { await this.saveCredentials(this.credentials); await this.saveToken(response, 'redirect'); logger.log('Background authentication completed successfully'); // Reset authentication state after successful completion this.pendingAuthUrl = null; } } catch (error) { logger.error('Failed to process redirect callback:', error); } } /** * Get current authentication status */ async getAuthenticationStatus() { try { const storedTokens = await credentialStore.getTokens(); if (!storedTokens) { return { isAuthenticated: false, message: 'No authentication tokens found' }; } if (storedTokens.expiresOn <= Date.now()) { return { isAuthenticated: false, message: 'Authentication tokens have expired' }; } return { isAuthenticated: true, user: storedTokens.account, message: 'Authentication is valid' }; } catch (error) { logger.error('Failed to get authentication status:', error); return { isAuthenticated: false, message: 'Failed to check authentication status' }; } } /** * Logout and clear all stored data */ async logout() { try { await credentialStore.clearAll(); this.credentials = null; this.msalClient = null; this.pendingAuthUrl = null; // Close callback server if it's running if (this.callbackServer) { this.callbackServer.close(() => { logger.log('Callback server closed during logout'); }); this.callbackServer = null; } logger.log('Logout completed successfully'); } catch (error) { logger.error('Failed to logout:', error); throw error; } } /** * Get authenticated Graph API client */ async getGraphClient() { const token = await this.authenticate(); const client = Client.init({ authProvider: async () => { return token.accessToken; } }); return client; } } /** * Custom error for when OAuth redirect authentication is required */ export class OAuthRedirectRequiredError extends Error { authUrl; redirectUri; constructor(authUrl, redirectUri) { super(`OAuth redirect authentication required: ${authUrl}`); this.name = 'OAuthRedirectRequiredError'; this.authUrl = authUrl; this.redirectUri = redirectUri; } } // Export singleton instance export const teamsAuth = new TeamsAuth();