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