import fetch, { Response } from 'node-fetch';
import { readFileSync } from 'fs';
import { resolve } from 'path';
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
*/
function loadEnvFile(): Record<string, string> {
const envVars: Record<string, string> = {};
// Try user home directory first (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)
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
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);
}
}