@codesys/mcp-toolkit
Version:
Model Context Protocol (MCP) server for CODESYS automation platform
271 lines (270 loc) • 15.9 kB
JavaScript
/**
* CODESYS Interop Module
* Handles direct interaction with the CODESYS executable via command-line scripts.
*
* This module manages:
* - Creating temporary Python script files
* - Executing them via CODESYS's scripting engine
* - Capturing and processing results
*
* IMPORTANT: Path handling for Windows is critical - paths with spaces require
* special handling to avoid the 'C:\Program' not recognized error.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeCodesysScript = executeCodesysScript;
const child_process_1 = require("child_process");
const promises_1 = require("fs/promises");
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const fs = __importStar(require("fs")); // Import fs for existsSync check
// Define expected success/error markers from the Python scripts
const SCRIPT_SUCCESS_MARKER = 'SCRIPT_SUCCESS';
const SCRIPT_ERROR_MARKER = 'SCRIPT_ERROR';
/**
* Executes a CODESYS Python script using the command line interface.
*
* @param scriptContent The Python script code to execute.
* @param codesysExePath The full path to the CODESYS.exe executable (can contain spaces).
* @param codesysProfileName The name of the CODESYS profile to use for scripting.
* @returns A promise resolving to an object containing the success status and the script's output.
*
* NOTE: Using shell: true with careful command string quoting to handle
* CODESYS argument parsing quirks when launched non-interactively.
*/
function executeCodesysScript(scriptContent, codesysExePath, codesysProfileName) {
return __awaiter(this, void 0, void 0, function* () {
// --- Pre-checks ---
if (!codesysExePath)
throw new Error('CODESYS executable path was not provided.');
if (!codesysProfileName)
throw new Error('CODESYS profile name was not provided.');
if (!fs.existsSync(codesysExePath))
throw new Error(`CODESYS executable not found at provided path: ${codesysExePath}`);
// --- End Pre-checks ---
const tempDir = os.tmpdir();
const tempFileName = `codesys_script_${Date.now()}_${Math.random().toString(36).substring(2, 9)}.py`;
const tempFilePath = path.join(tempDir, tempFileName); // Path module uses OS-specific separators ('\' on Windows)
let output = '';
let stderrOutput = '';
let success = false;
let exitCode = null;
const codesysDir = path.dirname(codesysExePath); // Directory containing CODESYS.exe
try {
const normalizedScriptContent = scriptContent.replace(/\r\n/g, '\n'); // Normalize line endings
// <<< --- ADDED SCRIPT CONTENT LOGGING --- >>>
process.stderr.write(`INTEROP: Script content to be written (first 500 chars):\n`);
process.stderr.write(`------ START SCRIPT (TEMP FILE) -----\n`);
process.stderr.write(`${normalizedScriptContent.substring(0, 500)}\n`); // Log the first 500 chars
process.stderr.write(`------ END SCRIPT SNIPPET (TEMP FILE) -----\n`);
// <<< --- END SCRIPT CONTENT LOGGING --- >>>
yield (0, promises_1.writeFile)(tempFilePath, normalizedScriptContent, 'latin1'); // Write the normalized content
process.stderr.write(`INTEROP: Temp script written: ${tempFilePath}\n`);
// --- Construct command string for shell: true ---
// Quote the executable path itself
const quotedExePath = `"${codesysExePath}"`;
// Format arguments exactly as CODESYS seems to want: --option="Value With Spaces"
// The outer quotes are for the shell parser.
const profileArg = `--profile="${codesysProfileName}"`;
const scriptArg = `--runscript="${tempFilePath}"`; // tempFilePath from path.join has correct backslashes for Win
// Combine into a single string for the shell
const fullCommandString = `${quotedExePath} ${profileArg} --noUI ${scriptArg}`;
// Example result: "\"C:\\Program Files\\...\\CODESYS.exe\" --profile=\"CODESYS V3.5 SP21\" --noUI --runscript=\"C:\\Users\\...\\script.py\""
// --- End command string construction ---
process.stderr.write(`INTEROP: Spawning command (shell:true): ${fullCommandString}\n`);
process.stderr.write(`INTEROP ENV: CWD before spawn: ${process.cwd()}\n`);
process.stderr.write(`INTEROP ENV: Forcing CWD for spawn: ${codesysDir}\n`); // Re-enabled CWD change
// --- Create modified environment (Re-enabled) ---
const spawnEnv = Object.assign({}, process.env);
const pathSeparator = ';'; // Windows
const originalPath = spawnEnv.PATH || '';
spawnEnv.PATH = `${codesysDir}${pathSeparator}${originalPath}`; // Prepend CODESYS dir to PATH
process.stderr.write(`INTEROP ENV: MODIFIED PATH for spawn (prepended): ${spawnEnv.PATH.substring(0, 100)}...\n`); // Re-enabled ENV change
// --- End modified environment ---
const spawnResult = yield new Promise((resolve) => {
let stdoutData = '';
let stderrData = '';
const controller = new AbortController();
const timeoutSignal = controller.signal;
const timeoutDuration = 60000; // 60 seconds
// Pass the single command string, empty args array, and shell: true
const childProcess = (0, child_process_1.spawn)(fullCommandString, [], {
windowsHide: true,
signal: timeoutSignal,
cwd: codesysDir, // Re-enabled CWD change
env: spawnEnv, // Re-enabled ENV change
shell: true // USE shell: true
});
const timeoutId = setTimeout(() => {
process.stderr.write('INTEROP: Process timeout reached.\n');
controller.abort();
}, timeoutDuration);
// --- Event Listeners (stdout, stderr, error, close, abort) ---
childProcess.stdout.on('data', (data) => {
const chunk = data.toString();
stdoutData += chunk;
process.stderr.write(`INTEROP stdout chunk: ${chunk.length > 50 ? chunk.substring(0, 50) + '...' : chunk}\n`);
});
childProcess.stderr.on('data', (data) => {
const chunk = data.toString();
stderrData += chunk;
// Check for specific error patterns
if (chunk.includes('--profile="profile name"')) {
process.stderr.write(`>>>> INTEROP STDERR DETECTED Profile Error Message: ${chunk}\n`);
}
else if (chunk.includes('is not recognized')) {
process.stderr.write(`>>>> INTEROP STDERR DETECTED 'not recognized' (shell issue?): ${chunk}\n`);
}
else if (chunk.includes('SyntaxErrorException')) {
process.stderr.write(`>>>> INTEROP STDERR DETECTED Syntax Error: ${chunk}\n`);
} // More specific check
else {
process.stderr.write(`INTEROP stderr chunk: ${chunk}\n`);
}
});
childProcess.on('error', (spawnError) => {
var _a;
clearTimeout(timeoutId);
process.stderr.write(`INTEROP SPAWN ERROR (shell:true): ${spawnError.message}\n`);
resolve({ code: (_a = spawnError.errno) !== null && _a !== void 0 ? _a : 1, stdout: stdoutData, stderr: stderrData, error: spawnError });
});
childProcess.on('close', (code) => {
clearTimeout(timeoutId);
process.stderr.write(`INTEROP: Process closed code: ${code}\n`);
resolve({ code: code, stdout: stdoutData, stderr: stderrData });
});
timeoutSignal.addEventListener('abort', () => {
process.stderr.write('INTEROP: Abort signal received, attempting to kill process.\n');
if (!childProcess.killed) {
if (!childProcess.kill('SIGTERM')) { // Try graceful termination first
process.stderr.write('INTEROP: SIGTERM failed, attempting SIGKILL in 2s.\n');
setTimeout(() => { if (!childProcess.killed)
childProcess.kill('SIGKILL'); }, 2000);
}
else {
process.stderr.write('INTEROP: SIGTERM sent.\n');
}
}
resolve({ code: null, stdout: stdoutData, stderr: stderrData + "\nTIMEOUT: Process aborted due to timeout." });
}, { once: true });
// --- End Event Listeners ---
});
output = spawnResult.stdout;
stderrOutput = spawnResult.stderr;
exitCode = spawnResult.code;
// --- Success Determination Logic ---
success = false; // Assume failure unless proven otherwise
if (spawnResult.error) {
process.stderr.write(`INTEROP: Failure determined by spawn error: ${spawnResult.error.message}\n`);
if (!stderrOutput.includes(SCRIPT_ERROR_MARKER))
stderrOutput = `SCRIPT_ERROR: Spawn failed: ${spawnResult.error.message}\n${stderrOutput}`;
}
else if (stderrOutput.includes('is not recognized as an internal or external command')) {
process.stderr.write("INTEROP: Failure determined by 'not recognized' error in stderr (shell:true quoting issue likely).\n");
if (!stderrOutput.includes(SCRIPT_ERROR_MARKER))
stderrOutput = `SCRIPT_ERROR: Shell execution failed: ${stderrOutput}`;
}
else if (stderrOutput.includes('--profile="profile name"')) {
process.stderr.write("INTEROP: Failure determined by CODESYS profile error message in stderr.\n");
if (!stderrOutput.includes(SCRIPT_ERROR_MARKER))
stderrOutput = `SCRIPT_ERROR: ${stderrOutput}`;
}
else if (stderrOutput.includes('SyntaxErrorException')) { // Check for syntax error specifically
process.stderr.write("INTEROP: Failure determined by CODESYS Script Syntax Error.\n");
if (!stderrOutput.includes(SCRIPT_ERROR_MARKER))
stderrOutput = `SCRIPT_ERROR: ${stderrOutput}`; // Include the syntax error details
}
else {
// No spawn error, no shell error, no profile error, no syntax error -> check markers/exit code
process.stderr.write(`INTEROP: Checking markers/exit code (Code: ${exitCode})...\n`);
if (output.includes(SCRIPT_SUCCESS_MARKER) || stderrOutput.includes(SCRIPT_SUCCESS_MARKER)) {
success = true;
process.stderr.write("INTEROP: Success determined by SUCCESS marker.\n");
}
else if (output.includes(SCRIPT_ERROR_MARKER) || stderrOutput.includes(SCRIPT_ERROR_MARKER)) {
success = false; // Explicit error marker found
process.stderr.write("INTEROP: Failure determined by ERROR marker.\n");
}
else {
// No markers found, rely solely on exit code
success = exitCode === 0;
if (success) {
process.stderr.write(`INTEROP: Success determined by exit code 0 (no markers found).\n`);
}
else {
process.stderr.write(`INTEROP: Failure determined by non-zero exit code ${exitCode} (no markers found).\n`);
// Add generic failure message if stderr doesn't already contain SCRIPT_ERROR
if (!stderrOutput.includes(SCRIPT_ERROR_MARKER))
stderrOutput = `SCRIPT_ERROR: Process failed with exit code ${exitCode} (no markers found).\n${stderrOutput}`;
}
}
}
// --- End Success Determination ---
}
catch (error) {
process.stderr.write(`INTEROP: Error during setup: ${error.message}\n${error.stack}\n`);
stderrOutput = `SCRIPT_ERROR: Failed during script execution setup: ${error.message}`;
success = false;
}
finally {
// Cleanup: Attempt to delete the temporary script file
try {
yield (0, promises_1.unlink)(tempFilePath);
process.stderr.write(`INTEROP: Temp script deleted: ${tempFilePath}\n`);
}
catch (cleanupError) {
process.stderr.write(`INTEROP: Failed to delete temp file ${tempFilePath}: ${cleanupError.message}\n`);
if (success)
stderrOutput += `\nWARNING: Failed to delete temporary script file ${tempFilePath}. ${cleanupError.message}`;
}
}
// Final output processing
// Combine stderr and stdout only on failure to preserve clean success output
const finalOutput = success ? output : `${stderrOutput}\n${output}`.trim();
process.stderr.write(`INTEROP: Final Success: ${success}\n`);
process.stderr.write(`INTEROP: Final Output Length: ${finalOutput.length}\n---\n`);
return { success, output: finalOutput };
});
}
;