import fetch, { Response } from 'node-fetch';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import { CookieJar } from 'tough-cookie';
import * as cheerio from 'cheerio';
import {
ServiceNowConfig,
ScriptExecutionRequest,
ScriptExecutionResult,
ServiceNowScriptError,
ServiceNowHTTPResponse,
ScriptExecutionFormData,
ClientConfig,
DEFAULT_CONFIG,
ERROR_CODES,
} from './types.js';
import { extractAndValidateCSRFToken } from './tokenExtractor.js';
import { parseScriptOutput } from './outputParser.js';
/**
* Load credentials from MCP-ACE specific .env file
* Format: KEY=VALUE (one per line)
* Uses separate credential files to avoid conflicts with MCP-Connect
*/
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 Background Script Client
*
* Handles the complete flow of executing background scripts in ServiceNow:
* 1. GET the background script page to obtain CSRF token and cookies
* 2. POST the script execution request with form data
* 3. Parse and return the execution results
*/
export class ServiceNowBackgroundScriptClient {
private config: ServiceNowConfig;
private baseUrl: string;
private clientConfig: Required<ClientConfig>;
private cookieJar: CookieJar;
constructor(clientConfig: ClientConfig = {}) {
// Load MCP-ACE specific credentials (separate from MCP-Connect)
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 ServiceNowScriptError(
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.config = { instance, username, password };
this.baseUrl = `https://${instance}`;
this.clientConfig = { ...DEFAULT_CONFIG, ...clientConfig };
this.cookieJar = new CookieJar();
}
/**
* Execute a background script in ServiceNow
*
* @param request - Script execution request
* @returns Promise resolving to execution result
*/
async executeScript(request: ScriptExecutionRequest): Promise<ScriptExecutionResult> {
const startTime = Date.now();
try {
// Validate input
this.validateScriptRequest(request);
// Step 1: Establish UI session via form login
await this.establishUISession();
// Step 2: GET the background script page and parse form data
const pageResponse = await this.getBackgroundScriptPage();
const { actionUrl, formData } = this.parseFormData(pageResponse.html, request.script, request.scope);
// Step 3: POST the script execution request with retry logic
let executionResponse = await this.postScriptExecution({
actionUrl,
formData,
timeoutMs: request.timeoutMs || this.clientConfig.timeoutMs,
});
// Log telemetry (expert specification)
const cookieNames = this.getCookieHeader(actionUrl).split(';').map(c => c.split('=')[0]).filter(c => c).join(',');
// Check for session timeout and retry if needed
let retry = false;
if (this.hasSessionTimeout(executionResponse.body)) {
retry = true;
// Get fresh page and parse form data
const freshPageResponse = await this.getBackgroundScriptPage();
const { actionUrl: freshActionUrl, formData: freshFormData } = this.parseFormData(freshPageResponse.html, request.script, request.scope);
// Retry POST with fresh form data
executionResponse = await this.postScriptExecution({
actionUrl: freshActionUrl,
formData: freshFormData,
timeoutMs: request.timeoutMs || this.clientConfig.timeoutMs,
});
}
// Step 3: Parse the response
const parsedOutput = parseScriptOutput(executionResponse.body);
const executionTime = Date.now() - startTime;
// Log pre blocks telemetry
const preBlocks = (executionResponse.body.match(/<pre[^>]*>/gi) || []).length;
// Handle response_mode shortcuts and include_html parameter for text-only mode
const shouldIncludeHtml = request.include_html !== false && request.response_mode !== 'minimal';
if (!shouldIncludeHtml) {
return {
success: true,
output: {
text: parsedOutput.text,
},
metadata: {
executionTime,
htmlSize: 0,
timestamp: new Date().toISOString(),
scope: request.scope,
},
};
}
return {
success: true,
output: {
html: parsedOutput.html,
text: parsedOutput.text,
},
metadata: {
executionTime,
htmlSize: parsedOutput.html.length,
timestamp: new Date().toISOString(),
scope: request.scope,
},
};
} catch (error) {
if (error instanceof ServiceNowScriptError) {
throw error;
}
throw new ServiceNowScriptError(
ERROR_CODES.SCRIPT_EXECUTION_ERROR,
undefined,
`Script execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Set cookies from response headers into the cookie jar
*
* @param response - The HTTP response
* @param url - The URL the response came from
*/
private setCookiesFromResponse(response: Response, url: string): void {
// Use raw() to get all Set-Cookie header lines
const setCookies = response.headers.raw()['set-cookie'] || [];
for (const setCookie of setCookies) {
this.cookieJar.setCookieSync(setCookie, url);
}
}
/**
* Get cookie header string for a URL
*
* @param url - The URL to get cookies for
* @returns Cookie header string
*/
private getCookieHeader(url: string): string {
return this.cookieJar.getCookieStringSync(url);
}
/**
* Check if response indicates session timeout
*
* @param body - The response body
* @returns True if session timeout detected
*/
private hasSessionTimeout(body: string): boolean {
return body.includes('session has expired') ||
body.includes('not logged in') ||
body.includes('session_timeout') ||
body.includes('You are not logged in');
}
/**
* Parse form data from the background script page
*
* @param html - The HTML content of the background script page
* @param script - The script to execute
* @param scope - The scope for the script
* @returns Object containing form action URL and form data
*/
private parseFormData(html: string, script: string, scope: string): { actionUrl: string; formData: URLSearchParams } {
const $ = cheerio.load(html);
// Find the form and get its action URL
const form = $('form').first();
const action = form.attr('action') || '/sys.scripts.modern.do';
const actionUrl = new URL(action, this.baseUrl).toString();
// Collect all form fields (hidden inputs, selects, etc.)
const formData = new URLSearchParams();
$('form input, form select, form textarea').each((_, el) => {
const $el = $(el);
const name = $el.attr('name');
if (!name) return;
const type = ($el.attr('type') || '').toLowerCase();
const value = $el.val();
const stringValue = Array.isArray(value) ? value.join(',') : (value || '');
if (type === 'checkbox' || type === 'radio') {
if ($el.is(':checked')) {
formData.append(name, stringValue);
}
} else if (name !== 'script') {
// Don't override the script field yet, collect all other fields
formData.set(name, stringValue);
}
});
// Set the script content
formData.set('script', script);
// ALWAYS use the requested scope parameter, regardless of form fields
// This ensures the scope parameter is properly forwarded to ServiceNow
if (formData.has('current_scope')) {
formData.set('current_scope', scope);
} else if (formData.has('sys_scope')) {
formData.set('sys_scope', scope);
} else {
formData.set('current_scope', scope);
}
// Find the submit button and get its name/value
const submitButton = $('form input[type=submit], form button[type=submit]').first();
if (submitButton.length) {
const submitName = submitButton.attr('name') || 'runscript';
const submitValue = submitButton.attr('value') || 'Run script';
formData.set(submitName, submitValue);
} else {
// Fallback if no submit button found
formData.set('runscript', 'Run script');
}
return { actionUrl, formData };
}
/**
* Establish a UI session via form login
*
* @returns Promise resolving when session is established
*/
private async establishUISession(): Promise<void> {
const loginUrl = `${this.baseUrl}/login.do`;
try {
// Step 1: GET login page
const getResponse = await fetch(loginUrl, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': this.clientConfig.userAgent,
},
});
if (!getResponse.ok) {
throw new ServiceNowScriptError(
ERROR_CODES.NETWORK_ERROR,
getResponse.status,
`Failed to get login page: ${getResponse.statusText}`
);
}
// Set cookies from login page
this.setCookiesFromResponse(getResponse, loginUrl);
await getResponse.text();
// Step 2: POST login form
const formData = new URLSearchParams();
formData.set('user_name', this.config.username);
formData.set('user_password', this.config.password);
formData.set('remember_me', 'true');
formData.set('sys_action', 'sysverb_login');
const postResponse = await fetch(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': this.clientConfig.userAgent,
'Cookie': this.getCookieHeader(loginUrl),
},
body: formData.toString(),
redirect: 'manual'
});
// Set cookies from login response
this.setCookiesFromResponse(postResponse, loginUrl);
// Handle redirect if present
if (postResponse.status >= 300 && postResponse.status < 400) {
const location = postResponse.headers.get('location');
if (location) {
const redirectUrl = new URL(location, this.baseUrl).toString();
const redirectResponse = await fetch(redirectUrl, {
headers: {
'Cookie': this.getCookieHeader(redirectUrl),
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': this.clientConfig.userAgent,
}
});
this.setCookiesFromResponse(redirectResponse, redirectUrl);
await redirectResponse.text();
}
} else {
await postResponse.text();
}
// Step 3: Verify login by checking navpage
const navUrl = `${this.baseUrl}/navpage.do`;
const navResponse = await fetch(navUrl, {
headers: {
'Cookie': this.getCookieHeader(navUrl),
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': this.clientConfig.userAgent,
}
});
this.setCookiesFromResponse(navResponse, navUrl);
const navBody = await navResponse.text();
// Check if we're actually logged in (expert specification)
// Look for specific login indicators, not just the word "login"
const hasLoginRedirect = /You are not logged in|session has expired|login\.do/i.test(navBody);
if (hasLoginRedirect || this.hasSessionTimeout(navBody)) {
throw new ServiceNowScriptError(
ERROR_CODES.AUTHENTICATION_FAILED,
undefined,
'Form login failed - still not logged in'
);
}
} catch (error) {
if (error instanceof ServiceNowScriptError) {
throw error;
}
throw new ServiceNowScriptError(
ERROR_CODES.NETWORK_ERROR,
undefined,
`Failed to establish UI session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* GET the background script page to obtain CSRF token and cookies
*
* @returns Promise resolving to page response
*/
public async getBackgroundScriptPage(): Promise<{ html: string; cookies: string[]; headers?: Record<string, string> }> {
const url = `${this.baseUrl}/sys.scripts.modern.do`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'text/html',
'User-Agent': this.clientConfig.userAgent,
'Cookie': this.getCookieHeader(url),
},
});
if (response.status === 401) {
throw new ServiceNowScriptError(
ERROR_CODES.AUTHENTICATION_FAILED,
401,
'Invalid ServiceNow credentials (401 Unauthorized)'
);
}
if (response.status === 403) {
throw new ServiceNowScriptError(
ERROR_CODES.PERMISSION_DENIED,
403,
'Service account lacks permissions to access background scripts (403 Forbidden)'
);
}
if (!response.ok) {
throw new ServiceNowScriptError(
ERROR_CODES.HTTP_ERROR,
response.status,
`HTTP ${response.status}: ${response.statusText}`
);
}
// Set cookies from response into the jar
this.setCookiesFromResponse(response, url);
const html = await response.text();
// Extract headers for debugging
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
// Get cookies for debugging
const cookies = this.getCookieHeader(url).split(';').map(c => c.trim()).filter(c => c);
return { html, cookies, headers };
} catch (error) {
if (error instanceof ServiceNowScriptError) {
throw error;
}
throw new ServiceNowScriptError(
ERROR_CODES.NETWORK_ERROR,
undefined,
`Network request failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* POST script execution request to ServiceNow
*
* @param params - Parameters for script execution
* @returns Promise resolving to HTTP response
*/
private async postScriptExecution(params: {
actionUrl: string;
formData: URLSearchParams;
timeoutMs: number;
}): Promise<ServiceNowHTTPResponse> {
const { actionUrl, formData } = params;
// Build headers
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html',
'User-Agent': this.clientConfig.userAgent,
'Origin': this.baseUrl,
'Referer': `${this.baseUrl}/sys.scripts.modern.do`,
};
// Add cookies from the jar
const cookieHeader = this.getCookieHeader(actionUrl);
if (cookieHeader) {
headers['Cookie'] = cookieHeader;
}
try {
const response = await fetch(actionUrl, {
method: 'POST',
headers,
body: formData.toString(),
redirect: 'manual'
});
// Set cookies from response into the jar
this.setCookiesFromResponse(response, actionUrl);
let body = await response.text();
let finalUrl = actionUrl;
// Handle redirect if present
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
const redirectUrl = new URL(location, this.baseUrl).toString();
const redirectResponse = await fetch(redirectUrl, {
headers: {
'Cookie': this.getCookieHeader(redirectUrl),
'Accept': 'text/html',
'User-Agent': this.clientConfig.userAgent,
}
});
this.setCookiesFromResponse(redirectResponse, redirectUrl);
body = await redirectResponse.text();
finalUrl = redirectUrl;
}
}
return {
statusCode: response.status,
body,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) {
if (error instanceof ServiceNowScriptError) {
throw error;
}
throw new ServiceNowScriptError(
ERROR_CODES.NETWORK_ERROR,
undefined,
`Network request failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Validate script execution request
*
* @param request - Request to validate
* @throws ServiceNowScriptError if validation fails
*/
private validateScriptRequest(request: ScriptExecutionRequest): void {
if (!request.script || request.script.trim().length === 0) {
throw new ServiceNowScriptError(
ERROR_CODES.INVALID_SCRIPT,
undefined,
'Script cannot be empty'
);
}
if (request.script.length > this.clientConfig.maxScriptLength) {
throw new ServiceNowScriptError(
ERROR_CODES.INVALID_SCRIPT,
undefined,
`Script length (${request.script.length}) exceeds maximum allowed length (${this.clientConfig.maxScriptLength})`
);
}
if (!request.scope || request.scope.trim().length === 0) {
throw new ServiceNowScriptError(
ERROR_CODES.INVALID_SCOPE,
undefined,
'Scope cannot be empty'
);
}
if (request.timeoutMs && (request.timeoutMs < 1000 || request.timeoutMs > 300000)) {
throw new ServiceNowScriptError(
ERROR_CODES.VALIDATION_ERROR,
undefined,
'Timeout must be between 1000ms and 300000ms'
);
}
}
/**
* Get client configuration
*
* @returns Current client configuration
*/
getConfig(): Required<ClientConfig> {
return { ...this.clientConfig };
}
/**
* Update client configuration
*
* @param newConfig - New configuration options
*/
updateConfig(newConfig: Partial<ClientConfig>): void {
this.clientConfig = { ...this.clientConfig, ...newConfig };
}
}