Skip to main content
Glama
tableClient.ts45.5 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 { TableOperationRequest, TableOperationResult, TableErrorResponse, TableRecord, TableQueryParams, TableHTTPResponse, TableRequestConfig, ServiceNowTableError, TABLE_ERROR_CODES, DEFAULT_TABLE_CONFIG, TableClientConfig, BatchOperationRequest, isBatchOperation, isSingleRecordOperation, ResultSummary, loadTableConfig, } from './tableTypes.js'; /** * Load credentials from MCP-ACE specific .env file * Reuses the same credential loading logic as ServiceNowBackgroundScriptClient * 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 Table API Client * * Handles all CRUD operations against ServiceNow tables using the Table API: * - GET: Query records with full query parameter support * - POST: Create single or batch records * - PUT/PATCH: Update single or batch records * - DELETE: Delete single or batch records */ export class ServiceNowTableClient { private instance: string; private username: string; private password: string; private baseUrl: string; private clientConfig: Required<TableClientConfig>; private cookieJar: CookieJar; constructor(clientConfig: TableClientConfig = {}) { // Load MCP-ACE specific credentials (same as background script client) // 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 ServiceNowTableError( TABLE_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 = loadTableConfig(); this.clientConfig = { ...envConfig, ...clientConfig }; this.cookieJar = new CookieJar(); } /** * Execute a table operation (main entry point) * * @param request - Table operation request * @returns Promise resolving to operation result */ async executeTableOperation(request: TableOperationRequest): Promise<TableOperationResult> { const startTime = Date.now(); try { // Validate input this.validateTableRequest(request); // Route to appropriate operation let result: TableRecord[]; let recordCount: number; let contextOverflowPrevention = false; let downloadHint = false; // Initialize response data const responseData: any = { records: [], count: 0, }; switch (request.operation) { case 'GET': if (isSingleRecordOperation(request)) { result = [await this.getRecord(request.table, request.sys_id!)]; recordCount = 1; } else { // Apply strict fields filtering at query level if enabled let queryParams = this.buildQueryParams(request); if (request.strict_fields && request.fields) { // Ensure only requested fields are returned from ServiceNow queryParams.sysparm_fields = request.fields; } const queryResult = await this.queryRecordsWithMetadata(request.table, queryParams); result = queryResult.records; recordCount = result.length; // Store overflow prevention metadata for later use contextOverflowPrevention = queryResult.contextOverflowPrevention; downloadHint = queryResult.downloadHint; if (queryResult.summary) { responseData.summary = queryResult.summary; } } break; case 'POST': if (isBatchOperation(request)) { result = await this.createRecords(request.table, request.data as Record<string, any>[]); recordCount = result.length; } else { result = [await this.createRecord(request.table, request.data as Record<string, any>)]; recordCount = 1; } break; case 'PUT': case 'PATCH': if (isBatchOperation(request)) { result = await this.updateRecords(request.table, request.data as Record<string, any>[], request.operation); recordCount = result.length; } else { result = [await this.updateRecord(request.table, request.sys_id!, request.data as Record<string, any>, request.operation)]; recordCount = 1; } break; case 'DELETE': if (isBatchOperation(request)) { result = await this.deleteRecords(request.table, request.sys_ids!); recordCount = result.length; } else { result = [await this.deleteRecord(request.table, request.sys_id!)]; recordCount = 1; } break; default: throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_OPERATION, undefined, `Invalid operation: ${request.operation}` ); } const executionTime = Date.now() - startTime; // Update response data with results responseData.records = result; responseData.count = recordCount; // Add operation-specific counts switch (request.operation) { case 'GET': responseData.retrieved_count = recordCount; break; case 'POST': responseData.created_count = recordCount; break; case 'PUT': case 'PATCH': responseData.updated_count = recordCount; break; case 'DELETE': responseData.deleted_count = recordCount; break; } // Context overflow prevention is handled in queryRecords method // No additional processing needed here // Apply strict field filtering if requested - this is the critical fix if (request.strict_fields && request.fields) { result = this.filterFieldsStrictly(result, request.fields, request.response_mode); responseData.records = result; } else if (request.response_mode === 'minimal' || request.response_mode === 'compact') { // Apply response mode transformations for non-strict mode result = this.applyResponseMode(result, request.response_mode, request.fields); responseData.records = result; } // Add field validation warnings if any const warnings = this.validateFields(request); // Estimate response size for metadata const responseSize = JSON.stringify(responseData).length; const estimatedTokens = this.estimateTokenCount(JSON.stringify(responseData)); return { success: true, data: responseData, metadata: { operation: request.operation, table: request.table, executionTime, timestamp: new Date().toISOString(), recordCount, batch: isBatchOperation(request), warnings: warnings.length > 0 ? warnings : undefined, // Context overflow prevention metadata responseSize, contextOverflowPrevention: contextOverflowPrevention || responseSize > this.clientConfig.maxResponseSize * 0.8, // Warning threshold downloadHint: downloadHint, // Set by queryRecordsWithMetadata method }, }; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Table operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Query records from a table with full query parameter support * * @param table - Table name * @param queryParams - ServiceNow query parameters * @returns Promise resolving to array of records */ async queryRecords(table: string, queryParams: TableQueryParams = {}): Promise<TableRecord[]> { const url = `${this.baseUrl}/api/now/table/${table}`; const config = this.buildRequestConfig('GET', url, queryParams); try { const response = await this.makeRequest(config); const data = JSON.parse(response.body); if (!Array.isArray(data.result)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, response.statusCode, 'Invalid response format from ServiceNow Table API' ); } // Apply context overflow prevention const overflowResult = this.applyContextOverflowPrevention(data.result, response.body); // Log warning if truncation occurred if (overflowResult.truncated) { console.warn(`Context overflow prevention: Truncated ${overflowResult.originalCount} records to ${overflowResult.records.length} records for table ${table}`); } if (overflowResult.fieldTruncated) { console.warn(`Context overflow prevention: Large text fields truncated for table ${table}`); } return overflowResult.records; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Query operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Query records with overflow prevention metadata * * @param table - Table name * @param queryParams - ServiceNow query parameters * @returns Promise resolving to records and overflow prevention metadata */ async queryRecordsWithMetadata(table: string, queryParams: TableQueryParams = {}): Promise<{ records: TableRecord[]; contextOverflowPrevention: boolean; downloadHint: boolean; summary?: ResultSummary; }> { const url = `${this.baseUrl}/api/now/table/${table}`; const config = this.buildRequestConfig('GET', url, queryParams); try { const response = await this.makeRequest(config); const data = JSON.parse(response.body); if (!Array.isArray(data.result)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, response.statusCode, 'Invalid response format from ServiceNow Table API' ); } // Apply context overflow prevention const overflowResult = this.applyContextOverflowPrevention(data.result, response.body); // Log warning if truncation occurred if (overflowResult.truncated) { console.warn(`Context overflow prevention: Truncated ${overflowResult.originalCount} records to ${overflowResult.records.length} records for table ${table}`); } if (overflowResult.fieldTruncated) { console.warn(`Context overflow prevention: Large text fields truncated for table ${table}`); } return { records: overflowResult.records, contextOverflowPrevention: overflowResult.truncated, downloadHint: overflowResult.fieldTruncated, summary: overflowResult.summary }; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Query operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Get a single record by sys_id * * @param table - Table name * @param sysId - System ID of the record * @returns Promise resolving to the record */ async getRecord(table: string, sysId: string): Promise<TableRecord> { const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`; const config = this.buildRequestConfig('GET', url); try { const response = await this.makeRequest(config); const data = JSON.parse(response.body); if (!data.result || typeof data.result !== 'object') { throw new ServiceNowTableError( TABLE_ERROR_CODES.RECORD_NOT_FOUND, response.statusCode, `Record with sys_id ${sysId} not found in table ${table}` ); } return data.result; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Get record operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Create a single record * * @param table - Table name * @param data - Record data * @returns Promise resolving to the created record */ async createRecord(table: string, data: Record<string, any>): Promise<TableRecord> { const url = `${this.baseUrl}/api/now/table/${table}`; const config = this.buildRequestConfig('POST', url, {}, JSON.stringify(data)); try { const response = await this.makeRequest(config); const result = JSON.parse(response.body); if (!result.result || typeof result.result !== 'object') { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, response.statusCode, 'Invalid response format from ServiceNow Table API' ); } return result.result; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Create record operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Create multiple records in batch * * @param table - Table name * @param records - Array of record data * @returns Promise resolving to array of created records */ async createRecords(table: string, records: Record<string, any>[]): Promise<TableRecord[]> { if (records.length > this.clientConfig.maxBatchSize) { throw new ServiceNowTableError( TABLE_ERROR_CODES.BATCH_SIZE_EXCEEDED, undefined, `Batch size ${records.length} exceeds maximum allowed ${this.clientConfig.maxBatchSize}` ); } // ServiceNow Table API doesn't support true batch operations for POST // So we create records individually const results: TableRecord[] = []; const errors: string[] = []; for (const record of records) { try { const result = await this.createRecord(table, record); results.push(result); } catch (error) { errors.push(`Failed to create record: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (errors.length > 0 && results.length === 0) { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, undefined, `All batch create operations failed: ${errors.join(', ')}` ); } return results; } /** * Update a single record * * @param table - Table name * @param sysId - System ID of the record * @param data - Updated record data * @param method - HTTP method (PUT or PATCH) * @returns Promise resolving to the updated record */ async updateRecord(table: string, sysId: string, data: Record<string, any>, method: 'PUT' | 'PATCH' = 'PUT'): Promise<TableRecord> { const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`; const config = this.buildRequestConfig(method, url, {}, JSON.stringify(data)); try { const response = await this.makeRequest(config); const result = JSON.parse(response.body); if (!result.result || typeof result.result !== 'object') { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, response.statusCode, 'Invalid response format from ServiceNow Table API' ); } return result.result; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Update record operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Update multiple records in batch * * @param table - Table name * @param records - Array of record data with sys_id * @param method - HTTP method (PUT or PATCH) * @returns Promise resolving to array of updated records */ async updateRecords(table: string, records: Record<string, any>[], method: 'PUT' | 'PATCH' = 'PUT'): Promise<TableRecord[]> { if (records.length > this.clientConfig.maxBatchSize) { throw new ServiceNowTableError( TABLE_ERROR_CODES.BATCH_SIZE_EXCEEDED, undefined, `Batch size ${records.length} exceeds maximum allowed ${this.clientConfig.maxBatchSize}` ); } // ServiceNow Table API doesn't support true batch operations for PUT/PATCH // So we update records individually const results: TableRecord[] = []; const errors: string[] = []; for (const record of records) { if (!record.sys_id) { errors.push(`Record missing sys_id: ${JSON.stringify(record)}`); continue; } try { const result = await this.updateRecord(table, record.sys_id, record, method); results.push(result); } catch (error) { errors.push(`Failed to update record ${record.sys_id}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (errors.length > 0 && results.length === 0) { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, undefined, `All batch update operations failed: ${errors.join(', ')}` ); } return results; } /** * Delete a single record * * @param table - Table name * @param sysId - System ID of the record * @returns Promise resolving to the deleted record */ async deleteRecord(table: string, sysId: string): Promise<TableRecord> { const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`; const config = this.buildRequestConfig('DELETE', url); try { const response = await this.makeRequest(config); // DELETE operations typically return 204 No Content with empty body // If we get a successful response, return a minimal record object if (response.statusCode === 204 || response.statusCode === 200) { return { sys_id: sysId, deleted: true }; } // If there's a response body, try to parse it if (response.body && response.body.trim()) { const result = JSON.parse(response.body); if (result.result && typeof result.result === 'object') { return result.result; } } // Fallback for successful deletion return { sys_id: sysId, deleted: true }; } catch (error) { if (error instanceof ServiceNowTableError) { throw error; } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Delete record operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Delete multiple records in batch * * @param table - Table name * @param sysIds - Array of system IDs to delete * @returns Promise resolving to array of deleted records */ async deleteRecords(table: string, sysIds: string[]): Promise<TableRecord[]> { if (sysIds.length > this.clientConfig.maxBatchSize) { throw new ServiceNowTableError( TABLE_ERROR_CODES.BATCH_SIZE_EXCEEDED, undefined, `Batch size ${sysIds.length} exceeds maximum allowed ${this.clientConfig.maxBatchSize}` ); } // ServiceNow doesn't support batch delete via Table API, so we delete individually const results: TableRecord[] = []; const errors: string[] = []; for (const sysId of sysIds) { try { const result = await this.deleteRecord(table, sysId); results.push(result); } catch (error) { errors.push(`Failed to delete ${sysId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (errors.length > 0 && results.length === 0) { throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, undefined, `All delete operations failed: ${errors.join(', ')}` ); } return results; } /** * Build query parameters from request * * @param request - Table operation request * @returns Query parameters object */ private buildQueryParams(request: TableOperationRequest): TableQueryParams { const params: TableQueryParams = {}; // Handle sys_ids for GET operations if (request.sys_ids && Array.isArray(request.sys_ids) && request.sys_ids.length > 0) { const sysIdQuery = request.sys_ids.map(id => `sys_id=${id}`).join('^OR'); params.sysparm_query = request.query ? `${request.query}^${sysIdQuery}` : sysIdQuery; } else if (request.query) { params.sysparm_query = request.query; } if (request.fields) { params.sysparm_fields = request.fields; } // Apply default limit if none specified (context overflow prevention) if (request.limit) { params.sysparm_limit = request.limit; } else { // Apply default limit to prevent massive responses params.sysparm_limit = this.clientConfig.defaultLimit; } if (request.offset) { params.sysparm_offset = request.offset; } if (request.display_value) { params.sysparm_display_value = request.display_value; } if (request.exclude_reference_link !== undefined) { params.sysparm_exclude_reference_link = request.exclude_reference_link; } return params; } /** * Build request configuration * * @param method - HTTP method * @param url - Request URL * @param queryParams - Query parameters * @param body - Request body * @returns Request configuration */ private buildRequestConfig( method: string, url: string, queryParams: TableQueryParams = {}, body?: string ): TableRequestConfig { // Build URL with query parameters const urlObj = new URL(url); Object.entries(queryParams).forEach(([key, value]) => { if (value !== undefined && value !== null) { urlObj.searchParams.set(key, String(value)); } }); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': this.clientConfig.userAgent, 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, }; return { method, url: urlObj.toString(), headers, body, timeout: this.clientConfig.timeoutMs, }; } /** * Make HTTP request with retry logic * * @param config - Request configuration * @returns Promise resolving to HTTP response */ private async makeRequest(config: TableRequestConfig): Promise<TableHTTPResponse> { let lastError: Error | null = null; for (let attempt = 1; attempt <= this.clientConfig.retryAttempts; attempt++) { try { // Create AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout || this.clientConfig.timeoutMs); const response = await fetch(config.url, { method: config.method, headers: config.headers, body: config.body, signal: controller.signal, }); clearTimeout(timeoutId); const body = await response.text(); // Handle HTTP errors if (response.status >= 400) { // Parse error response body for more details let errorMessage = `HTTP ${response.status}: ${response.statusText}`; let errorDetails: any = null; try { if (body && body.trim()) { const errorData = JSON.parse(body); if (errorData.error) { errorDetails = errorData.error; errorMessage = errorData.error.message || errorMessage; } } } catch (parseError) { // Ignore JSON parse errors, use default message } if (response.status === 401) { throw new ServiceNowTableError( TABLE_ERROR_CODES.AUTHENTICATION_FAILED, 401, `Invalid ServiceNow credentials (401 Unauthorized): ${errorMessage}`, errorDetails ); } if (response.status === 403) { throw new ServiceNowTableError( TABLE_ERROR_CODES.PERMISSION_DENIED, 403, `Service account lacks permissions for this operation (403 Forbidden): ${errorMessage}`, errorDetails ); } if (response.status === 404) { throw new ServiceNowTableError( TABLE_ERROR_CODES.RECORD_NOT_FOUND, 404, `Record not found or table not accessible (404 Not Found): ${errorMessage}`, errorDetails ); } if (response.status === 400) { throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_DATA, 400, `Bad request (400 Bad Request): ${errorMessage}`, errorDetails ); } throw new ServiceNowTableError( TABLE_ERROR_CODES.HTTP_ERROR, response.status, errorMessage, errorDetails ); } return { statusCode: response.status, body, headers: Object.fromEntries(response.headers.entries()), }; } catch (error) { lastError = error instanceof Error ? error : new Error('Unknown error'); // Don't retry on deterministic errors (4xx status codes) if (error instanceof ServiceNowTableError && (error.code === TABLE_ERROR_CODES.AUTHENTICATION_FAILED || error.code === TABLE_ERROR_CODES.PERMISSION_DENIED || error.code === TABLE_ERROR_CODES.RECORD_NOT_FOUND || error.code === TABLE_ERROR_CODES.INVALID_DATA || error.code === TABLE_ERROR_CODES.INVALID_OPERATION || error.code === TABLE_ERROR_CODES.INVALID_TABLE || error.code === TABLE_ERROR_CODES.INVALID_SYS_ID || error.code === TABLE_ERROR_CODES.INVALID_QUERY || error.code === TABLE_ERROR_CODES.VALIDATION_ERROR || error.code === TABLE_ERROR_CODES.BATCH_SIZE_EXCEEDED)) { throw error; } // Wait before retry (exponential backoff) if (attempt < this.clientConfig.retryAttempts) { await new Promise(resolve => setTimeout(resolve, this.clientConfig.retryDelay * attempt)); } } } throw new ServiceNowTableError( TABLE_ERROR_CODES.NETWORK_ERROR, undefined, `Request failed after ${this.clientConfig.retryAttempts} attempts: ${lastError?.message || 'Unknown error'}` ); } /** * Validate fields and return warnings for invalid field names * * @param request - Request to validate fields for * @returns Array of warning messages */ private validateFields(request: TableOperationRequest): string[] { const warnings: string[] = []; // Validate fields if explicitly requested OR if fields are provided and validation is not explicitly disabled if (request.validate_fields === false) { return warnings; } // Check fields parameter for potential issues if (request.fields) { const fields = request.fields.split(','); const invalidFields: string[] = []; const suspiciousFields: string[] = []; // Basic field name validation (alphanumeric, underscore, dot) const fieldRegex = /^[a-zA-Z_][a-zA-Z0-9_.]*$/; // Common ServiceNow field patterns for additional validation const commonFields = [ 'sys_id', 'sys_created_on', 'sys_created_by', 'sys_updated_on', 'sys_updated_by', 'sys_mod_count', 'active', 'state', 'number', 'short_description', 'description', 'priority', 'urgency', 'category', 'subcategory', 'assigned_to', 'assignment_group', 'opened_by', 'opened_at', 'closed_at', 'resolved_at', 'due_date', 'work_notes', 'comments', 'close_notes', 'resolution_notes', 'user_name', 'email', 'first_name', 'last_name', 'phone', 'mobile_phone', 'department', 'location', 'manager' ]; // Common non-existent field patterns that users might try const suspiciousPatterns = [ 'nonexistent_field', 'invalid_field', 'test_field', 'dummy_field', 'fake_field', 'unknown_field', 'missing_field', 'bad_field', 'wrong_field', 'error_field' ]; for (const field of fields) { const trimmedField = field.trim(); if (trimmedField) { // Check for invalid field names if (!fieldRegex.test(trimmedField)) { invalidFields.push(trimmedField); } // Check for suspicious non-existent field patterns else if (suspiciousPatterns.includes(trimmedField)) { suspiciousFields.push(`${trimmedField} appears to be a test/non-existent field name. This field may not exist in the table.`); } // Check for potentially misspelled common fields else if (!commonFields.includes(trimmedField)) { // Check for close matches to common fields const closeMatch = commonFields.find(common => this.levenshteinDistance(trimmedField, common) <= 2 && Math.abs(trimmedField.length - common.length) <= 2 ); if (closeMatch) { suspiciousFields.push(`${trimmedField} (did you mean '${closeMatch}'?)`); } } } } if (invalidFields.length > 0) { warnings.push(`Invalid field names detected: ${invalidFields.join(', ')}. These fields will be silently ignored by ServiceNow.`); } if (suspiciousFields.length > 0) { warnings.push(`Potentially misspelled field names: ${suspiciousFields.join(', ')}. Please verify field names to avoid silent failures.`); } } return warnings; } /** * Filter fields strictly to only include requested fields and strip large fields * * @param records - Array of records to filter * @param fields - Comma-separated list of fields to include * @returns Filtered records with only requested fields */ private filterFieldsStrictly(records: TableRecord[], fields: string, responseMode?: string): TableRecord[] { const requestedFields = fields.split(',').map(f => f.trim()); const largeFields = ['script', 'html', 'css', 'description', 'work_notes', 'comments', 'close_notes', 'resolution_notes']; const maxLength = responseMode === 'minimal' ? 500 : 1000; // Harder clamp for minimal mode const result = records.map(record => { const filtered: any = {}; // Only include explicitly requested fields requestedFields.forEach(field => { if (record[field] !== undefined) { // For large fields, only include if explicitly requested if (largeFields.includes(field)) { if (typeof record[field] === 'string' && record[field].length > maxLength) { filtered[field] = record[field].substring(0, maxLength) + `...[truncated, use download for full content]`; } else if (typeof record[field] === 'string' && record[field].length > 100) { filtered[field] = record[field].substring(0, 100) + '...[truncated]'; } else { filtered[field] = record[field]; } } else { // For non-large fields, include as-is filtered[field] = record[field]; } } }); return filtered; }); return result; } /** * Apply response mode transformations to reduce response size * * @param records - Array of records to process * @param responseMode - Response mode (minimal, compact) * @param fields - Requested fields (if any) * @returns Processed records */ private applyResponseMode(records: TableRecord[], responseMode: string, fields?: string): TableRecord[] { const largeFields = ['script', 'html', 'css', 'description', 'work_notes', 'comments', 'close_notes', 'resolution_notes']; const maxLength = responseMode === 'minimal' ? 500 : 1000; // Harder clamp for minimal mode return records.map(record => { const processed: any = {}; // If fields are specified, only include those fields if (fields) { const requestedFields = fields.split(',').map(f => f.trim()); requestedFields.forEach(field => { if (record[field] !== undefined) { // Apply harder clamp for large fields if (largeFields.includes(field) && typeof record[field] === 'string' && record[field].length > maxLength) { processed[field] = record[field].substring(0, maxLength) + `...[truncated, use download for full content]`; } else { processed[field] = record[field]; } } }); } else { // Include all fields but process large ones Object.keys(record).forEach(key => { if (largeFields.includes(key) && typeof record[key] === 'string' && record[key].length > maxLength) { processed[key] = record[key].substring(0, maxLength) + `...[truncated, use download for full content]`; } else { processed[key] = record[key]; } }); } return processed; }); } /** * Calculate Levenshtein distance between two strings for fuzzy matching * * @param str1 - First string * @param str2 - Second string * @returns Edit distance between strings */ private levenshteinDistance(str1: string, str2: string): number { const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); for (let i = 0; i <= str1.length; i++) { matrix[0][i] = i; } for (let j = 0; j <= str2.length; j++) { matrix[j][0] = j; } for (let j = 1; j <= str2.length; j++) { for (let i = 1; i <= str1.length; i++) { const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j][i - 1] + 1, // deletion matrix[j - 1][i] + 1, // insertion matrix[j - 1][i - 1] + indicator // substitution ); } } return matrix[str2.length][str1.length]; } /** * Validate table operation request * * @param request - Request to validate * @throws ServiceNowTableError if validation fails */ private validateTableRequest(request: TableOperationRequest): void { if (!request.operation || !['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(request.operation)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_OPERATION, undefined, `Invalid operation: ${request.operation}. Must be one of: GET, POST, PUT, PATCH, DELETE` ); } if (!request.table || request.table.trim().length === 0) { throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_TABLE, undefined, 'Table name is required and cannot be empty' ); } // Validate operation-specific requirements if (['PUT', 'PATCH', 'DELETE'].includes(request.operation)) { if (!request.sys_id && !request.sys_ids && !isBatchOperation(request)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.MISSING_PARAMETER, undefined, `${request.operation} operation requires sys_id or sys_ids parameter` ); } } if (['POST', 'PUT', 'PATCH'].includes(request.operation)) { if (!request.data) { throw new ServiceNowTableError( TABLE_ERROR_CODES.MISSING_PARAMETER, undefined, `${request.operation} operation requires data parameter` ); } } // Validate batch operations if (isBatchOperation(request)) { if (request.operation === 'POST' && !Array.isArray(request.data)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_DATA, undefined, 'Batch POST operation requires data to be an array' ); } if (request.operation === 'DELETE' && !Array.isArray(request.sys_ids)) { throw new ServiceNowTableError( TABLE_ERROR_CODES.INVALID_DATA, undefined, 'Batch DELETE operation requires sys_ids to be an array' ); } } } /** * Get client configuration * * @returns Current client configuration */ getConfig(): Required<TableClientConfig> { return { ...this.clientConfig }; } /** * Update client configuration * * @param newConfig - New configuration options */ updateConfig(newConfig: Partial<TableClientConfig>): void { this.clientConfig = { ...this.clientConfig, ...newConfig }; } /** * Check if response size exceeds limits and apply context overflow prevention * * @param records - Array of records from ServiceNow * @param responseBody - Raw response body for size calculation * @returns Processed records with overflow prevention applied */ private applyContextOverflowPrevention( records: TableRecord[], responseBody: string ): { records: TableRecord[]; summary?: ResultSummary; truncated: boolean; originalCount: number; fieldTruncated: boolean } { const responseSize = responseBody.length; const recordCount = records.length; const originalCount = recordCount; // Large text fields that commonly cause context bloat const largeTextFields = ['script', 'html', 'css', 'description', 'work_notes', 'comments', 'close_notes', 'resolution_notes', 'content', 'body']; const maxFieldLength = 2000; // Hard limit per field to prevent bloat let fieldTruncated = false; // First pass: Truncate individual large text fields within records const processedRecords = records.map(record => { const processed: any = {}; let recordTruncated = false; Object.keys(record).forEach(key => { const value = record[key]; // Truncate large text fields if (largeTextFields.includes(key) && typeof value === 'string' && value.length > maxFieldLength) { processed[key] = value.substring(0, maxFieldLength) + `...[truncated, use download for full content]`; recordTruncated = true; fieldTruncated = true; } else { processed[key] = value; } }); return processed; }); // Check if we still exceed limits after field truncation const processedResponseSize = JSON.stringify(processedRecords).length; if (processedResponseSize > this.clientConfig.maxResponseSize) { // Calculate how many records we can safely include const avgRecordSize = processedResponseSize / recordCount; const maxSafeRecords = Math.floor(this.clientConfig.maxResponseSize / avgRecordSize); const safeRecordCount = Math.max(1, Math.min(maxSafeRecords, this.clientConfig.maxSummaryRecords)); // Truncate records if necessary const truncatedRecords = processedRecords.slice(0, safeRecordCount); const truncated = recordCount > safeRecordCount; // Create summary if truncation occurred let summary: ResultSummary | undefined; if ((truncated || fieldTruncated) && this.clientConfig.enableResultSummarization) { const messages = []; if (truncated) { messages.push(`Response truncated due to size limits. Showing ${safeRecordCount} of ${originalCount} records.`); } if (fieldTruncated) { messages.push(`Large text fields truncated to prevent context bloat. Use download for full content.`); } messages.push(`Use pagination (offset/limit) or field filtering to reduce response size.`); summary = { total_records: originalCount, displayed_records: safeRecordCount, truncated: true, sample_records: truncatedRecords, message: messages.join(' ') }; } return { records: truncatedRecords, summary, truncated: truncated || fieldTruncated, originalCount, fieldTruncated }; } return { records: processedRecords, truncated: fieldTruncated, originalCount, fieldTruncated }; } /** * Create a summary for large result sets * * @param records - Array of records * @param totalCount - Total number of records available * @returns Result summary */ private createResultSummary(records: TableRecord[], totalCount: number): ResultSummary { const displayedCount = records.length; const truncated = displayedCount < totalCount; return { total_records: totalCount, displayed_records: displayedCount, truncated, sample_records: records.slice(0, this.clientConfig.maxSummaryRecords), message: truncated ? `Showing ${displayedCount} of ${totalCount} records. Use pagination (offset/limit) to retrieve more records.` : `Retrieved all ${totalCount} records.` }; } /** * Estimate token count for a response (rough approximation) * * @param responseBody - Response body string * @returns Estimated token count */ private estimateTokenCount(responseBody: string): number { // Rough approximation: 1 token ≈ 4 characters for English text // This is a conservative estimate for JSON responses return Math.ceil(responseBody.length / 3); } }

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