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
JavaScript
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