0
0
Fork 0
Codesys-MCP-SP21-plus/inject-once.mjs
Karstein Phobic Nyvold Kvistad f34d002601 feat(online): pre-register Device User credentials to suppress modal login dialog
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.
2026-04-28 21:41:31 +02:00

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);