@auth0/auth0-mcp-server
Version:
Auth0 Model Context Protocol (MCP) Server (Beta) — A secure and extendable implementation of an MCP server that provides AI assistants with controlled access to the Auth0 Management API through natural language. This project is in beta and not intended fo
409 lines • 15.1 kB
JavaScript
import chalk from 'chalk';
import { jwtDecode } from 'jwt-decode';
import readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import { getAllScopes } from './scopes.js';
/**
* Generic function to prompt user for a choice from a list of options
*/
export async function promptForChoice(question, choices, defaultChoice) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log(chalk.cyan(`\n${question}`));
choices.forEach((choice, index) => {
console.log(`${index + 1}. ${choice.label}`);
});
rl.question(chalk.yellow(`Enter your choice (1-${choices.length}): `), (answer) => {
rl.close();
const choice = answer.trim();
const choiceIndex = parseInt(choice, 10) - 1;
if (choiceIndex >= 0 && choiceIndex < choices.length) {
resolve(choices[choiceIndex].value);
}
else {
// Try to match by label (case insensitive)
const matchedChoice = choices.find((c) => c.value.toLowerCase() === choice.toLowerCase() ||
c.label.toLowerCase().includes(choice.toLowerCase()));
if (matchedChoice) {
resolve(matchedChoice.value);
}
else if (defaultChoice) {
console.log(chalk.yellow(`Invalid choice, defaulting to ${defaultChoice}.`));
resolve(defaultChoice);
}
else {
console.log(chalk.yellow(`Invalid choice, defaulting to first option.`));
resolve(choices[0].value);
}
}
});
});
}
/**
* Generic function to prompt user for a file/directory path with validation
*/
export async function promptForPath(question, options = {}) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
const askForPath = () => {
rl.question(chalk.yellow(question), (input) => {
const trimmedInput = input.trim();
if (options.required && !trimmedInput) {
console.log(chalk.red('Path is required'));
askForPath();
return;
}
if (!trimmedInput) {
rl.close();
resolve('');
return;
}
const resolvedPath = path.resolve(trimmedInput);
if (options.mustExist && !fs.existsSync(resolvedPath)) {
console.log(chalk.red(`Path does not exist: ${resolvedPath}`));
askForPath();
return;
}
if (options.mustBeDirectory &&
fs.existsSync(resolvedPath) &&
!fs.statSync(resolvedPath).isDirectory()) {
console.log(chalk.red(`Path is not a directory: ${resolvedPath}`));
askForPath();
return;
}
if (options.mustBeFile &&
fs.existsSync(resolvedPath) &&
!fs.statSync(resolvedPath).isFile()) {
console.log(chalk.red(`Path is not a file: ${resolvedPath}`));
askForPath();
return;
}
rl.close();
resolve(resolvedPath);
});
};
askForPath();
});
}
/**
* Handles terminal interactions, output formatting, spinners, and user input
*/
class Terminal {
spinnerInterval = null;
currentMessage = '';
spinnerConfig = {
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
interval: 80,
};
audiencePath = '/api/v2/';
/**
* Writes a message to stdout
*
* @param {string} message - The message to output
* @returns {boolean} - Always returns true
*/
output(message) {
process.stdout.write(message);
return true;
}
/**
* Starts a spinner with the given message
*
* @param {string} message - Message to display alongside the spinner
*/
startSpinner(message) {
let frameIndex = 0;
this.currentMessage = message;
// Clear any existing spinner
this.stopSpinnerWithoutMessage();
// Initial spinner state
process.stdout.write(`\r${chalk.cyan(this.spinnerConfig.frames[0])} ${message}`);
this.spinnerInterval = setInterval(() => {
process.stdout.write(`\r${chalk.cyan(this.spinnerConfig.frames[frameIndex])} ${message}`);
frameIndex = (frameIndex + 1) % this.spinnerConfig.frames.length;
}, this.spinnerConfig.interval);
}
/**
* Stops the spinner and clears the line without showing a completion message
*/
stopSpinnerWithoutMessage() {
if (this.spinnerInterval) {
clearInterval(this.spinnerInterval);
this.spinnerInterval = null;
// Clear the spinner line
process.stdout.write('\r\x1B[K');
}
}
/**
* Stops the spinner and shows a completion message
*/
stopSpinner() {
if (this.spinnerInterval) {
this.stopSpinnerWithoutMessage();
this.output(`${chalk.green('✓')} ${this.currentMessage}\n`);
}
}
/**
* Extracts tenant information from an access token
*
* @param {string} accessToken - JWT access token
* @returns {string} - The tenant hostname
* @throws {Error} - If tenant extraction fails
*/
getTenantFromToken(accessToken) {
try {
const payload = jwtDecode(accessToken);
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
for (const aud of audiences) {
const url = new URL(aud);
if (url.pathname === this.audiencePath) {
return url.host;
}
}
throw new Error('No valid audience found in token');
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to extract tenant: ${errorMessage}`);
}
}
/**
* Prompts user for permission to open browser
*
* @returns {Promise<boolean>} - Resolves to true when user presses Enter
*/
async promptForBrowserPermission() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question('', () => {
rl.close();
resolve(true);
});
});
}
/**
* Prompts user to select scopes or uses provided ones
*
* @param {string[]} [providedScopes] - Optional pre-selected scopes
* @returns {Promise<string[]>} - Resolves to array of selected scopes
*/
async promptForScopeSelection(providedScopes) {
// If providedScopes were specified via --scopes flag, return them directly
if (providedScopes && providedScopes.length > 0) {
console.log(chalk.green(`Using provided scopes: ${providedScopes.join(', ')}`));
return providedScopes;
}
const scopeSelector = new ScopeSelector();
return scopeSelector.selectScopes();
}
}
/**
* Handles interactive scope selection in the terminal
*/
class ScopeSelector {
allScopes = [];
selectedScopes = new Set();
currentIndex = 0;
/**
* Creates a new scope selector instance
*/
constructor() {
this.allScopes = getAllScopes();
}
/**
* Displays the interactive scope selection UI
*
* @returns {Promise<string[]>} - Resolves to an array of selected scopes
*/
async selectScopes() {
// Display header
console.log(chalk.cyan.bold('\nSelect scopes using spacebar, then press Enter to confirm:'));
console.log(chalk.dim('(Press spacebar to toggle selection, arrows to navigate, Enter when done)'));
console.log();
// Only add newlines if there are actually scopes to display
if (this.allScopes.length === 0) {
console.log(chalk.yellow('No scopes available to select.'));
return [];
}
// Hide cursor
process.stdout.write('\x1B[?25l');
// Handle keyboard input
process.stdin.setRawMode(true);
process.stdin.resume();
// Add initial newlines for menu display
this.allScopes.forEach(() => console.log(''));
// Display the menu initially
this.renderMenu();
return this.handleKeyboardInput();
}
/**
* Sets up event listeners for keyboard input during scope selection
*
* @returns {Promise<string[]>} - Resolves to the selected scopes
*/
handleKeyboardInput() {
return new Promise((resolve) => {
process.stdin.on('data', (key) => {
const keyString = String(key);
if (this.handleControlKey(keyString, resolve)) {
return;
}
if (keyString === ' ') {
this.toggleCurrentSelection();
}
else if (keyString.startsWith('\u001B[')) {
this.handleArrowKeys(keyString);
}
});
});
}
/**
* Handles control keys (Enter, Ctrl+C) during selection
*
* @param {string} keyString - The key input string
* @param {(scopes: string[]) => void} resolve - The promise resolve function
* @returns {boolean} - True if a control key was handled, false otherwise
*/
handleControlKey(keyString, resolve) {
if (keyString === '\r' || keyString === '\n') {
// Enter key - finish selection
this.cleanup();
const result = Array.from(this.selectedScopes);
if (result.length === 0) {
console.log(chalk.yellow('No scopes selected.'));
}
else {
console.log(chalk.green(`Selected scopes: ${result.join(', ')}`));
}
resolve(result);
return true;
}
if (keyString === '\u0003') {
// Ctrl+C - exit
this.cleanup();
console.log(chalk.red('Selection cancelled.'));
process.exit(0);
return true;
}
return false;
}
/**
* Toggles selection of the current scope
*/
toggleCurrentSelection() {
const scope = this.allScopes[this.currentIndex];
if (this.selectedScopes.has(scope)) {
this.selectedScopes.delete(scope);
}
else {
this.selectedScopes.add(scope);
}
this.renderMenu();
}
/**
* Handles arrow key navigation
*
* @param {string} keyString - The key input string
*/
handleArrowKeys(keyString) {
// Simple arrow keys (up/down)
if (keyString === '\u001B[A') {
this.currentIndex = Math.max(0, this.currentIndex - 1);
this.renderMenu();
return;
}
if (keyString === '\u001B[B') {
this.currentIndex = Math.min(this.allScopes.length - 1, this.currentIndex + 1);
this.renderMenu();
return;
}
// Handle other arrow key combinations
const arrowMatch = keyString.match(/\u001B\[(\d+)?([A-D])/);
if (arrowMatch) {
const [_, count, direction] = arrowMatch;
const moveCount = parseInt(count || '1', 10);
if (direction === 'A') {
// Up arrow
this.currentIndex = Math.max(0, this.currentIndex - moveCount);
this.renderMenu();
}
else if (direction === 'B') {
// Down arrow
this.currentIndex = Math.min(this.allScopes.length - 1, this.currentIndex + moveCount);
this.renderMenu();
}
}
}
/**
* Renders the scope selection menu in the terminal
*/
renderMenu() {
// Clear previous menu
process.stdout.write('\r\x1B[K'); // Clear current line
for (let i = 0; i < this.allScopes.length; i++) {
process.stdout.write('\x1B[1A\x1B[K'); // Move up and clear line
}
// Display each scope with selection indicator
this.allScopes.forEach((scope, index) => {
const isSelected = this.selectedScopes.has(scope);
const isCurrent = index === this.currentIndex;
process.stdout.write(`${isCurrent ? '>' : ' '} ${isSelected ? '[x]' : '[ ]'} ${chalk.green(scope)}\n`);
});
}
/**
* Cleans up terminal state after selection
*/
cleanup() {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeAllListeners('data');
process.stdout.write('\x1B[?25h'); // Show cursor
console.log(''); // Add a newline
}
}
// Create and export the Terminal instance
const terminal = new Terminal();
// Export functions that match the original API
export const cliOutput = (message) => terminal.output(message);
export const startSpinner = (message) => terminal.startSpinner(message);
export const stopSpinner = () => terminal.stopSpinner();
export const getTenantFromToken = (accessToken) => terminal.getTenantFromToken(accessToken);
export const promptForBrowserPermission = () => terminal.promptForBrowserPermission();
export const promptForScopeSelection = (providedScopes) => terminal.promptForScopeSelection(providedScopes);
/**
* Masks a tenant name according to the specified format:
* - Shows letters before the dash
* - Shows 3 letters after the dash
* - Shows the last word after the last dot
* - Masks everything else with "xxx"
*
* Example: "dev-sfhjdfhdgfghhjdfhf.us.auth0.com" becomes "dev-sfh***com"
*
* @param tenantName The tenant name to mask
* @returns The masked tenant name or the original if it doesn't match the expected format
*/
export function maskTenantName(tenantName) {
if (!tenantName) {
return 'unknown';
}
const dashIndex = tenantName.indexOf('-');
const lastDotIndex = tenantName.lastIndexOf('.');
if (dashIndex === -1 || lastDotIndex === -1) {
return tenantName; // Return as is if format doesn't match expected pattern
}
const prefix = tenantName.substring(0, dashIndex);
const threeAfterDash = tenantName.substring(dashIndex + 1, dashIndex + 4);
const lastPart = tenantName.substring(lastDotIndex + 1);
return `${prefix}-${threeAfterDash}***${lastPart}`;
}
//# sourceMappingURL=terminal.js.map