UNPKG

andrew-dataverse-mcp

Version:

A Model Context Protocol server for Microsoft Dataverse with comprehensive CRUD operations

313 lines (311 loc) 17.2 kB
import { ConfidentialClientApplication, PublicClientApplication } from '@azure/msal-node'; import { getConfig } from '../config/environment.js'; import path from 'path'; import os from 'os'; import fs from 'fs'; export class DataverseAuth { msalInstance; cachedToken = null; tokenExpiryTime = 0; usePersonalAuth = false; authenticationInProgress = false; constructor() { console.error('[Auth] Initializing Dataverse authentication...'); const config = getConfig(); // Check if we should use personal authentication (no client secret provided) this.usePersonalAuth = !config.dataverse.clientSecret || config.dataverse.clientSecret.toLowerCase() === 'personal'; // Create a global cache directory const globalCacheDir = path.join(os.homedir(), '.dataverse-mcp-cache'); if (!fs.existsSync(globalCacheDir)) { fs.mkdirSync(globalCacheDir, { recursive: true }); } if (this.usePersonalAuth) { console.error('[Auth] Using personal account authentication (Device Code Flow)'); console.error(`[Auth] Cache directory: ${globalCacheDir}`); this.msalInstance = new PublicClientApplication({ auth: { clientId: config.dataverse.clientId, authority: `https://login.microsoftonline.com/${config.dataverse.tenantId}`, }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { // Only log errors to avoid spam if (level === 1) { // Error level console.error('[MSAL]', message); } }, piiLoggingEnabled: false, logLevel: 1, // Error only } } }); } else { console.error('[Auth] Using application credentials authentication'); this.msalInstance = new ConfidentialClientApplication({ auth: { clientId: config.dataverse.clientId, clientSecret: config.dataverse.clientSecret, authority: `https://login.microsoftonline.com/${config.dataverse.tenantId}`, }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (level === 1) { // Error level console.error('[MSAL]', message); } }, piiLoggingEnabled: false, logLevel: 1, // Error only } } }); } console.error('[Auth] MSAL client initialized successfully'); } /** * Get a valid access token, refreshing if necessary */ async getAccessToken() { try { // Check if we have a valid cached token if (this.cachedToken && Date.now() < this.tokenExpiryTime) { console.error('[Auth] Using cached access token'); return this.cachedToken.access_token; } if (this.usePersonalAuth) { return await this.getPersonalAccessToken(); } else { return await this.getApplicationAccessToken(); } } catch (error) { console.error('[Auth] Failed to acquire access token:', error); // Provide helpful error message for personal auth failures if (this.usePersonalAuth) { const helpMessage = this.getPersonalAuthHelpMessage(); console.error(helpMessage); throw new Error(`Personal authentication required. See console output for instructions.`); } throw new Error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get access token using application credentials (Client Credentials Flow) */ async getApplicationAccessToken() { console.error('[Auth] Requesting new application access token...'); const config = getConfig(); const clientCredentialRequest = { scopes: [`${config.dataverse.environmentUrl}/.default`], }; const response = await this.msalInstance.acquireTokenByClientCredential(clientCredentialRequest); if (!response) { throw new Error('Failed to acquire access token - no response received'); } this.cacheToken(response); return this.cachedToken.access_token; } /** * Get access token using personal account (Device Code Flow) */ async getPersonalAccessToken() { console.error('[Auth] Requesting personal access token...'); const config = getConfig(); // First try to get token silently (from cache) try { const accounts = await this.msalInstance.getTokenCache().getAllAccounts(); if (accounts.length > 0) { console.error('[Auth] Attempting silent token acquisition...'); const silentRequest = { account: accounts[0], scopes: [`${config.dataverse.environmentUrl}/user_impersonation`], }; const response = await this.msalInstance.acquireTokenSilent(silentRequest); if (response) { this.cacheToken(response); return this.cachedToken.access_token; } } } catch (error) { console.error('[Auth] Silent token acquisition failed, proceeding with device code flow'); } // Check if authentication is already in progress if (this.authenticationInProgress) { throw new Error('Authentication already in progress. Please complete the browser authentication.'); } // Detect if we're running in MCP context (but allow device code flow to proceed) const isMCPContext = process.env.NODE_ENV !== 'development' && (!process.stdout.isTTY || process.env.MCP_SERVER === 'true' || !process.stdin.isTTY); if (isMCPContext) { console.error('[Auth] Running in MCP context - device code flow will be attempted'); console.error('[Auth] Watch the VS Code/Cursor output window for authentication instructions'); } // Perform device code flow (works in both interactive and MCP contexts) return await this.performDeviceCodeFlow(); } /** * Generate MCP-specific authentication error with actionable steps */ getMCPAuthenticationError() { const config = getConfig(); return ` ╔════════════════════════════════════════════════════════════════════════════════╗ ║ 🔐 MCP AUTHENTICATION REQUIRED ║ ╠════════════════════════════════════════════════════════════════════════════════╣ ║ The Dataverse MCP server needs authentication but is running in MCP context. ║ ║ ║ ║ 🔧 SOLUTION: Pre-authenticate in your terminal ║ ║ ║ ║ Run this command in VS Code terminal: ║ ║ npx -y andrew-dataverse-mcp --auth --clientId "${config.dataverse.clientId.substring(0, 8)}..." \\ ║ ║ --tenantId "${config.dataverse.tenantId.substring(0, 8)}..." \\ ║ ║ --environmentUrl "https://your-environment.crm.dynamics.com" ║ ║ ║ ║ 🎯 This will: ║ ║ 1. Show device code for browser authentication ║ ║ 2. Cache your credentials for MCP use ║ ║ 3. Allow future MCP calls to work automatically ║ ║ ║ ║ 📝 Note: You only need to do this once (until token expires) ║ ╚════════════════════════════════════════════════════════════════════════════════╝ Command to run: npx -y andrew-dataverse-mcp --auth --clientId ${config.dataverse.clientId} --tenantId ${config.dataverse.tenantId} --environmentUrl ${config.dataverse.environmentUrl} `; } /** * Perform device code flow with proper error handling and messaging */ async performDeviceCodeFlow() { this.authenticationInProgress = true; try { console.error('[Auth] Starting device code flow for personal authentication...'); const config = getConfig(); let deviceCodeInfo = null; const deviceCodeRequest = { deviceCodeCallback: (response) => { deviceCodeInfo = response; console.error('\n' + '═'.repeat(80)); console.error('🔐 DATAVERSE AUTHENTICATION REQUIRED'); console.error('═'.repeat(80)); console.error(''); console.error('📱 STEP 1: Open your web browser and go to:'); console.error(` ${response.verificationUri}`); console.error(''); console.error('🔑 STEP 2: Enter this code when prompted:'); console.error(` ${response.userCode}`); console.error(''); console.error(`⏰ This code expires in ${Math.floor(response.expiresIn / 60)} minutes`); console.error(''); console.error('🎯 What happens next:'); console.error(' • Your browser will show a Microsoft sign-in page'); console.error(' • Sign in with your account that has Dataverse access'); console.error(' • Complete any multi-factor authentication if required'); console.error(' • Grant permission for the app to access Dataverse'); console.error(' • This authentication will be cached for future use'); console.error(''); console.error('⏳ Waiting for you to complete authentication in your browser...'); console.error(' (This window will automatically continue once you\'re done)'); console.error('═'.repeat(80)); console.error(''); }, scopes: [`${config.dataverse.environmentUrl}/user_impersonation`], timeout: 300, // 5 minutes timeout }; const response = await this.msalInstance.acquireTokenByDeviceCode(deviceCodeRequest); if (!response) { throw new Error('Failed to acquire access token via device code flow'); } this.cacheToken(response); console.error('✅ Personal authentication completed successfully!'); console.error('🎉 Token cached - you won\'t need to authenticate again for a while.'); return this.cachedToken.access_token; } catch (error) { console.error('❌ Device code authentication failed:', error); // Provide helpful guidance based on error type if (error instanceof Error) { if (error.message.includes('timeout') || error.message.includes('expired')) { console.error('\n⏰ Authentication timed out. This can happen if:'); console.error(' • You didn\'t complete authentication within the time limit'); console.error(' • Network connectivity issues occurred'); console.error(' • The authentication process was interrupted'); console.error('\n🔄 To retry authentication, run the command again.'); } else if (error.message.includes('canceled') || error.message.includes('denied')) { console.error('\n🚫 Authentication was canceled or denied.'); console.error(' • Make sure to grant permission to the application'); console.error(' • Check that your account has access to the Dataverse environment'); } } throw error; } finally { this.authenticationInProgress = false; } } /** * Generate helpful message for personal authentication setup */ getPersonalAuthHelpMessage() { const config = getConfig(); return ` ╔════════════════════════════════════════════════════════════════════════════════╗ ║ 🔐 AUTHENTICATION REQUIRED ║ ╠════════════════════════════════════════════════════════════════════════════════╣ ║ The Dataverse MCP server needs to authenticate with your personal account. ║ ║ ║ ║ 💡 SOLUTION OPTIONS: ║ ║ ║ ║ 1️⃣ PRE-AUTHENTICATE (Recommended for npx users): ║ ║ Run this command once to cache authentication: ║ ║ npx dataverse-mcp --auth --clientId "${config.dataverse.clientId.substring(0, 8)}..." ║ ║ ║ ║ 2️⃣ USE APPLICATION CREDENTIALS (For persistent setups): ║ ║ Set up Azure AD app registration with client secret ║ ║ ║ ║ 3️⃣ EXTEND TIMEOUT (For immediate retry): ║ ║ Try the same command again - authentication will retry ║ ║ ║ ║ 📚 For detailed setup instructions, visit: ║ ║ https://github.com/your-repo/dataverse-mcp#authentication ║ ╚════════════════════════════════════════════════════════════════════════════════╝ `; } /** * Cache the authentication response */ cacheToken(response) { this.cachedToken = { access_token: response.accessToken, token_type: 'Bearer', expires_in: response.expiresOn ? Math.floor((response.expiresOn.getTime() - Date.now()) / 1000) : 3600, expires_on: response.expiresOn ? Math.floor(response.expiresOn.getTime() / 1000) : Math.floor(Date.now() / 1000) + 3600, }; // Set expiry time with 5-minute buffer this.tokenExpiryTime = (response.expiresOn?.getTime() || Date.now() + 3600000) - 300000; console.error('[Auth] Access token acquired successfully'); console.error(`[Auth] Token expires at: ${new Date(this.tokenExpiryTime).toISOString()}`); } /** * Clear the cached token (useful for testing or forcing refresh) */ clearTokenCache() { console.error('[Auth] Clearing token cache'); this.cachedToken = null; this.tokenExpiryTime = 0; } /** * Get authorization header for API requests */ async getAuthorizationHeader() { const token = await this.getAccessToken(); return { Authorization: `Bearer ${token}`, }; } }