Skip to main content
Glama
updateSetClient.ts47.8 kB
import fetch, { Response } from 'node-fetch'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; import { CookieJar } from 'tough-cookie'; import { UpdateSetOperationRequest, UpdateSetOperationResult, UpdateSetErrorResponse, UpdateSetRecord, XMLRecord, WorkingSetState, UpdateSetContents, RecentXMLActivity, UpdateSetDiff, XMLSummary, XMLDetectionResult, XMLReassignmentResult, UpdateSetFilters, UpdateSetClientConfig, DEFAULT_UPDATE_SET_CONFIG, loadUpdateSetConfig, ServiceNowUpdateSetError, UPDATE_SET_ERROR_CODES, isWorkingSetState, isUpdateSetRecord, isXMLRecord, } from './updateSetTypes.js'; /** * Load credentials from MCP-ACE specific .env file * Reuses the same credential loading logic as other ServiceNow clients * Checks in order: project directory → home directory → system directory */ function loadEnvFile(): Record<string, string> { const envVars: Record<string, string> = {}; // Try project directory first (project-specific config, highest priority) try { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Go up from src/servicenow to project root const projectRoot = resolve(__dirname, '../..'); const projectEnvFile = resolve(projectRoot, '.servicenow-ace.env'); const content = readFileSync(projectEnvFile, 'utf-8'); content.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); const value = valueParts.join('=').trim(); if (key && value) { envVars[key.trim()] = value; } } }); return envVars; // Successfully loaded from project file, return early } catch (error) { // Project .env file not found - try user home directory } // Try user home directory (MCP-ACE specific) const userEnvFile = resolve(homedir(), '.servicenow-ace.env'); try { const content = readFileSync(userEnvFile, 'utf-8'); content.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); const value = valueParts.join('=').trim(); if (key && value) { envVars[key.trim()] = value; } } }); return envVars; // Successfully loaded from user file, return early } catch (error) { // User .env file not found or can't be read - try shared system location } // Try shared system credential file as fallback (MCP-ACE specific) const sharedEnvFile = '/etc/csadmin/servicenow-ace.env'; try { const content = readFileSync(sharedEnvFile, 'utf-8'); content.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); const value = valueParts.join('=').trim(); if (key && value) { envVars[key.trim()] = value; } } }); } catch (error) { // Shared .env file not found either - that's okay, env vars might be set } return envVars; } /** * ServiceNow Update Set Management Client * * Handles all update set lifecycle operations, XML detection and reassignment, * and working set state management using background scripts and Table API. */ export class ServiceNowUpdateSetClient { private instance: string; private username: string; private password: string; private baseUrl: string; private clientConfig: Required<UpdateSetClientConfig>; private cookieJar: CookieJar; private workingUpdateSet: WorkingSetState | null = null; constructor(clientConfig: UpdateSetClientConfig = {}) { // Load MCP-ACE specific credentials (same as other clients) // Priority order: 1) process.env (from MCP config or system), 2) .env files let instance = process.env.SERVICENOW_ACE_INSTANCE; let username = process.env.SERVICENOW_ACE_USERNAME; let password = process.env.SERVICENOW_ACE_PASSWORD; // If env vars are missing, try .env file (project directory → home directory → system directory) if (!instance || !username || !password) { const envFileVars = loadEnvFile(); instance = instance || envFileVars.SERVICENOW_ACE_INSTANCE; username = username || envFileVars.SERVICENOW_ACE_USERNAME; password = password || envFileVars.SERVICENOW_ACE_PASSWORD; } if (!instance || !username || !password) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_CREDENTIALS, undefined, 'Missing required credentials. Set SERVICENOW_ACE_INSTANCE, SERVICENOW_ACE_USERNAME, SERVICENOW_ACE_PASSWORD as environment variables or create ~/.servicenow-ace.env' ); } this.instance = instance; this.username = username; this.password = password; this.baseUrl = `https://${instance}`; // Load from environment variables first, then apply any overrides const envConfig = loadUpdateSetConfig(); this.clientConfig = { ...envConfig, ...clientConfig }; this.cookieJar = new CookieJar(); } /** * Execute an update set operation (main entry point) * * @param request - Update set operation request * @returns Promise resolving to operation result */ async executeUpdateSetOperation(request: UpdateSetOperationRequest): Promise<UpdateSetOperationResult> { const startTime = Date.now(); try { // Validate input this.validateUpdateSetRequest(request); // Route to appropriate operation let result: any; let metadata: any = { operation: request.operation, executionTime: 0, timestamp: new Date().toISOString(), working_set: this.workingUpdateSet, }; switch (request.operation) { case 'create': result = await this.createUpdateSet(request); if (request.set_as_working && result.update_set) { this.setWorkingSet(result.update_set); result.working_set = this.workingUpdateSet; } break; case 'set_working': if (!request.update_set_sys_id) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'update_set_sys_id is required for set_working operation' ); } const updateSet = await this.getUpdateSetInfo(request.update_set_sys_id); this.setWorkingSet(updateSet); result = { working_set: this.workingUpdateSet }; break; case 'show_working': result = { working_set: this.workingUpdateSet }; break; case 'clear_working': this.clearWorkingSet(); result = { working_set: null }; break; case 'insert': if (!request.table || !request.data) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'table and data are required for insert operation' ); } result = await this.insertWithReassignment( request.table, request.data as Record<string, any>, request.update_set_sys_id, request.custom_timestamp_before ); break; case 'update': if (!request.table || !request.sys_id || !request.data) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'table, sys_id, and data are required for update operation' ); } result = await this.updateWithReassignment( request.table, request.sys_id, request.data as Record<string, any>, request.update_set_sys_id, request.custom_timestamp_before ); break; case 'rehome': if (!request.xml_sys_ids && !request.query) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'xml_sys_ids or query is required for rehome operation' ); } if (request.xml_sys_ids) { result = await this.rehomeXMLByIds( request.xml_sys_ids, request.update_set_sys_id || this.getWorkingSetId(), request.force || false ); } else { result = await this.rehomeXMLByQuery( request.query!, request.update_set_sys_id || this.getWorkingSetId(), request.force || false ); } break; case 'contents': result = await this.getUpdateSetContents( request.update_set_sys_id || this.getWorkingSetId(), request.response_mode ); break; case 'recent': result = await this.getRecentXML(request.limit || 50, request.response_mode); break; case 'list': result = await this.listUpdateSets(request.filters, request.limit, request.offset); break; case 'info': if (!request.update_set_sys_id) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'update_set_sys_id is required for info operation' ); } result = { update_set: await this.getUpdateSetInfo(request.update_set_sys_id) }; break; case 'complete': if (!request.update_set_sys_id) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'update_set_sys_id is required for complete operation' ); } result = await this.completeUpdateSet(request.update_set_sys_id); break; case 'reopen': if (!request.update_set_sys_id) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'update_set_sys_id is required for reopen operation' ); } // Check if state changes are disabled if (process.env.SKYENET_UPDATESET_DISABLE_STATE_CHANGES === 'true') { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.OPERATION_DISABLED, undefined, 'Reopen operation is disabled due to ServiceNow business rule limitations. Use create, list, contents, recent, diff, and complete operations instead.' ); } result = await this.reopenUpdateSet(request.update_set_sys_id); break; case 'delete': if (!request.update_set_sys_id) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'update_set_sys_id is required for delete operation' ); } // Check if state changes are disabled if (process.env.SKYENET_UPDATESET_DISABLE_STATE_CHANGES === 'true') { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.OPERATION_DISABLED, undefined, 'Delete operation is disabled due to ServiceNow business rule limitations. Use create, list, contents, recent, diff, and complete operations instead.' ); } result = await this.deleteUpdateSet(request.update_set_sys_id); break; case 'diff_default': result = await this.diffAgainstDefault( request.update_set_sys_id || this.getWorkingSetId(), request.response_mode ); break; default: throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.INVALID_OPERATION, undefined, `Invalid operation: ${request.operation}` ); } const executionTime = Date.now() - startTime; metadata.executionTime = executionTime; // Estimate response size for metadata const responseSize = JSON.stringify(result).length; metadata.responseSize = responseSize; metadata.contextOverflowPrevention = responseSize > 40000; // 40KB threshold // Handle quiet mode for update operations - ultra-minimal response if (request.quiet && (request.operation === 'insert' || request.operation === 'update' || request.operation === 'set_working')) { return { success: true, data: { message: 'Update accepted' }, metadata: { operation: request.operation, executionTime: 0, timestamp: new Date().toISOString(), quiet_mode: true } }; } return { success: true, data: result, metadata, }; } catch (error) { if (error instanceof ServiceNowUpdateSetError) { throw error; } throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.NETWORK_ERROR, undefined, `Update set operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } // ============================================================================ // STATE MANAGEMENT // ============================================================================ /** * Set the working update set */ setWorkingSet(updateSet: UpdateSetRecord): void { this.workingUpdateSet = { sys_id: updateSet.sys_id, name: updateSet.name, scope: updateSet.scope, state: updateSet.state, created_on: updateSet.created_on, }; } /** * Get the working update set */ getWorkingSet(): WorkingSetState | null { return this.workingUpdateSet; } /** * Clear the working update set */ clearWorkingSet(): void { this.workingUpdateSet = null; } /** * Get working set ID or throw error if not set */ private getWorkingSetId(): string { if (!this.workingUpdateSet) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.WORKING_SET_NOT_SET, undefined, 'No working update set is set. Use set_working operation first.' ); } return this.workingUpdateSet.sys_id; } // ============================================================================ // LIFECYCLE OPERATIONS // ============================================================================ /** * Create a new update set */ async createUpdateSet(request: UpdateSetOperationRequest): Promise<{ update_set: UpdateSetRecord }> { if (!request.name) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'name is required for create operation' ); } const scope = request.scope || this.clientConfig.defaultScope; const description = request.description || `Created by SkyeNet MCP ACE on ${new Date().toISOString()}`; // Create update set via background script const script = ` var updateSet = new GlideRecord('sys_update_set'); updateSet.initialize(); updateSet.setValue('name', '${request.name}'); updateSet.setValue('description', '${description}'); updateSet.setValue('scope', '${scope}'); updateSet.setValue('state', 'in_progress'); updateSet.setValue('created_by', gs.getUserID()); var sysId = updateSet.insert(); if (sysId) { gs.info('SUCCESS: ' + sysId); gs.info('NAME: ' + updateSet.getValue('name')); gs.info('SCOPE: ' + updateSet.getValue('scope')); gs.info('STATE: ' + updateSet.getValue('state')); } else { gs.warn('ERROR: Failed to create update set'); } `; // Run in global scope to avoid cross-scope security issues const result = await this.executeBackgroundScript(script, 'global'); if (!result.success || !result.output.text.includes('SUCCESS:')) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.SCRIPT_EXECUTION_ERROR, undefined, 'Failed to create update set: ' + (result.output.text || 'Unknown error') ); } // Extract sys_id from output const sysIdMatch = result.output.text.match(/SUCCESS:\s*([a-f0-9]{32})/); if (!sysIdMatch) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.SCRIPT_EXECUTION_ERROR, undefined, 'Could not extract update set sys_id from script output' ); } const sysId = sysIdMatch[1]; const updateSet = await this.getUpdateSetInfo(sysId); return { update_set: updateSet }; } /** * Complete an update set */ async completeUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> { // First check if update set has any in-progress XML const contents = await this.getUpdateSetContents(sysId); if (contents.total_count > 0) { // Check if any XML records are in progress const inProgressXML = contents.by_type?.['in_progress'] || { count: 0 }; if (inProgressXML.count > 0) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.UPDATE_SET_HAS_XML, undefined, `Cannot complete update set: ${inProgressXML.count} XML records are still in progress` ); } } // Update the update set state to complete const updateData = { state: 'complete' }; await this.updateTableRecord('sys_update_set', sysId, updateData); const updateSet = await this.getUpdateSetInfo(sysId); return { update_set: updateSet }; } /** * Reopen an update set */ async reopenUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> { try { const updateData = { state: 'in_progress' }; await this.updateTableRecord('sys_update_set', sysId, updateData); // Add a small delay to allow ServiceNow to process the state change await new Promise(resolve => setTimeout(resolve, 1000)); const updateSet = await this.getUpdateSetInfo(sysId); // Check if the state change was successful if (updateSet.state !== 'in_progress') { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED, undefined, `Failed to reopen update set. Current state: ${updateSet.state}. This may be due to ServiceNow business rules that prevent reopening completed update sets. Check your instance's business rules and ACLs.` ); } return { update_set: updateSet }; } catch (error) { if (error instanceof ServiceNowUpdateSetError) { throw error; } throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED, undefined, `Failed to reopen update set: ${error instanceof Error ? error.message : 'Unknown error'}. This may be due to ServiceNow business rules or insufficient permissions.` ); } } /** * Delete an update set (soft delete) */ async deleteUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> { try { const updateData = { state: 'deleted' }; await this.updateTableRecord('sys_update_set', sysId, updateData); // Add a small delay to allow ServiceNow to process the state change await new Promise(resolve => setTimeout(resolve, 1000)); const updateSet = await this.getUpdateSetInfo(sysId); // Check if the state change was successful if (updateSet.state !== 'deleted') { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED, undefined, `Failed to delete update set. Current state: ${updateSet.state}. This may be due to ServiceNow business rules that prevent deleting update sets. Check your instance's business rules and ACLs.` ); } return { update_set: updateSet }; } catch (error) { if (error instanceof ServiceNowUpdateSetError) { throw error; } throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED, undefined, `Failed to delete update set: ${error instanceof Error ? error.message : 'Unknown error'}. This may be due to ServiceNow business rules or insufficient permissions.` ); } } /** * List update sets with optional filters, limit, and offset */ async listUpdateSets(filters?: UpdateSetFilters, limit?: number, offset?: number): Promise<{ update_sets: UpdateSetRecord[] }> { let query = ''; const queryParts: string[] = []; if (filters?.scope) { queryParts.push(`scope=${filters.scope}`); } if (filters?.state) { queryParts.push(`state=${filters.state}`); } if (filters?.created_by) { queryParts.push(`created_by=${filters.created_by}`); } if (filters?.sys_created_on) { queryParts.push(`sys_created_on${filters.sys_created_on}`); } if (queryParts.length > 0) { query = queryParts.join('^'); } // Apply limit and offset for pagination const queryParams: Record<string, any> = { sysparm_query: query, sysparm_fields: 'sys_id,name,description,scope,state,created_by,created_on,updated_on', }; // Use provided limit or default to 50 for context bloat prevention queryParams.sysparm_limit = limit || 50; if (offset) { queryParams.sysparm_offset = offset; } const records = await this.queryTableRecords('sys_update_set', queryParams); const updateSets = records.map(record => this.mapToUpdateSetRecord(record)); return { update_sets: updateSets }; } /** * Get update set information with XML count */ async getUpdateSetInfo(sysId: string): Promise<UpdateSetRecord> { const record = await this.getTableRecord('sys_update_set', sysId); const updateSet = this.mapToUpdateSetRecord(record); // Get XML count const xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: `update_set=${sysId}`, sysparm_limit: 1, }); updateSet.xml_count = xmlRecords.length; return updateSet; } // ============================================================================ // XML DETECTION AND REASSIGNMENT // ============================================================================ /** * Detect newly created XML records after an operation */ async detectNewXML( recordSysId: string, tableName: string, timestampBefore: number, timestampAfter: number ): Promise<XMLDetectionResult> { const detectionStartTime = Date.now(); try { // Get the Default update set sys_id (personal update set) const defaultUpdateSet = await this.getDefaultUpdateSet(); // Primary strategy: name LIKE recordSysId% AND in Default update set let query = `nameLIKE${recordSysId}%^update_set=${defaultUpdateSet.sys_id}^sys_created_on>=${new Date(timestampBefore).toISOString()}^sys_created_on<=${new Date(timestampAfter).toISOString()}`; let xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: query, sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name', sysparm_limit: 100, }); if (xmlRecords.length > 0) { return { found: true, xml_records: xmlRecords.map(record => this.mapToXMLRecord(record)), detection_strategy: 'primary', detection_time: Date.now() - detectionStartTime, }; } // Fallback strategy: created_on timestamp + created_by + name contains table + in Default update set query = `sys_created_on>=${new Date(timestampBefore).toISOString()}^sys_created_on<=${new Date(timestampAfter).toISOString()}^created_by=admin^nameLIKE${tableName}^update_set=${defaultUpdateSet.sys_id}`; xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: query, sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name', sysparm_limit: 100, }); return { found: xmlRecords.length > 0, xml_records: xmlRecords.map(record => this.mapToXMLRecord(record)), detection_strategy: 'fallback', detection_time: Date.now() - detectionStartTime, message: xmlRecords.length === 0 ? 'No XML records detected using either strategy' : undefined, }; } catch (error) { return { found: false, xml_records: [], detection_strategy: 'fallback', detection_time: Date.now() - detectionStartTime, message: `XML detection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } /** * Reassign XML records to a target update set */ async reassignXML( xmlSysIds: string[], targetUpdateSetSysId: string, force: boolean = false ): Promise<XMLReassignmentResult> { const results: Array<{ xml_sys_id: string; success: boolean; error?: string; old_update_set?: string; new_update_set: string; }> = []; let successCount = 0; let failureCount = 0; for (const xmlSysId of xmlSysIds) { try { // Get current XML record to check update set const xmlRecord = await this.getTableRecord('sys_update_xml', xmlSysId); const currentUpdateSet = xmlRecord.update_set; // Extract the actual update set ID from the object structure const currentUpdateSetId = typeof currentUpdateSet === 'string' ? currentUpdateSet : currentUpdateSet?.value || currentUpdateSet; // Check if we can move this record if (!force && currentUpdateSetId !== this.getDefaultUpdateSetId()) { results.push({ xml_sys_id: xmlSysId, success: false, error: `XML record is not in Default update set (current: ${currentUpdateSetId})`, old_update_set: currentUpdateSet, new_update_set: targetUpdateSetSysId, }); failureCount++; continue; } // Reassign the XML record await this.updateTableRecord('sys_update_xml', xmlSysId, { update_set: targetUpdateSetSysId, }); results.push({ xml_sys_id: xmlSysId, success: true, old_update_set: currentUpdateSet, new_update_set: targetUpdateSetSysId, }); successCount++; } catch (error) { results.push({ xml_sys_id: xmlSysId, success: false, error: error instanceof Error ? error.message : 'Unknown error', new_update_set: targetUpdateSetSysId, }); failureCount++; } } return { success: failureCount === 0, reassigned_count: successCount, failed_count: failureCount, results, message: failureCount > 0 ? `${failureCount} XML records failed to reassign` : undefined, }; } // ============================================================================ // RECORD OPERATIONS WITH AUTO-REASSIGNMENT // ============================================================================ /** * Insert record with automatic XML reassignment */ async insertWithReassignment( table: string, data: Record<string, any>, updateSetSysId?: string, customTimestampBefore?: number ): Promise<{ record: Record<string, any>; xml_reassignment?: XMLReassignmentResult; detection_result?: XMLDetectionResult; }> { const targetUpdateSet = updateSetSysId || this.getWorkingSetId(); const timestampBefore = customTimestampBefore || Date.now(); // Insert the record const record = await this.createTableRecord(table, data); const recordSysId = record.sys_id; // Wait a moment for XML to be created await new Promise(resolve => setTimeout(resolve, 1000)); const timestampAfter = Date.now() + this.clientConfig.xmlDetectionWindowMs; // Detect new XML records const detectionResult = await this.detectNewXML( recordSysId, table, timestampBefore, timestampAfter ); let reassignmentResult: XMLReassignmentResult | undefined; if (detectionResult.found && detectionResult.xml_records.length > 0) { // Reassign XML records const xmlSysIds = detectionResult.xml_records.map(xml => xml.sys_id); reassignmentResult = await this.reassignXML(xmlSysIds, targetUpdateSet, false); } return { record, xml_reassignment: reassignmentResult, detection_result: detectionResult, }; } /** * Update record with automatic XML reassignment */ async updateWithReassignment( table: string, sysId: string, data: Record<string, any>, updateSetSysId?: string, customTimestampBefore?: number ): Promise<{ record: Record<string, any>; xml_reassignment?: XMLReassignmentResult; detection_result?: XMLDetectionResult; }> { const targetUpdateSet = updateSetSysId || this.getWorkingSetId(); const timestampBefore = customTimestampBefore || Date.now(); // Update the record const record = await this.updateTableRecord(table, sysId, data); // Wait a moment for XML to be created await new Promise(resolve => setTimeout(resolve, 1000)); const timestampAfter = Date.now() + this.clientConfig.xmlDetectionWindowMs; // Detect new XML records const detectionResult = await this.detectNewXML( sysId, table, timestampBefore, timestampAfter ); let reassignmentResult: XMLReassignmentResult | undefined; if (detectionResult.found && detectionResult.xml_records.length > 0) { // Reassign XML records const xmlSysIds = detectionResult.xml_records.map(xml => xml.sys_id); reassignmentResult = await this.reassignXML(xmlSysIds, targetUpdateSet, false); } return { record, xml_reassignment: reassignmentResult, detection_result: detectionResult, }; } // ============================================================================ // VERIFICATION AND REPORTING // ============================================================================ /** * Get update set contents grouped by type */ async getUpdateSetContents(updateSetSysId: string, responseMode?: string): Promise<UpdateSetContents> { const xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: `update_set=${updateSetSysId}`, sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name', sysparm_limit: 1000, }); const updateSet = await this.getUpdateSetInfo(updateSetSysId); const xmlRecordsMapped = xmlRecords.map(record => this.mapToXMLRecord(record)); // For minimal mode, return simplified structure if (responseMode === 'minimal') { return { update_set_sys_id: updateSetSysId, update_set_name: updateSet.name, total_count: xmlRecordsMapped.length, records: xmlRecordsMapped, // Simple flat array // Remove by_type and by_table for minimal mode }; } // Group by type const byType: Record<string, { count: number; records: XMLRecord[] }> = {}; const byTable: Record<string, { count: number; records: XMLRecord[] }> = {}; for (const xml of xmlRecordsMapped) { // Group by type if (!byType[xml.type]) { byType[xml.type] = { count: 0, records: [] }; } byType[xml.type].count++; byType[xml.type].records.push(xml); // Group by table if (!byTable[xml.table]) { byTable[xml.table] = { count: 0, records: [] }; } byTable[xml.table].count++; byTable[xml.table].records.push(xml); } return { update_set_sys_id: updateSetSysId, update_set_name: updateSet.name, total_count: xmlRecordsMapped.length, by_type: byType, by_table: byTable, }; } /** * Get recent XML activity across all update sets */ async getRecentXML(limit: number = 50, responseMode?: string): Promise<RecentXMLActivity> { const xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: 'sys_created_on>=javascript:gs.daysAgo(7)', sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name', sysparm_limit: limit, sysparm_orderby: 'sys_created_on', sysparm_display_value: responseMode === 'minimal' ? 'true' : 'all', // Minimal mode only gets display_value }); // Map records and properly resolve update_set_name const xmlRecordsMapped = xmlRecords.map(record => { const mapped = this.mapToXMLRecord(record); // Properly extract update set name from reference field let updateSetName = 'Unknown'; if (record.update_set) { if (typeof record.update_set === 'string') { updateSetName = record.update_set; } else if (record.update_set.display_value) { updateSetName = record.update_set.display_value; } else if (record.update_set.value) { updateSetName = record.update_set.value; } } // For minimal mode, clean up duplicate value/display_value pairs and apply hard truncation const cleanedRecord: any = { ...mapped }; if (responseMode === 'minimal') { // Remove duplicate value/display_value pairs, keep only display_value Object.keys(cleanedRecord).forEach(key => { if (cleanedRecord[key] && typeof cleanedRecord[key] === 'object' && cleanedRecord[key].display_value) { cleanedRecord[key] = cleanedRecord[key].display_value; } }); // Apply hard truncation to long fields for minimal mode const longFields = ['name', 'target_name', 'sys_update_name']; longFields.forEach(field => { if (cleanedRecord[field] && typeof cleanedRecord[field] === 'string' && cleanedRecord[field].length > 50) { cleanedRecord[field] = cleanedRecord[field].substring(0, 50) + '...[truncated]'; } }); // Remove redundant update_set field in minimal mode (we have update_set_name) delete cleanedRecord.update_set; } return { ...cleanedRecord, update_set_name: updateSetName, is_active_set: (record.update_set?.value || record.update_set) === this.workingUpdateSet?.sys_id, }; }); // For minimal mode, return only the flat array without grouping const limitedRecords = xmlRecordsMapped.length > 5 ? xmlRecordsMapped.slice(0, 5) : xmlRecordsMapped; return { total_count: xmlRecordsMapped.length, records: limitedRecords, // Flat array with update_set_name and is_active_set count: limitedRecords.length, summary: xmlRecordsMapped.length > 5 ? `5 of ${xmlRecordsMapped.length} records` : undefined, // Remove by_update_set grouping to eliminate [object Object] issue }; } /** * Compare working set against Default to find strays */ async diffAgainstDefault(updateSetSysId: string, responseMode?: string): Promise<UpdateSetDiff> { const workingSet = await this.getUpdateSetInfo(updateSetSysId); const defaultSet = await this.getUpdateSetInfo(this.getDefaultUpdateSetId()); // Find XML records that should be in working set but are in Default const strayRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: `update_set=${this.getDefaultUpdateSetId()}^sys_created_on>=javascript:gs.daysAgo(1)`, sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name', sysparm_limit: 100, }); let processedStrayRecords = strayRecords.map(record => this.mapToXMLRecord(record)); // Apply minimal mode transformations to reduce payload size if (responseMode === 'minimal') { // Limit to first 5 records and truncate long fields processedStrayRecords = processedStrayRecords.slice(0, 5).map(record => { const truncated: any = { ...record }; // Truncate long fields const longFields = ['name', 'target_name', 'sys_update_name']; longFields.forEach(field => { if (truncated[field] && typeof truncated[field] === 'string' && truncated[field].length > 50) { truncated[field] = truncated[field].substring(0, 50) + '...[truncated]'; } }); // Remove redundant fields in minimal mode delete truncated.update_set; delete truncated.created_by; delete truncated.updated_on; return truncated; }); } return { working_set: { sys_id: workingSet.sys_id, name: workingSet.name, count: workingSet.xml_count || 0, }, default_set: { sys_id: defaultSet.sys_id, name: defaultSet.name, count: defaultSet.xml_count || 0, }, stray_records: processedStrayRecords, stray_count: strayRecords.length, message: strayRecords.length > 0 ? `${strayRecords.length} recent records found in Default that may belong to working set` : 'No stray records found', // Add summary for minimal mode when records are truncated ...(responseMode === 'minimal' && strayRecords.length > 5 && { summary: `Showing 5 of ${strayRecords.length} stray records. Use full mode to see all records.` }) }; } /** * Get XML summary for an update set */ async getXMLSummary(updateSetSysId: string): Promise<XMLSummary> { const contents = await this.getUpdateSetContents(updateSetSysId); const updateSet = await this.getUpdateSetInfo(updateSetSysId); // Aggregate counts const byType: Record<string, number> = {}; const byTable: Record<string, number> = {}; const byCreatedBy: Record<string, number> = {}; let lastCreated = ''; let lastUpdated = ''; let activityCount = 0; if (contents.by_type) { for (const [type, xml] of Object.entries(contents.by_type)) { for (const record of xml.records) { // Count by type byType[record.type] = (byType[record.type] || 0) + 1; // Count by table byTable[record.table] = (byTable[record.table] || 0) + 1; // Count by created by byCreatedBy[record.created_by] = (byCreatedBy[record.created_by] || 0) + 1; // Track activity if (!lastCreated || record.created_on > lastCreated) { lastCreated = record.created_on; } if (!lastUpdated || record.updated_on > lastUpdated) { lastUpdated = record.updated_on; } activityCount++; } } } return { update_set_sys_id: updateSetSysId, update_set_name: updateSet.name, total_count: contents.total_count, by_type: byType, by_table: byTable, by_created_by: byCreatedBy, recent_activity: { last_created: lastCreated, last_updated: lastUpdated, activity_count: activityCount, }, }; } // ============================================================================ // REHOMING OPERATIONS // ============================================================================ /** * Rehome XML records by sys_ids */ async rehomeXMLByIds( xmlSysIds: string[], targetUpdateSetSysId: string, force: boolean = false ): Promise<XMLReassignmentResult> { return await this.reassignXML(xmlSysIds, targetUpdateSetSysId, force); } /** * Rehome XML records by query */ async rehomeXMLByQuery( query: string, targetUpdateSetSysId: string, force: boolean = false ): Promise<XMLReassignmentResult> { // First find XML records matching the query const xmlRecords = await this.queryTableRecords('sys_update_xml', { sysparm_query: query, sysparm_fields: 'sys_id', sysparm_limit: 1000, }); const xmlSysIds = xmlRecords.map(record => record.sys_id); if (xmlSysIds.length === 0) { return { success: true, reassigned_count: 0, failed_count: 0, results: [], message: 'No XML records found matching the query', }; } return await this.reassignXML(xmlSysIds, targetUpdateSetSysId, force); } // ============================================================================ // HELPER METHODS // ============================================================================ /** * Execute background script using the existing background script client */ private async executeBackgroundScript(script: string, scope: string): Promise<any> { // Import and use the existing background script client const { ServiceNowBackgroundScriptClient } = await import('./client.js'); const backgroundScriptClient = new ServiceNowBackgroundScriptClient(); const result = await backgroundScriptClient.executeScript({ script, scope, }); return result; } /** * Get table record by sys_id */ private async getTableRecord(table: string, sysId: string): Promise<Record<string, any>> { const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`; const headers = { 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, 'Accept': 'application/json', }; const response = await fetch(url, { headers }); if (response.status === 404) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.UPDATE_SET_NOT_FOUND, 404, `Record not found: ${table}/${sysId}` ); } if (!response.ok) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.HTTP_ERROR, response.status, `Failed to get record: ${response.statusText}` ); } const data = await response.json(); return data.result; } /** * Create table record */ private async createTableRecord(table: string, data: Record<string, any>): Promise<Record<string, any>> { const url = `${this.baseUrl}/api/now/table/${table}`; const headers = { 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, 'Content-Type': 'application/json', }; const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(data), }); if (!response.ok) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.HTTP_ERROR, response.status, `Failed to create record: ${response.statusText}` ); } const result = await response.json(); return result.result; } /** * Update table record */ private async updateTableRecord(table: string, sysId: string, data: Record<string, any>): Promise<Record<string, any>> { const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`; const headers = { 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, 'Content-Type': 'application/json', }; const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(data), }); if (!response.ok) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.HTTP_ERROR, response.status, `Failed to update record: ${response.statusText}` ); } const result = await response.json(); return result.result; } /** * Query table records */ private async queryTableRecords(table: string, params: Record<string, any> = {}): Promise<Record<string, any>[]> { const url = new URL(`${this.baseUrl}/api/now/table/${table}`); // Add query parameters Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } }); const headers = { 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, 'Accept': 'application/json', }; const response = await fetch(url.toString(), { headers }); if (!response.ok) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.HTTP_ERROR, response.status, `Failed to query records: ${response.statusText}` ); } const data = await response.json(); return data.result || []; } /** * Get default update set ID (hardcoded for now) */ private getDefaultUpdateSetId(): string { return this.clientConfig.defaultUpdateSetId || '2836addc83f0b6100bb093a6feaad35d'; // Default update set } /** * Get the Default update set record */ private async getDefaultUpdateSet(): Promise<UpdateSetRecord> { const defaultSetId = this.getDefaultUpdateSetId(); return await this.getUpdateSetInfo(defaultSetId); } /** * Map table record to UpdateSetRecord */ private mapToUpdateSetRecord(record: Record<string, any>): UpdateSetRecord { return { sys_id: record.sys_id, name: record.name, description: record.description, scope: record.scope, state: record.state, created_by: record.created_by, created_on: record.created_on, updated_on: record.updated_on, xml_count: record.xml_count, }; } /** * Map table record to XMLRecord */ private mapToXMLRecord(record: Record<string, any>): XMLRecord { return { sys_id: record.sys_id, name: record.name, table: record.table, type: record.type, update_set: record.update_set, update_set_name: record.update_set_name, created_by: record.created_by, created_on: record.created_on, updated_on: record.updated_on, target_name: record.target_name, sys_update_name: record.sys_update_name, }; } /** * Validate update set operation request */ private validateUpdateSetRequest(request: UpdateSetOperationRequest): void { if (!request.operation) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.MISSING_PARAMETER, undefined, 'operation is required' ); } const validOperations = [ 'create', 'set_working', 'show_working', 'clear_working', 'insert', 'update', 'rehome', 'contents', 'recent', 'list', 'info', 'complete', 'reopen', 'delete', 'diff_default' ]; if (!validOperations.includes(request.operation)) { throw new ServiceNowUpdateSetError( UPDATE_SET_ERROR_CODES.INVALID_OPERATION, undefined, `Invalid operation: ${request.operation}. Valid operations: ${validOperations.join(', ')}` ); } } /** * Get client configuration */ getConfig(): Required<UpdateSetClientConfig> { return { ...this.clientConfig }; } /** * Update client configuration */ updateConfig(newConfig: Partial<UpdateSetClientConfig>): void { this.clientConfig = { ...this.clientConfig, ...newConfig }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ClearSkye/SkyeNet-MCP-ACE'

If you have feedback or need assistance with the MCP directory API, please join our Discord server