Skip to main content
Glama
client.ts20.4 kB
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 }; } }

Implementation Reference

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