WHY: connect_to_device and download_to_device against a password-protected runtime pop a modal "Device User Login" dialog in the IDE. IronPython can't marshal to the WPF UI thread to dismiss it, so headless / agent-driven sessions block forever -- and even for interactive use, the dialog pops on EVERY download, which is a constant friction point. API: ScriptOnline.set_default_credentials(username, password) was added in CODESYS scripting API 3.5.3.0. Effect lasts until end of the current script execution. Source: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptOnline.html Implementation: - New helper script src/scripts/register_device_credentials.py defines a register_device_credentials_if_set() function that no-ops when DEVICE_USER or DEVICE_PASSWORD is empty, gracefully skips on older SPs that lack set_default_credentials, and never raises (always falls back to the current dialog-prompting behaviour). - connect_to_device.py and download_to_device.py call the helper as the FIRST action inside their try blocks, before ensure_project_open and any login() attempt, so credentials are registered before any code path that could trigger the dialog. - server.ts adds optional deviceUser / devicePassword args to both tools' input schemas. Resolution order: args.deviceUser (per-call override) -> process.env.CODESYS_DEVICE_USER -> '' (empty, dialog pops as before) Same for devicePassword. Env-var path is the recommended config: claude mcp add -s user codesys-sp22-patch1 \ -e CODESYS_DEVICE_USER=Karstein \ -e CODESYS_DEVICE_PASSWORD=codesys123 \ -- codesys-mcp-sp21-plus --codesys-path ... --codesys-profile ... \ --mode persistent --no-auto-launch Backward compat: when both creds are empty (default for existing users), the helper short-circuits and behaviour is byte-identical to 0.5.x. No regression. Verified by smoke-testing prepareScriptWithHelpers locally with both filled and empty inputs -- function definition + callsite are both wired in either case; only set_default_credentials() is suppressed when empty. README updated for connect_to_device and download_to_device tool rows. Version bumped 0.5.0 -> 0.6.0.
69 lines
2.5 KiB
JavaScript
69 lines
2.5 KiB
JavaScript
// One-shot bridge: sends a command to an existing watcher's IPC dir,
|
|
// matching the file format from src/ipc.ts so the watcher (which has no
|
|
// idea this MCP server even exists) picks up and executes it.
|
|
// Used to verify a script change without restarting CODESYS / VSCode.
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as crypto from 'node:crypto';
|
|
|
|
const IPC_DIR = process.argv[2];
|
|
const PROJECT_FILE_PATH = process.argv[3];
|
|
const SCRIPT_NAME = process.argv[4] || 'list_project_libraries';
|
|
const HELPERS = (process.argv[5] || 'ensure_project_open').split(',').filter(Boolean);
|
|
const TIMEOUT_MS = Number(process.argv[6] || '120000');
|
|
|
|
if (!IPC_DIR || !PROJECT_FILE_PATH) {
|
|
console.error('usage: node inject-once.mjs <IPC_DIR> <PROJECT_FILE_PATH> [scriptName] [helpers,csv] [timeoutMs]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const SCRIPTS_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname).replace(/^\//, ''), 'dist/scripts');
|
|
|
|
const helperBodies = HELPERS.map((h) =>
|
|
fs.readFileSync(path.join(SCRIPTS_DIR, `${h}.py`), 'utf-8')
|
|
);
|
|
const mainBody = fs.readFileSync(path.join(SCRIPTS_DIR, `${SCRIPT_NAME}.py`), 'utf-8');
|
|
const combined = [...helperBodies, mainBody].join('\n\n');
|
|
const interpolated = combined.replace(/\{PROJECT_FILE_PATH\}/g, PROJECT_FILE_PATH);
|
|
|
|
const reqId = crypto.randomUUID();
|
|
const cmdsDir = path.join(IPC_DIR, 'commands');
|
|
const resDir = path.join(IPC_DIR, 'results');
|
|
const scriptPath = path.join(cmdsDir, `${reqId}.py`);
|
|
const cmdPath = path.join(cmdsDir, `${reqId}.command.json`);
|
|
const resPath = path.join(resDir, `${reqId}.result.json`);
|
|
|
|
function atomicWrite(p, content) {
|
|
const tmp = p + '.tmp';
|
|
fs.writeFileSync(tmp, content, 'utf-8');
|
|
fs.renameSync(tmp, p);
|
|
}
|
|
|
|
console.error(`[inject] reqId=${reqId} ipcDir=${IPC_DIR}`);
|
|
atomicWrite(scriptPath, interpolated);
|
|
atomicWrite(cmdPath, JSON.stringify({
|
|
requestId: reqId,
|
|
scriptPath: scriptPath,
|
|
timestamp: Date.now(),
|
|
}));
|
|
|
|
const start = Date.now();
|
|
let pollMs = 100;
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
while (Date.now() - start < TIMEOUT_MS) {
|
|
if (fs.existsSync(resPath)) {
|
|
try {
|
|
const result = JSON.parse(fs.readFileSync(resPath, 'utf-8'));
|
|
console.log(JSON.stringify(result, null, 2));
|
|
try { fs.unlinkSync(resPath); } catch { /* ignore */ }
|
|
process.exit(result.success ? 0 : 1);
|
|
} catch {
|
|
// partial write, retry next tick
|
|
}
|
|
}
|
|
await sleep(pollMs);
|
|
pollMs = Math.min(pollMs * 2, 1000);
|
|
}
|
|
console.error(`[inject] timeout after ${TIMEOUT_MS}ms`);
|
|
process.exit(3);
|