andrew-dataverse-mcp
Version:
A Model Context Protocol server for Microsoft Dataverse with comprehensive CRUD operations
313 lines (311 loc) • 17.2 kB
JavaScript
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}`,
};
}
}