UNPKG

okta-mcp-server

Version:

Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching

742 lines 28.5 kB
import { z } from 'zod'; import { logger } from '../../utils/logger.js'; import { BulkCreateUsersSchema, BulkUpdateUsersSchema, BulkDeleteUsersSchema, ExportUsersSchema, GenerateImportTemplateSchema, GetBulkOperationStatusSchema, } from './schemas.js'; import { parseCSV, toCSV, validateCSVUsers, validateJSONUsers, generateSampleUsers, } from './parsers.js'; import { progressTracker } from './progress-tracker.js'; import { BatchProcessor } from './batch-processor.js'; /** * Handle bulk user creation */ export async function handleBulkCreateUsers(args, client) { try { const params = BulkCreateUsersSchema.parse(args); const options = params.options || {}; // Parse and validate user data let users; if (params.format === 'csv') { const csvUsers = parseCSV(params.data, options.csvMapping); users = validateCSVUsers(csvUsers); } else { users = validateJSONUsers(params.data); } // Dry run - just validate and return if (options.dryRun) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'dry_run', message: 'Data validation successful', totalUsers: users.length, preview: users.slice(0, 5), }, null, 2), }, ], }; } // Create progress tracking const operationId = progressTracker.createOperation('create', users.length); // Initialize batch processor const processor = new BatchProcessor(client, { batchSize: options.batchSize || 10, continueOnError: options.continueOnError ?? true, rateLimitPerMinute: 600, // Okta default delayBetweenBatches: 1000, // 1 second between batches }); // Process users in batches const results = await processor.createUsersInBatches(users, operationId, { activate: options.activate, }); // Complete operation progressTracker.completeOperation(operationId); // Get final status const operation = progressTracker.getOperation(operationId); return { content: [ { type: 'text', text: JSON.stringify({ operationId, status: operation?.status, summary: { total: operation?.total || 0, succeeded: operation?.succeeded || 0, failed: operation?.failed || 0, }, errors: operation?.errors || [], message: `Bulk user creation completed. ${operation?.succeeded} succeeded, ${operation?.failed} failed.`, }, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error in bulk user creation: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } /** * Handle bulk user updates */ export async function handleBulkUpdateUsers(args, client) { try { const params = BulkUpdateUsersSchema.parse(args); const options = params.options || {}; // Prepare update list based on mode let updates = []; if (params.updateMode === 'list' && params.userIds) { // Update specific users by ID if (!params.updates) { throw new Error('Updates object is required when using list mode'); } updates = params.userIds.map((userId) => ({ userId, userData: params.updates, })); } else if (params.updateMode === 'filter' && params.filter) { // Fetch users matching filter const processor = new BatchProcessor(client, { batchSize: options.batchSize || 10, continueOnError: options.continueOnError ?? true, rateLimitPerMinute: 600, }); const matchingUsers = []; for await (const batch of processor.fetchAllUsers(params.filter)) { matchingUsers.push(...batch); } if (!params.updates) { throw new Error('Updates object is required when using filter mode'); } updates = matchingUsers.map((user) => ({ userId: user.id, userData: params.updates, })); } else if (params.updateMode === 'csv' && params.csvData) { // Parse CSV for updates const mapping = options.csvMapping; if (!mapping?.identifier || !mapping?.identifierType || !mapping?.fields) { throw new Error('CSV mapping is required for CSV mode'); } const csvData = parseCSV(params.csvData); // Process each CSV row for (const row of csvData) { const identifier = row[mapping.identifier]; if (!identifier) continue; // Get user ID based on identifier type let userId; if (mapping.identifierType === 'id') { userId = identifier; } else { // Fetch user by email/login try { const user = await client.getUser(identifier); userId = user.id; } catch (error) { logger.error('Failed to find user', { identifier, error }); continue; } } // Build update object from CSV fields const userData = { profile: {} }; for (const [oktaField, csvColumn] of Object.entries(mapping.fields)) { if (row[csvColumn] !== undefined && row[csvColumn] !== '') { if (oktaField.startsWith('profile.')) { userData.profile[oktaField.substring(8)] = row[csvColumn]; } else { userData[oktaField] = row[csvColumn]; } } } if (Object.keys(userData.profile).length > 0 || Object.keys(userData).length > 1) { updates.push({ userId, userData }); } } } if (updates.length === 0) { return { content: [ { type: 'text', text: 'No users to update based on the provided criteria', }, ], }; } // Dry run - just show what would be updated if (options.dryRun) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'dry_run', message: 'Update preview', totalUsers: updates.length, preview: updates.slice(0, 5), }, null, 2), }, ], }; } // Create progress tracking const operationId = progressTracker.createOperation('update', updates.length); // Initialize batch processor const processor = new BatchProcessor(client, { batchSize: options.batchSize || 10, continueOnError: options.continueOnError ?? true, rateLimitPerMinute: 600, }); // Process updates in batches const results = await processor.updateUsersInBatches(updates, operationId); // Complete operation progressTracker.completeOperation(operationId); // Get final status const operation = progressTracker.getOperation(operationId); return { content: [ { type: 'text', text: JSON.stringify({ operationId, status: operation?.status, summary: { total: operation?.total || 0, succeeded: operation?.succeeded || 0, failed: operation?.failed || 0, }, errors: operation?.errors || [], message: `Bulk user update completed. ${operation?.succeeded} succeeded, ${operation?.failed} failed.`, }, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error in bulk user update: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } /** * Handle bulk user deletion */ export async function handleBulkDeleteUsers(args, client) { try { const params = BulkDeleteUsersSchema.parse(args); const options = params.options || {}; // Prepare user list based on selection mode let userIds = []; if (params.selectionMode === 'list' && params.userIds) { userIds = params.userIds; } else if (params.selectionMode === 'filter' && params.filter) { // Fetch users matching filter const processor = new BatchProcessor(client, { batchSize: 200, continueOnError: true, rateLimitPerMinute: 600, }); const matchingUsers = []; for await (const batch of processor.fetchAllUsers(params.filter)) { matchingUsers.push(...batch); } userIds = matchingUsers.map((user) => user.id); } else if (params.selectionMode === 'csv' && params.csvData) { // Parse CSV for user identifiers const mapping = options.csvMapping; if (!mapping?.identifier || !mapping?.identifierType) { throw new Error('CSV mapping is required for CSV mode'); } const csvData = parseCSV(params.csvData); // Resolve identifiers to user IDs for (const row of csvData) { const identifier = row[mapping.identifier]; if (!identifier) continue; if (mapping.identifierType === 'id') { userIds.push(identifier); } else { // Fetch user by email/login try { const user = await client.getUser(identifier); userIds.push(user.id); } catch (error) { logger.error('Failed to find user', { identifier, error }); } } } } if (userIds.length === 0) { return { content: [ { type: 'text', text: 'No users found matching the provided criteria', }, ], }; } // Safety check for large deletions if (userIds.length > 100 && !options.dryRun) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'confirmation_required', message: `This operation will ${params.action} ${userIds.length} users. This is a large number. Please run with dryRun: true first to preview, then set requireConfirmation: false to proceed.`, totalUsers: userIds.length, }, null, 2), }, ], }; } // Dry run - show what would be deleted if (options.dryRun) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'dry_run', message: `Preview: Would ${params.action} ${userIds.length} users`, action: params.action, totalUsers: userIds.length, userIds: userIds.slice(0, 10), note: userIds.length > 10 ? `...and ${userIds.length - 10} more` : undefined, }, null, 2), }, ], }; } // Require explicit confirmation for destructive operations if (options.requireConfirmation !== false) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'confirmation_required', message: 'Set requireConfirmation: false to proceed with deletion', action: params.action, totalUsers: userIds.length, }, null, 2), }, ], }; } // Create progress tracking const operationId = progressTracker.createOperation('delete', userIds.length); // Initialize batch processor const processor = new BatchProcessor(client, { batchSize: options.batchSize || 10, continueOnError: true, // Always continue on error for deletions rateLimitPerMinute: 600, }); // Process deletions in batches const results = await processor.deleteUsersInBatches(userIds, operationId, { action: params.action, sendEmail: options.sendEmail, }); // Complete operation progressTracker.completeOperation(operationId); // Get final status const operation = progressTracker.getOperation(operationId); return { content: [ { type: 'text', text: JSON.stringify({ operationId, status: operation?.status, summary: { total: operation?.total || 0, succeeded: operation?.succeeded || 0, failed: operation?.failed || 0, }, errors: operation?.errors || [], message: `Bulk user ${params.action} completed. ${operation?.succeeded} succeeded, ${operation?.failed} failed.`, }, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error in bulk user deletion: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } /** * Handle user export */ export async function handleExportUsers(args, client) { try { const params = ExportUsersSchema.parse(args); const options = params.options || {}; // Create progress tracking const operationId = progressTracker.createOperation('export', 0); // Will update total as we fetch // Initialize batch processor const processor = new BatchProcessor(client, { batchSize: 200, continueOnError: true, rateLimitPerMinute: 600, }); // Fetch all users matching criteria const allUsers = []; let batchCount = 0; progressTracker.startOperation(operationId); for await (const batch of processor.fetchAllUsers(params.filter, params.search)) { allUsers.push(...batch); batchCount++; progressTracker.updateProgress(operationId, { processed: allUsers.length, succeeded: allUsers.length, }); // Apply limit if specified if (options.limit && allUsers.length >= options.limit) { allUsers.splice(options.limit); break; } } // Update total in progress tracker progressTracker.updateProgress(operationId, { processed: allUsers.length, succeeded: allUsers.length, }); // Enrich with additional data if requested if (options.includeGroups || options.includeApps) { for (const user of allUsers) { if (options.includeGroups) { try { const groupsResponse = await client.getUserGroups(user.id, { limit: 200 }); user['groups'] = groupsResponse.data.map((g) => ({ id: g.id, name: g.profile.name, })); } catch (error) { logger.error('Failed to fetch groups for user', { userId: user.id, error }); user['groups'] = []; } } // Note: App assignments would require additional API endpoint implementation if (options.includeApps) { user['apps'] = []; // Placeholder - would need to implement getUser Apps } } } // Format output let output; let contentType = 'text/plain'; if (params.format === 'csv') { // Flatten user objects for CSV const flattenedUsers = allUsers.map((user) => { const flattened = {}; for (const field of params.fields || []) { if (field.includes('.')) { // Handle nested fields const parts = field.split('.'); let value = user; for (const part of parts) { value = value?.[part]; } flattened[field] = value; } else { flattened[field] = user[field]; } } // Add groups if included if (options.includeGroups && user['groups']) { flattened['groups'] = user['groups'].map((g) => g.name).join(';'); } return flattened; }); output = toCSV(flattenedUsers, params.fields || []); contentType = 'text/csv'; } else { // JSON format output = JSON.stringify(allUsers, null, 2); contentType = 'application/json'; } // Complete operation progressTracker.completeOperation(operationId); // Get final status const operation = progressTracker.getOperation(operationId); // For large exports, truncate the data to avoid token limits const MAX_OUTPUT_LENGTH = 50000; // Characters, not tokens const truncated = output.length > MAX_OUTPUT_LENGTH; const truncatedOutput = truncated ? output.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)' : output; return { content: [ { type: 'text', text: JSON.stringify({ operationId, status: 'completed', summary: { totalExported: allUsers.length, format: params.format, filename: options.filename || `okta_users_export.${params.format}`, dataSize: output.length, truncated, }, data: truncatedOutput, contentType, message: truncated ? `Successfully exported ${allUsers.length} users to ${params.format.toUpperCase()} format. Data truncated due to size (${output.length} chars). Use limit parameter to export fewer users.` : `Successfully exported ${allUsers.length} users to ${params.format.toUpperCase()} format`, }, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error exporting users: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } /** * Handle import template generation */ export async function handleGenerateImportTemplate(args, client) { try { const params = GenerateImportTemplateSchema.parse(args); // Generate sample users const sampleUsers = generateSampleUsers(params.sampleRows || 3, params.includeOptionalFields || false); // Add custom attributes if specified if (params.includeCustomAttributes) { sampleUsers.forEach((user, index) => { params.includeCustomAttributes.forEach((attr) => { user[attr] = `custom_value_${index + 1}`; }); }); } let template; let contentType = 'text/plain'; if (params.format === 'csv') { // Generate CSV template const fields = [ 'email', 'firstName', 'lastName', ...(params.includeOptionalFields ? ['login', 'mobilePhone', 'department', 'title', 'manager'] : []), ...(params.includeCustomAttributes || []), ]; template = toCSV(sampleUsers, fields); contentType = 'text/csv'; } else { // Generate JSON template const jsonUsers = sampleUsers.map((user) => ({ profile: { email: user.email, login: user.login || user.email, firstName: user.firstName, lastName: user.lastName, ...(params.includeOptionalFields ? { mobilePhone: user['mobilePhone'], department: user['department'], title: user['title'], manager: user['manager'], } : {}), ...(params.includeCustomAttributes ? Object.fromEntries(params.includeCustomAttributes.map((attr) => [attr, user[attr]])) : {}), }, ...(params.includeOptionalFields ? { credentials: { password: { value: 'TempPassword123!', }, }, } : {}), })); template = JSON.stringify(jsonUsers, null, 2); contentType = 'application/json'; } return { content: [ { type: 'text', text: JSON.stringify({ status: 'success', format: params.format, filename: `import_template.${params.format}`, contentType, template, instructions: params.format === 'csv' ? 'Fill in the CSV with your user data. Email is required, other fields are optional.' : 'Fill in the JSON array with your user objects. Each object must have a profile with at least an email.', }, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error generating template: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } /** * Handle bulk operation status check */ export async function handleGetBulkOperationStatus(args, client) { try { const params = GetBulkOperationStatusSchema.parse(args); const operation = progressTracker.getOperation(params.operationId); if (!operation) { return { content: [ { type: 'text', text: JSON.stringify({ status: 'not_found', message: `Operation ${params.operationId} not found`, }, null, 2), }, ], }; } return { content: [ { type: 'text', text: JSON.stringify(operation, null, 2), }, ], }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: 'text', text: `Validation error: ${JSON.stringify(error.errors, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Error getting operation status: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], isError: true, }; } } //# sourceMappingURL=handlers.js.map