0
0
Fork 0
Codesys-MCP-SP21-plus/src/server.ts
Karstein Phobic Nyvold Kvistad 37fc80764e feat(ssh): read_running_version_ssh -- read PLC project version over SSH, no CODESYS needed
New MCP tool + --ssh-version CLI flag. Bypasses the CODESYS IDE
entirely: SSH to a CODESYS Control Linux PLC, sudo strings the boot
application binary, extract the X.Y.Z.W literal of
_MCP_PROJECT_VERSION.sVersion. Filters out 3.5.x.y CODESYS runtime
versions automatically.

Solves the case where the .project file is locked by another CODESYS
instance, or no CODESYS install is reachable, but the PLC is. Read-
only on the PLC (just strings the boot binary).

Requires SSH key auth + passwordless sudo for /usr/bin/strings on
the PLC. Both error paths surface exact-instructions error messages
(PowerShell key install command, sudoers line) instead of opaque
failures.

Smoke-tested against codesys-pi (RPi running CODESYS Control 3.5.22)
with MCPTest2 v1.5.0.0 downloaded -- correctly extracts 1.5.0.0 and
filters out the 3.5.22.0 runtime version literal.
2026-04-27 22:13:29 +02:00

2524 lines
109 KiB
TypeScript

/**
* MCP Server — registers tools and resources for CODESYS automation.
* Supports persistent (watcher-based) and headless (spawn-per-command) modes.
*/
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { ServerConfig, IpcResult, ScriptExecutor, ExecutionMode } from './types';
import { CodesysLauncher } from './launcher';
import { HeadlessExecutor } from './headless';
import { ScriptManager } from './script-manager';
import { serverLog, setLogLevel } from './logger';
import { readRunningVersionSsh, formatSshVersionResult } from './ssh-version';
/**
* Classifier for `bump_project_version --level=auto`.
*
* Diffs the project's mcp-mirror/ folder against the latest v* git tag in
* the project's parent directory (assumed to be a git repo) and decides
* which version part to bump:
*
* D (delete) or R (rename) -> major (public symbol gone or renamed)
* A (add) -> minor (new public symbol)
* M (modify) -> revision (internal change)
* no changes / no v* tag -> build (also triggers the seed-at-1.0.0.0
* first-run path on the Python side
* when version is unset)
*
* v1 keeps the heuristic at file granularity. A future iteration could
* split each modified .st file into its decl block (before
* `(* === IMPLEMENTATION === *)`) and impl block (after) to distinguish
* decl-changed minor (signature add/change) from impl-only revision.
*/
type ClassifyResult =
| { kind: 'bump'; level: 'major' | 'minor' | 'revision' | 'build'; evidence: string[] }
| { kind: 'no-changes'; evidence: string[] }
| { kind: 'first-run'; evidence: string[] };
/**
* Appends a new entry to <projectDir>/Changelog.md describing the version
* bump. Newest entries at the top, under a one-time intro header.
*
* Soft-fail: any I/O error logs to stderr but does not fail the bump --
* the bump itself has already succeeded by the time this runs, and the
* Changelog is documentation, not state-of-truth.
*/
function appendChangelogEntry(
projectDir: string,
fromVersion: string | null,
toVersion: string,
levelLabel: string,
evidence: string[]
): void {
try {
const changelogPath = path.join(projectDir, 'Changelog.md');
// YYYY-MM-DD HH:MM in local time -- readable for humans glancing at the
// file, sort-friendly, no timezone-conversion friction. Avoid seconds
// and timezone suffix to keep the heading compact.
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
const bullets =
evidence.length > 0
? evidence.map((e) => `- ${e}`).join('\n')
: '- (no classification evidence -- manual bump)';
const fromTo =
fromVersion && fromVersion !== toVersion ? ` (from \`${fromVersion}\`)` : '';
const newEntry =
`## v${toVersion} -- ${stamp} (${levelLabel})${fromTo}\n\n${bullets}\n`;
const intro =
`# Changelog\n\n` +
`Auto-generated by \`bump_project_version\`. Newest entries at the top. ` +
`Versions match the value written to \`Project Information.Version\` and ` +
`the runtime anchor \`_MCP_PROJECT_VERSION.sVersion\` -- so an entry here ` +
`corresponds 1:1 to a value the running PLC will report back via ` +
`\`read_running_version_online\`.\n\n`;
let existing = '';
try {
existing = fs.readFileSync(changelogPath, 'utf-8');
} catch {
// file doesn't exist yet
}
let next: string;
if (!existing.trim()) {
next = intro + newEntry;
} else {
const firstHeading = existing.indexOf('\n## v');
if (firstHeading >= 0) {
// insert before the first ## v entry, after the intro
next = existing.substring(0, firstHeading + 1) + newEntry + '\n' + existing.substring(firstHeading + 1);
} else {
// no existing entries, just an intro -- append
next = existing.trimEnd() + '\n\n' + newEntry;
}
}
fs.writeFileSync(changelogPath, next, 'utf-8');
} catch (e) {
serverLog.warn(
`Changelog append failed (bump itself was OK): ${e instanceof Error ? e.message : String(e)}`
);
}
}
function parseBumpedVersion(output: string): { from: string | null; to: string | null } {
// Python script prints one of:
// "Project Information.Version: <before> -> <after>"
// "Project Information.Version: (skipped -- node missing) -> <after>"
// Use a non-greedy capture so the "skipped" parenthetical isn't mis-parsed
// as the from-version. Also fall back to the runtime anchor line on the
// off chance the metadata line ever changes shape again.
const m = /Project Information\.Version:\s*(.+?)\s*->\s*(\S+)/.exec(output);
if (m) {
const fromRaw = m[1].trim();
const isSkipped = fromRaw.startsWith('(') || fromRaw.toLowerCase() === 'none';
return { from: isSkipped ? null : fromRaw, to: m[2] };
}
// Last-ditch: take the runtime anchor's value -- always reflects the post-bump version.
const a = /Runtime anchor:\s*_MCP_PROJECT_VERSION\.sVersion\s*:=\s*'([^']+)'/.exec(output);
if (a) return { from: null, to: a[1] };
return { from: null, to: null };
}
/**
* Compute SHA-256 of a single file's contents. Returns the lowercase hex
* digest. Used to detect "did the .project binary change?" between releases
* -- catches changes that don't show up in the textual mirror_export output
* (device tree, library refs, task config, visualizations, etc.).
*/
function sha256OfFile(filePath: string): string {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
/**
* Compute a deterministic SHA-256 over a directory tree. Walks all regular
* files in sorted order, hashing each (relative-path, content) pair so the
* output is stable across machines and depends only on the tree's logical
* content.
*
* Used to detect "did the user edit the mcp-mirror tree directly?" between
* release calls. The mirror is normally a one-way export from the .project
* binary, but a curious user can edit a .st file with a text editor; we
* want to surface that case rather than silently overwriting their work
* on the next mirror_export.
*
* Returns empty string if the directory doesn't exist.
*/
function sha256OfDirectory(dirPath: string): string {
if (!fs.existsSync(dirPath)) return '';
const hash = crypto.createHash('sha256');
const baseLen = dirPath.length + 1;
function walk(dir: string): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full);
} else if (e.isFile()) {
const rel = full.slice(baseLen).replace(/\\/g, '/');
hash.update(rel);
hash.update('\0');
try {
hash.update(fs.readFileSync(full));
} catch {
// skip unreadable files; their absence still alters the hash
// because subsequent entries continue to feed it
}
hash.update('\0');
}
}
}
walk(dirPath);
return hash.digest('hex');
}
/**
* Read the SHA-256 fingerprints stored in an annotated git tag's body.
* release_project_version writes both `project-sha256:` and `mirror-sha256:`
* lines into each release tag, so the next release can compare against
* them and detect even non-textual changes.
*
* Returns undefined for either field if the tag body doesn't carry it
* (older tags, lightweight tags, missing-tag failure).
*/
function readTagShas(projectDir: string, tagName: string): { project?: string; mirror?: string } {
if (!tagName) return {};
let body = '';
try {
body = execSync(`git -C "${projectDir}" cat-file -p ${tagName}`, {
encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return {};
}
// Some early SHA-tracking tags (v1.3.2.0 onward) were written via
// `git tag -m` with JSON.stringify(body) which escaped newlines as
// literal "\n" two-char sequences instead of real LF bytes -- the
// regex below requires real line starts (multiline mode), so handle
// both forms by normalising the literal sequence to a real newline
// before matching. Future tags written via `git tag -F <tempfile>`
// preserve real newlines and need no normalisation; this fallback
// is just for backward compatibility with the early tags.
const normalized = body.replace(/\\n/g, '\n');
const projMatch = normalized.match(/^project-sha256:\s*([0-9a-f]{64})\s*$/m);
const mirMatch = normalized.match(/^mirror-sha256:\s*([0-9a-f]{64})\s*$/m);
return {
project: projMatch ? projMatch[1] : undefined,
mirror: mirMatch ? mirMatch[1] : undefined,
};
}
/**
* GitLab-Flavored Markdown anchor generator: lowercase, spaces -> hyphens,
* drop everything not alphanumeric/underscore/hyphen. Matches GitLab's
* lib/banzai/filter/table_of_contents_filter.rb so TOC links jump to the
* right headings on the GitLab UI.
*/
function gfmSlug(s: string): string {
return s.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9_-]/g, '');
}
interface LibRefData {
id?: string; name?: string; namespace?: string;
is_placeholder?: boolean; is_managed?: boolean; system_library?: boolean;
qualified_only?: boolean; optional?: boolean; placeholder_name?: string;
effective_resolution?: string; default_resolution?: string;
is_redirected?: boolean; resolution_info?: string;
}
interface DeviceData {
path: string; name?: string;
device_id_type?: string; device_id_id?: string; device_id_version?: string;
}
interface ContainerData {
container_name: string; libman_name: string; references: LibRefData[];
}
interface LibrariesData {
project?: string;
project_info?: { version?: string | null; title?: string | null; company?: string | null; author?: string | null };
ide_version?: string; compiler_version?: string | null; devices?: DeviceData[]; containers: ContainerData[]; total_references: number;
}
interface PouEntry { path: string; type?: string; declaration?: string; implementation?: string; }
function renderLibraryMd(libs: LibrariesData, runtimeAnchorVersion?: string): string {
const L: string[] = [];
L.push('# Library inventory -- ' + (libs.project ?? '?'));
L.push('');
L.push('Auto-generated by `list_project_libraries` from the [`phobicdotno/Codesys-MCP-SP21-plus`](https://github.com/phobicdotno/Codesys-MCP-SP21-plus) fork.');
L.push('');
L.push('## Versions');
L.push('');
L.push('| Field | Value |');
L.push('|---|---|');
const pi = libs.project_info ?? {};
if (pi.version) L.push(`| Project Information.Version | \`${pi.version}\` |`);
if (pi.title) L.push(`| Project Information.Title | ${pi.title} |`);
if (pi.company) L.push(`| Project Information.Company | ${pi.company} |`);
if (pi.author) L.push(`| Project Information.Author | ${pi.author} |`);
if (libs.ide_version) L.push(`| CODESYS Development System | \`${libs.ide_version.replace(/\s+/g, ' ').trim()}\` |`);
if (libs.compiler_version) L.push(`| Project compiler version | \`${libs.compiler_version}\` |`);
if (runtimeAnchorVersion) L.push(`| Runtime anchor | \`_MCP_PROJECT_VERSION.sVersion := "${runtimeAnchorVersion}"\` |`);
L.push('');
if (libs.devices && libs.devices.length > 0) {
L.push('## Devices');
L.push('');
L.push('| Path | Type | ID | Version |');
L.push('|---|---|---|---|');
for (const d of libs.devices) {
L.push(`| \`${d.path}\` | \`${d.device_id_type ?? ''}\` | \`${d.device_id_id ?? ''}\` | **\`${d.device_id_version ?? ''}\`** |`);
}
L.push('');
}
L.push(`**Total:** ${libs.total_references} library references across ${libs.containers.length} library managers.`);
L.push('');
for (const c of libs.containers) {
L.push(`## Container: \`${c.container_name}\` (libman: ${c.libman_name}) -- ${c.references.length} refs`);
L.push('');
L.push('| name | namespace | kind | sys | effective |');
L.push('|---|---|---|---|---|');
for (const r of c.references) {
const kindParts = [r.is_managed && 'managed', r.is_placeholder && 'placeholder', r.is_redirected && 'redir', r.optional && 'opt'].filter(Boolean) as string[];
const kind = kindParts.join('+') || '-';
const eff = r.effective_resolution ?? r.default_resolution ?? '';
const esc = (s: string | undefined) => (s ?? '').replace(/\|/g, '\\|');
L.push(`| \`${esc(r.name)}\` | \`${esc(r.namespace)}\` | ${kind} | ${r.system_library ? 'yes' : 'no'} | ${esc(eff)} |`);
}
L.push('');
}
return L.join('\n');
}
function renderPouDumpMd(pou: PouEntry[], projectName: string): string {
pou.sort((a, b) => a.path.localeCompare(b.path));
const slugs: string[] = [];
const seen = new Map<string, number>();
for (const e of pou) {
const base = gfmSlug(e.path);
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
slugs.push(n === 0 ? base : `${base}-${n}`);
}
const today = new Date().toISOString().slice(0, 10);
const L: string[] = [];
L.push(`# POU dump -- ${projectName}`);
L.push('');
L.push(`Generated ${today} from the live CODESYS instance via the [\`phobicdotno/Codesys-MCP-SP21-plus\`](https://github.com/phobicdotno/Codesys-MCP-SP21-plus) fork.`);
L.push('');
L.push(`**Total:** ${pou.length} objects with textual code.`);
L.push('');
L.push('## Index');
L.push('');
for (let i = 0; i < pou.length; i++) L.push(`- [${pou[i].path}](#${slugs[i]})`);
L.push(''); L.push('---'); L.push('');
for (const e of pou) {
L.push(`## ${e.path}`);
L.push('');
if (e.declaration) { L.push('### Declaration'); L.push('```iecst'); L.push(e.declaration.replace(/\r\n/g, '\n').trimEnd()); L.push('```'); L.push(''); }
if (e.implementation) { L.push('### Implementation'); L.push('```iecst'); L.push(e.implementation.replace(/\r\n/g, '\n').trimEnd()); L.push('```'); L.push(''); }
L.push('');
}
return L.join('\n');
}
function classifyMcpMirrorChanges(projectDir: string): ClassifyResult {
const evidence: string[] = [];
const isGit = (() => {
try {
return (
execSync(`git -C "${projectDir}" rev-parse --is-inside-work-tree`, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim() === 'true'
);
} catch {
return false;
}
})();
if (!isGit) {
evidence.push(`'${projectDir}' is not a git repo -- can't classify, treating as first-run`);
return { kind: 'first-run', evidence };
}
let baseRef = '';
try {
baseRef = execSync(
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim();
} catch {
evidence.push('no v* tag found -- first-run');
return { kind: 'first-run', evidence };
}
evidence.push(`baseline: tag ${baseRef}`);
let raw = '';
try {
// --ignore-cr-at-eol: ignore CRLF<->LF normalisation noise. CODESYS
// lives on Windows, the share lives on Linux/Samba, and git
// autocrlf settings can flip line endings on every checkout. Without
// this flag the classifier reported every .st file as M after a fresh
// checkout even though the content was identical, triggering a
// phantom release on X33 (commit 6c23e38, reverted in 3e6f12f).
// -w: also ignore whitespace-only changes (defensive; phantom releases
// shouldn't fire on a stray blank line either).
raw = execSync(
`git -C "${projectDir}" diff --name-status --ignore-cr-at-eol -w -M50% ${baseRef} -- mcp-mirror/`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
);
} catch {
evidence.push(`git diff against ${baseRef} failed -- treating as no-changes`);
return { kind: 'no-changes', evidence };
}
// git diff only reports TRACKED changes. New files that mirror_export just
// wrote are untracked from git's POV until added, and would otherwise be
// invisible to the classifier (they wouldn't trigger a 'minor' bump even
// though they're new public symbols). Pull them in via ls-files --others.
// Surfaced on MCPTest2 today: adding FB_Position + FB_Random5s via
// create_pou caused the classifier to see only 1 modified file (PLC_PRG)
// and classify as 'revision' instead of 'minor'. The added FBs were
// untracked at classify time.
let untracked = '';
try {
untracked = execSync(
`git -C "${projectDir}" ls-files --others --exclude-standard -- mcp-mirror/`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
);
} catch {
// ls-files failure shouldn't block classification on tracked diff alone
}
if (!raw.trim() && !untracked.trim()) {
evidence.push('no changes in mcp-mirror/ since baseline');
return { kind: 'no-changes', evidence };
}
let hasDelete = false;
let hasRename = false;
let hasAdd = false;
let hasModify = false;
for (const line of raw.split('\n').filter((l) => l.trim())) {
const status = line[0];
const tab = line.indexOf('\t');
const rest = tab >= 0 ? line.substring(tab + 1) : '';
if (status === 'D') {
hasDelete = true;
evidence.push(`deleted: ${rest}`);
} else if (status === 'R') {
hasRename = true;
evidence.push(`renamed: ${rest}`);
} else if (status === 'A') {
hasAdd = true;
evidence.push(`added: ${rest}`);
} else if (status === 'M') {
hasModify = true;
evidence.push(`modified: ${rest}`);
}
}
for (const line of untracked.split('\n').filter((l) => l.trim())) {
hasAdd = true;
evidence.push(`added (untracked): ${line}`);
}
if (hasDelete || hasRename) return { kind: 'bump', level: 'major', evidence };
if (hasAdd) return { kind: 'bump', level: 'minor', evidence };
if (hasModify) return { kind: 'bump', level: 'revision', evidence };
return { kind: 'no-changes', evidence };
}
/**
* IEC 61131-3 identifiers that are reserved for time-literal suffixes or
* standard-block I/O conventions. Using these as variable names produces
* red-underlined warnings or compile errors in CODESYS.
*
* s/t/d/m/h/ms/us/ns -> time-literal suffixes (T#5s, T#100ms, etc.)
* S/R -> SR/RS flip-flop input names
*
* The set is lowercased separately from the original casing -- we check
* exact-match (case-sensitive) so we catch both 's' and 'S' separately.
*/
const RESERVED_IEC_IDENTIFIERS = new Set([
's', 't', 'd', 'm', 'h', 'ms', 'us', 'ns',
'S', 'R',
]);
/**
* Scan an IEC declarationCode block for VAR declarations whose variable
* name collides with a reserved identifier. Returns one warning string
* per offending name. Empty list if the input is empty/safe.
*
* Pattern matches lines of the form `<name> : <type>` and is line-anchored
* so it ignores struct member access (`fb.s`) and similar non-declarations.
* Catches the first name in each line; multi-name lists like
* `s, t : BOOL;` only catch the last comma-separated name (rare but
* worth a future tightening).
*/
function findReservedIecIdentifiers(declarationCode: string | undefined): string[] {
if (!declarationCode) return [];
const warnings: string[] = [];
const seen = new Set<string>();
const pattern = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Za-z_]/gm;
let match: RegExpExecArray | null;
while ((match = pattern.exec(declarationCode)) !== null) {
const name = match[1];
if (RESERVED_IEC_IDENTIFIERS.has(name) && !seen.has(name)) {
seen.add(name);
warnings.push(
`Reserved IEC identifier '${name}' used as variable name. ` +
`Single-letter names like s/t/d/m/h/ms/us/ns are time-literal suffixes (T#5s, T#100ms); ` +
`S/R conflict with SR/RS flip-flop semantics. ` +
`Rename to a meaningful identifier (e.g. '${name}Inst', '${name}Sample', or use a Hungarian-style prefix like 'st'/'fb'/'b'/'n').`
);
}
}
return warnings;
}
// Zod enums for POU tools
const PouTypeEnum = z.enum(['Program', 'FunctionBlock', 'Function']);
const ImplementationLanguageEnum = z.enum([
'ST', 'LD', 'FBD', 'SFC', 'IL', 'CFC',
'StructuredText', 'LadderDiagram', 'FunctionBlockDiagram',
'SequentialFunctionChart', 'InstructionList', 'ContinuousFunctionChart',
]);
/** Resolve a file path to an absolute normalized path */
function resolvePath(filePath: string, workspaceDir: string): string {
return path.normalize(
path.isAbsolute(filePath) ? filePath : path.join(workspaceDir, filePath)
);
}
/** Sanitize a POU path (forward slashes, no leading/trailing slashes) */
function sanitizePouPath(pouPath: string): string {
return pouPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
/** Format an IpcResult into an MCP tool response */
function formatToolResponse(
result: IpcResult,
successMessage: string
): { content: Array<{ type: 'text'; text: string }>; isError: boolean } {
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
return {
content: [
{
type: 'text' as const,
text: success
? successMessage
: `Operation failed. Output:\n${result.output}${result.error ? '\nError: ' + result.error : ''}`,
},
],
isError: !success,
};
}
/**
* Auto-mirror context threaded through tools that modify the project.
* When --auto-mirror is on, every successful edit triggers a follow-up
* mirror_export so an external editor watching <projectDir>/mcp-mirror/
* sees the change immediately. Best-effort VSCode integration also opens
* the mirror dir in the user's active VSCode window once per project.
*/
interface MirrorCtx {
autoMirror: boolean;
scriptManager: ScriptManager;
executor: ScriptExecutor;
workspaceDir: string;
/** Mirror dirs already added to VSCode this session (per absolute path). */
openedInVscode: Set<string>;
/** Absolute path to the VSCode `code` CLI shim, or null if not found. */
vscodeCli: string | null;
}
/**
* Locate the VSCode CLI shim on Windows. The shim (code.cmd) is what you
* want to call from a script -- the bare code.exe is the GUI binary and
* doesn't behave the same way for --add / --reuse-window flags.
*
* Returns absolute path to the .cmd shim, or null if not found.
* Best-effort, no error -- the auto-mirror feature still works without
* VSCode integration; the user just doesn't get the auto-pop into the
* Source Control panel.
*/
function findVscodeCli(): string | null {
if (process.platform !== 'win32') return null;
const candidates = [
path.join(process.env.PROGRAMFILES ?? 'C:\\Program Files', 'Microsoft VS Code', 'bin', 'code.cmd'),
path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'),
path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft VS Code', 'bin', 'code.cmd'),
];
for (const c of candidates) {
if (c && fs.existsSync(c)) return c;
}
return null;
}
/**
* Best-effort: add <projectDir>/mcp-mirror to the user's active VSCode
* window so they see the source-control diff appear after each edit.
* No-op if VSCode CLI wasn't found, if we've already opened this dir
* this session, or if the spawn itself fails. Never blocks the tool
* response: spawned detached + unref'd.
*/
function maybeOpenMirrorInVscode(projectFilePath: string, ctx: MirrorCtx): void {
if (!ctx.vscodeCli) return;
const mirrorDir = path.join(path.dirname(projectFilePath), 'mcp-mirror');
const key = mirrorDir.toLowerCase();
if (ctx.openedInVscode.has(key)) return;
if (!fs.existsSync(mirrorDir)) return; // mirror_export hasn't created it yet on this call
ctx.openedInVscode.add(key);
try {
// --add appends the folder to the last active window's workspace, which
// makes it show up in Explorer + Source Control without opening a new
// window. Detached + unref so the launcher doesn't hold a handle to
// VSCode after the spawn returns.
const child = require('child_process').spawn(
ctx.vscodeCli,
['--add', mirrorDir],
{ detached: true, stdio: 'ignore', windowsHide: true }
);
child.unref();
} catch {
// Swallow -- VSCode integration is a UX enhancement, never a blocker.
}
}
async function maybeAutoMirror(
projectFilePath: string,
editResult: IpcResult,
ctx: MirrorCtx
): Promise<string> {
if (!ctx.autoMirror) return '';
// Don't mirror after a failed edit -- nothing to refresh, and the error
// will already dominate the response.
const editSucceeded = editResult.success && editResult.output.includes('SCRIPT_SUCCESS');
if (!editSucceeded) return '';
try {
const script = ctx.scriptManager.prepareScriptWithHelpers(
'mirror_export',
{ PROJECT_FILE_PATH: projectFilePath, MIRROR_ROOT: '' },
['ensure_project_open']
);
const mirrorResult = await ctx.executor.executeScript(script);
const mirrorOk = mirrorResult.success && mirrorResult.output.includes('SCRIPT_SUCCESS');
if (mirrorOk) {
maybeOpenMirrorInVscode(projectFilePath, ctx);
return '\n(auto-mirror: refreshed)';
}
return `\n(auto-mirror: FAILED -- ${mirrorResult.error ?? 'see CODESYS log'})`;
} catch (err) {
return `\n(auto-mirror: FAILED -- ${(err as Error).message})`;
}
}
/**
* Like formatToolResponse but additionally runs maybeAutoMirror so the
* response carries a one-line auto-mirror status when --auto-mirror is on.
* Use for tools that modify the .project file. Tools that only read should
* keep using formatToolResponse directly.
*/
async function formatModifyingResponse(
result: IpcResult,
successMessage: string,
projectFilePath: string,
mirrorCtx: MirrorCtx
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError: boolean }> {
const mirrorNote = await maybeAutoMirror(projectFilePath, result, mirrorCtx);
return formatToolResponse(result, successMessage + mirrorNote);
}
/** Check if a file exists (async) */
async function fileExists(filePath: string): Promise<boolean> {
try {
fs.statSync(filePath);
return true;
} catch {
return false;
}
}
export async function startMcpServer(config: ServerConfig): Promise<void> {
// Set log level
if (config.debug) setLogLevel('debug');
else if (config.verbose) setLogLevel('info');
serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`);
serverLog.info(`Mode: ${config.mode}`);
serverLog.info(`CODESYS Path: ${config.codesysPath}`);
serverLog.info(`Profile: ${config.profileName}`);
serverLog.info(`Workspace: ${config.workspaceDir}`);
// Validate CODESYS path
if (!fs.existsSync(config.codesysPath)) {
throw new Error(`CODESYS executable not found: ${config.codesysPath}`);
}
// Initialize executor based on mode
let executor: ScriptExecutor;
let launcher: CodesysLauncher | null = null;
let executionMode: ExecutionMode = config.mode;
if (config.mode === 'persistent') {
launcher = new CodesysLauncher(config);
if (config.autoLaunch) {
try {
await launcher.launch();
executor = launcher;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
serverLog.error(`Persistent launch failed: ${errMsg}`);
if (config.fallbackHeadless) {
serverLog.warn('Falling back to headless mode');
executor = new HeadlessExecutor(config);
executionMode = 'headless';
} else {
throw err;
}
}
} else {
// Launcher exists but not yet launched — will use headless until manually launched
executor = new HeadlessExecutor(config);
executionMode = 'headless';
}
} else {
executor = new HeadlessExecutor(config);
}
const scriptManager = new ScriptManager();
// Auto-mirror context shared by every modifying tool. When --auto-mirror
// is enabled, formatModifyingResponse triggers a follow-up mirror_export
// after each successful edit, and (best-effort) opens the resulting
// <projectDir>/mcp-mirror/ folder in VSCode so the user sees the diff
// in the Source Control panel immediately. The set tracks which mirror
// dirs we've already opened in VSCode this session so we don't spam.
const mirrorCtx: MirrorCtx = {
autoMirror: config.autoMirror,
scriptManager,
executor,
workspaceDir: config.workspaceDir,
openedInVscode: new Set<string>(),
vscodeCli: findVscodeCli(),
};
const workspaceDir = config.workspaceDir;
// Create MCP server
const server = new McpServer(
{
name: 'CODESYS Persistent MCP Server',
version: '0.1.0',
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
},
}
);
// Note: using 'as any' cast on server for tool() calls to work around
// TS2589 deep type instantiation with MCP SDK generics + Zod.
const s = server as any;
// ─── Management Tools ────────────────────────────────────────────────
s.tool(
'launch_codesys',
'Manually launch CODESYS with UI. Use when --no-auto-launch was set.',
async () => {
if (!launcher) {
return {
content: [{ type: 'text' as const, text: 'Persistent mode not configured. Use --mode persistent.' }],
isError: true,
};
}
try {
await launcher.launch();
executor = launcher;
executionMode = 'persistent';
return {
content: [{ type: 'text' as const, text: 'CODESYS launched successfully in persistent mode.' }],
isError: false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text' as const, text: `Launch failed: ${msg}` }],
isError: true,
};
}
}
);
s.tool(
'shutdown_codesys',
'Shut down the persistent CODESYS instance.',
async () => {
if (!launcher) {
return {
content: [{ type: 'text' as const, text: 'No persistent CODESYS instance to shut down.' }],
isError: true,
};
}
try {
await launcher.shutdown();
executor = new HeadlessExecutor(config);
executionMode = 'headless';
return {
content: [{ type: 'text' as const, text: 'CODESYS shut down successfully.' }],
isError: false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text' as const, text: `Shutdown failed: ${msg}` }],
isError: true,
};
}
}
);
s.tool(
'get_codesys_status',
'Get the current status of the CODESYS instance (state, PID, mode).',
async () => {
const status = launcher ? launcher.getStatus() : {
state: 'stopped',
pid: null,
sessionId: null,
ipcDir: null,
startedAt: null,
lastError: null,
};
const text = [
`State: ${status.state}`,
`Mode: ${executionMode}`,
`PID: ${status.pid ?? 'N/A'}`,
`Session: ${status.sessionId ?? 'N/A'}`,
`Started: ${status.startedAt ? new Date(status.startedAt).toISOString() : 'N/A'}`,
status.lastError ? `Last Error: ${status.lastError}` : null,
].filter(Boolean).join('\n');
return {
content: [{ type: 'text' as const, text }],
isError: false,
};
}
);
// ─── Project Tools ───────────────────────────────────────────────────
s.tool(
'open_project',
'Opens an existing CODESYS project file.',
{
filePath: z.string().describe("Path to the project file (e.g., 'C:/Projects/MyPLC.project')."),
},
async (args: { filePath: string }) => {
const escaped = resolvePath(args.filePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'open_project', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(result, `Project opened: ${args.filePath}`, escaped, mirrorCtx);
}
);
s.tool(
'create_project',
'Creates a new CODESYS project from the standard template.',
{
filePath: z.string().describe("Path where the new project file should be created."),
},
async (args: { filePath: string }) => {
const absPath = path.normalize(
path.isAbsolute(args.filePath) ? args.filePath : path.join(workspaceDir, args.filePath)
);
// Find template project
let templatePath = '';
try {
const baseDir = path.dirname(path.dirname(config.codesysPath));
templatePath = path.normalize(path.join(baseDir, 'Templates', 'Standard.project'));
if (!(await fileExists(templatePath))) {
const programData = process.env.ALLUSERSPROFILE || process.env.ProgramData || 'C:\\ProgramData';
const pd1 = path.normalize(path.join(programData, 'CODESYS', 'CODESYS', config.profileName, 'Templates', 'Standard.project'));
if (await fileExists(pd1)) {
templatePath = pd1;
} else {
const pd2 = path.normalize(path.join(programData, 'CODESYS', 'Templates', 'Standard.project'));
if (await fileExists(pd2)) {
templatePath = pd2;
} else {
throw new Error('Standard template project file not found.');
}
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: [{ type: 'text' as const, text: `Template Error: ${msg}` }],
isError: true,
};
}
const script = scriptManager.prepareScript('create_project', {
PROJECT_FILE_PATH: absPath,
TEMPLATE_PROJECT_PATH: templatePath,
});
const result = await executor.executeScript(script);
return await formatModifyingResponse(result, `Project created from template: ${absPath}`, absPath, mirrorCtx);
}
);
s.tool(
'save_project',
'Saves the currently open CODESYS project.',
{
projectFilePath: z.string().describe("Path to the project file to ensure is open before saving."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'save_project', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(result, `Project saved: ${args.projectFilePath}`, escaped, mirrorCtx);
}
);
// ─── POU Tools ───────────────────────────────────────────────────────
s.tool(
'create_pou',
'Creates a new Program, Function Block, or Function POU within the specified CODESYS project.',
{
projectFilePath: z.string().describe("Path to the project file."),
name: z.string().describe("Name for the new POU (must be a valid IEC identifier)."),
type: z.string().describe("Type of POU: Program, FunctionBlock, or Function."),
language: z.string().describe("Implementation language: ST, LD, FBD, SFC, IL, or CFC."),
parentPath: z.string().describe("Relative path under project root or application (e.g., 'Application')."),
},
async (args: { projectFilePath: string; name: string; type: string; language: string; parentPath: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPath);
const script = scriptManager.prepareScriptWithHelpers(
'create_pou',
{
PROJECT_FILE_PATH: escProjPath,
POU_NAME: args.name.trim(),
POU_TYPE_STR: args.type,
IMPL_LANGUAGE_STR: args.language,
PARENT_PATH: sanParentPath,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`POU '${args.name}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'set_pou_code',
'Sets the declaration and/or implementation code for a specific POU, Method, or Property.',
{
projectFilePath: z.string().describe("Path to the project file."),
pouPath: z.string().describe("Full relative path to the target object (e.g., 'Application/MyPOU')."),
declarationCode: z.string().optional().describe("Code for the declaration part (VAR...END_VAR). If omitted, not changed."),
implementationCode: z.string().optional().describe("Code for the implementation logic. If omitted, not changed."),
},
async (args: { projectFilePath: string; pouPath: string; declarationCode?: string; implementationCode?: string }) => {
if (args.declarationCode === undefined && args.implementationCode === undefined) {
return {
content: [{ type: 'text' as const, text: 'Error: At least one of declarationCode or implementationCode must be provided.' }],
isError: true,
};
}
// Block on IEC reserved identifiers in declarationCode BEFORE
// touching the project. Better to refuse than to half-set then
// surface a soft warning the caller might miss.
const reservedWarnings = findReservedIecIdentifiers(args.declarationCode);
if (reservedWarnings.length > 0) {
return {
content: [{
type: 'text' as const,
text: `Refused: declarationCode contains IEC reserved identifier(s). Project NOT modified. Fix and retry.\n\n - ${reservedWarnings.join('\n - ')}`,
}],
isError: true,
};
}
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanPouPath = sanitizePouPath(args.pouPath);
// Escape for triple-quoted Python strings
const sanDecl = (args.declarationCode ?? '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
const sanImpl = (args.implementationCode ?? '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
// Distinguish "argument provided" from "argument is empty string". An
// omitted declaration must NOT reach decl_obj.replace('') -- doing so
// wipes the POU's PROGRAM/VAR...END_VAR block, leaving an UNKNOWN POU.
const setDecl = args.declarationCode !== undefined ? 'True' : 'False';
const setImpl = args.implementationCode !== undefined ? 'True' : 'False';
const script = scriptManager.prepareScriptWithHelpers(
'set_pou_code',
{
PROJECT_FILE_PATH: escProjPath,
POU_FULL_PATH: sanPouPath,
DECLARATION_CONTENT: sanDecl,
IMPLEMENTATION_CONTENT: sanImpl,
SET_DECLARATION: setDecl,
SET_IMPLEMENTATION: setImpl,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Code set for '${sanPouPath}' in ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'create_property',
'Creates a new Property within a specific Function Block POU.',
{
projectFilePath: z.string().describe("Path to the project file."),
parentPouPath: z.string().describe("Relative path to the parent Function Block POU (e.g., 'Application/MyFB')."),
propertyName: z.string().describe("Name for the new property (must be a valid IEC identifier)."),
propertyType: z.string().describe("Data type of the property (e.g., 'BOOL', 'INT', 'MyDUT')."),
},
async (args: { projectFilePath: string; parentPouPath: string; propertyName: string; propertyType: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPouPath);
const script = scriptManager.prepareScriptWithHelpers(
'create_property',
{
PROJECT_FILE_PATH: escProjPath,
PARENT_POU_FULL_PATH: sanParentPath,
PROPERTY_NAME: args.propertyName.trim(),
PROPERTY_TYPE: args.propertyType.trim(),
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Property '${args.propertyName}' created under '${sanParentPath}' in ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'create_method',
'Creates a new Method within a specific Function Block POU.',
{
projectFilePath: z.string().describe("Path to the project file."),
parentPouPath: z.string().describe("Relative path to the parent Function Block POU (e.g., 'Application/MyFB')."),
methodName: z.string().describe("Name of the new method (must be a valid IEC identifier)."),
returnType: z.string().optional().describe("Return type (e.g., 'BOOL', 'INT'). Leave empty or omit for no return value."),
},
async (args: { projectFilePath: string; parentPouPath: string; methodName: string; returnType?: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPouPath);
const script = scriptManager.prepareScriptWithHelpers(
'create_method',
{
PROJECT_FILE_PATH: escProjPath,
PARENT_POU_FULL_PATH: sanParentPath,
METHOD_NAME: args.methodName.trim(),
RETURN_TYPE: (args.returnType ?? '').trim(),
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Method '${args.methodName}' created under '${sanParentPath}' in ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'compile_project',
'Compiles (Builds) the primary application within a CODESYS project. Returns structured compiler messages (errors, warnings) when available.',
{
projectFilePath: z.string().describe("Path to the project file containing the application to compile."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'compile_project', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script, 120_000); // 120s timeout for compile
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
// Parse structured compile messages if present
let compileMessages: Array<{ severity: string; text: string; object?: string; line?: number }> = [];
const msgStartMarker = '### COMPILE_MESSAGES_START ###';
const msgEndMarker = '### COMPILE_MESSAGES_END ###';
const msgStartIdx = result.output.indexOf(msgStartMarker);
const msgEndIdx = result.output.indexOf(msgEndMarker);
if (msgStartIdx !== -1 && msgEndIdx !== -1 && msgStartIdx < msgEndIdx) {
try {
const jsonStr = result.output.substring(msgStartIdx + msgStartMarker.length, msgEndIdx).trim();
compileMessages = JSON.parse(jsonStr);
} catch {
// JSON parse failed, ignore
}
}
// Build response message
let message: string;
let isError = !success;
if (!success) {
message = `Failed initiating compilation for ${args.projectFilePath}. Output:\n${result.output}`;
} else if (compileMessages.length > 0) {
const errors = compileMessages.filter((m) => m.severity === 'error');
const warnings = compileMessages.filter((m) => m.severity === 'warning');
const formatMsg = (m: { severity: string; text: string; object?: string; line?: number }) => {
const loc = m.object ? (m.line != null ? ` [${m.object}:${m.line}]` : ` [${m.object}]`) : '';
return `${m.severity.toUpperCase()}: ${m.text}${loc}`;
};
message = `Compilation complete for ${args.projectFilePath}.\n`;
message += `${errors.length} error(s), ${warnings.length} warning(s).\n`;
if (errors.length > 0) {
message += '\nErrors:\n' + errors.map(formatMsg).join('\n');
isError = true;
}
if (warnings.length > 0) {
message += '\nWarnings:\n' + warnings.map(formatMsg).join('\n');
}
} else {
// No structured messages available — fall back to old behavior
message = `Compilation initiated for ${args.projectFilePath}.`;
const hasCompileErrors =
result.output.includes('Compile complete --') &&
!/ 0 error\(s\),/.test(result.output);
if (hasCompileErrors) {
message += ' WARNING: Build command reported errors. Use get_compile_messages for details.';
isError = true;
}
}
return { content: [{ type: 'text' as const, text: message }], isError };
}
);
s.tool(
'get_compile_messages',
'Retrieves the last compiler messages (errors, warnings) without triggering a new build. Useful after editing code to check remaining errors.',
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'get_compile_messages', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
// Parse structured messages
let compileMessages: Array<{ severity: string; text: string; object?: string; line?: number }> = [];
const msgStartMarker = '### COMPILE_MESSAGES_START ###';
const msgEndMarker = '### COMPILE_MESSAGES_END ###';
const msgStartIdx = result.output.indexOf(msgStartMarker);
const msgEndIdx = result.output.indexOf(msgEndMarker);
if (msgStartIdx !== -1 && msgEndIdx !== -1 && msgStartIdx < msgEndIdx) {
try {
const jsonStr = result.output.substring(msgStartIdx + msgStartMarker.length, msgEndIdx).trim();
compileMessages = JSON.parse(jsonStr);
} catch {
// JSON parse failed
}
}
if (compileMessages.length === 0) {
return {
content: [{ type: 'text' as const, text: 'No compile messages found. The message API may not be available in this CODESYS version.' }],
isError: false,
};
}
const errors = compileMessages.filter((m) => m.severity === 'error');
const warnings = compileMessages.filter((m) => m.severity === 'warning');
const formatMsg = (m: { severity: string; text: string; object?: string; line?: number }) => {
const loc = m.object ? (m.line != null ? ` [${m.object}:${m.line}]` : ` [${m.object}]`) : '';
return `${m.severity.toUpperCase()}: ${m.text}${loc}`;
};
let message = `${errors.length} error(s), ${warnings.length} warning(s), ${compileMessages.length} total message(s).\n`;
if (errors.length > 0) {
message += '\nErrors:\n' + errors.map(formatMsg).join('\n');
}
if (warnings.length > 0) {
message += '\nWarnings:\n' + warnings.map(formatMsg).join('\n');
}
const others = compileMessages.filter((m) => m.severity !== 'error' && m.severity !== 'warning');
if (others.length > 0) {
message += '\nOther:\n' + others.map(formatMsg).join('\n');
}
return {
content: [{ type: 'text' as const, text: message }],
isError: errors.length > 0,
};
}
);
// ─── Project Structure Tools ──────────────────────────────────────────
s.tool(
'create_dut',
'Creates a new Data Unit Type (DUT) — structure, enumeration, union, or alias — within the specified CODESYS project.',
{
projectFilePath: z.string().describe("Path to the project file."),
name: z.string().describe("Name for the new DUT (must be a valid IEC identifier)."),
dutType: z.string().describe("Type of DUT: Structure, Enumeration, Union, or Alias."),
parentPath: z.string().describe("Relative path under project root or application (e.g., 'Application')."),
},
async (args: { projectFilePath: string; name: string; dutType: string; parentPath: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPath);
const script = scriptManager.prepareScriptWithHelpers(
'create_dut',
{
PROJECT_FILE_PATH: escProjPath,
DUT_NAME: args.name.trim(),
DUT_TYPE_STR: args.dutType,
PARENT_PATH: sanParentPath,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`DUT '${args.name}' (${args.dutType}) created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'create_gvl',
'Creates a new Global Variable List (GVL) within the specified CODESYS project.',
{
projectFilePath: z.string().describe("Path to the project file."),
name: z.string().describe("Name for the new GVL (must be a valid IEC identifier)."),
parentPath: z.string().describe("Relative path under project root or application (e.g., 'Application')."),
declarationCode: z.string().optional().describe("Optional initial declaration code for the GVL (VAR_GLOBAL...END_VAR)."),
},
async (args: { projectFilePath: string; name: string; parentPath: string; declarationCode?: string }) => {
// Block on IEC reserved identifiers in declarationCode BEFORE
// creating the GVL. Refuse rather than create a broken GVL.
const reservedWarnings = findReservedIecIdentifiers(args.declarationCode);
if (reservedWarnings.length > 0) {
return {
content: [{
type: 'text' as const,
text: `Refused: declarationCode contains IEC reserved identifier(s). GVL NOT created. Fix and retry.\n\n - ${reservedWarnings.join('\n - ')}`,
}],
isError: true,
};
}
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPath);
const sanDecl = (args.declarationCode ?? '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
const script = scriptManager.prepareScriptWithHelpers(
'create_gvl',
{
PROJECT_FILE_PATH: escProjPath,
GVL_NAME: args.name.trim(),
PARENT_PATH: sanParentPath,
DECLARATION_CONTENT: sanDecl,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`GVL '${args.name}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'create_folder',
'Creates an organizational folder within the CODESYS project tree.',
{
projectFilePath: z.string().describe("Path to the project file."),
folderName: z.string().describe("Name for the new folder."),
parentPath: z.string().describe("Relative path under project root or application (e.g., 'Application')."),
},
async (args: { projectFilePath: string; folderName: string; parentPath: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanParentPath = sanitizePouPath(args.parentPath);
const script = scriptManager.prepareScriptWithHelpers(
'create_folder',
{
PROJECT_FILE_PATH: escProjPath,
FOLDER_NAME: args.folderName.trim(),
PARENT_PATH: sanParentPath,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Folder '${args.folderName}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'delete_object',
'Deletes a project object (POU, DUT, GVL, folder, etc.) from the CODESYS project. WARNING: This is destructive and cannot be undone.',
{
projectFilePath: z.string().describe("Path to the project file."),
objectPath: z.string().describe("Full relative path to the object to delete (e.g., 'Application/MyPOU')."),
},
async (args: { projectFilePath: string; objectPath: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanObjPath = sanitizePouPath(args.objectPath);
const script = scriptManager.prepareScriptWithHelpers(
'delete_object',
{
PROJECT_FILE_PATH: escProjPath,
OBJECT_PATH: sanObjPath,
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Object '${sanObjPath}' deleted from ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'rename_object',
'Renames a project object (POU, DUT, GVL, folder, etc.) in the CODESYS project.',
{
projectFilePath: z.string().describe("Path to the project file."),
objectPath: z.string().describe("Full relative path to the object to rename (e.g., 'Application/MyPOU')."),
newName: z.string().describe("New name for the object (must be a valid IEC identifier)."),
},
async (args: { projectFilePath: string; objectPath: string; newName: string }) => {
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
const sanObjPath = sanitizePouPath(args.objectPath);
const script = scriptManager.prepareScriptWithHelpers(
'rename_object',
{
PROJECT_FILE_PATH: escProjPath,
OBJECT_PATH: sanObjPath,
NEW_NAME: args.newName.trim(),
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Object '${sanObjPath}' renamed to '${args.newName}' in ${args.projectFilePath}. Project saved.`,
escProjPath,
mirrorCtx
);
}
);
s.tool(
'get_all_pou_code',
'Reads the declaration and implementation code of every POU/DUT/GVL in the project. Returns all code in a single response for bulk review.',
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'get_all_pou_code', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script, 120_000); // 120s for large projects
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
// Parse the JSON output
const codeStartMarker = '### ALL_POU_CODE_START ###';
const codeEndMarker = '### ALL_POU_CODE_END ###';
const startIdx = result.output.indexOf(codeStartMarker);
const endIdx = result.output.indexOf(codeEndMarker);
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
return {
content: [{ type: 'text' as const, text: 'Could not parse POU code output.' }],
isError: true,
};
}
try {
const jsonStr = result.output.substring(startIdx + codeStartMarker.length, endIdx).trim();
const allCode: Array<{ path: string; type: string; declaration?: string; implementation?: string }> = JSON.parse(jsonStr);
if (allCode.length === 0) {
return {
content: [{ type: 'text' as const, text: 'No POUs with code found in the project.' }],
isError: false,
};
}
// Format output
const sections = allCode.map((item) => {
let section = `\n=== ${item.path} (${item.type}) ===`;
if (item.declaration) {
section += `\n// ----- Declaration -----\n${item.declaration}`;
}
if (item.implementation) {
section += `\n// ----- Implementation -----\n${item.implementation}`;
}
return section;
});
return {
content: [{ type: 'text' as const, text: `${allCode.length} object(s) with code:\n${sections.join('\n')}` }],
isError: false,
};
} catch {
return {
content: [{ type: 'text' as const, text: 'Failed to parse POU code JSON.' }],
isError: true,
};
}
}
);
// ─── Online/Runtime Tools ─────────────────────────────────────────────
s.tool(
'connect_to_device',
'Connects (logs in) to the PLC runtime for the active application. Requires a configured device/gateway in the project. The first connect to a password-protected runtime pops a credential dialog in CODESYS that the user must fill in -- the loginWaitSeconds parameter controls how long the script polls for state stabilisation while that dialog is up.',
{
projectFilePath: z.string().describe("Path to the project file."),
loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for the application state to stabilise after login() returns. Used to give the user time to fill in a credential dialog. Default: 60. Range 0-600."),
},
async (args: { projectFilePath: string; loginWaitSeconds?: number }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const waitSec = args.loginWaitSeconds ?? 60;
const script = scriptManager.prepareScriptWithHelpers(
'connect_to_device',
{
PROJECT_FILE_PATH: escaped,
LOGIN_WAIT_SECONDS: String(waitSec),
},
['ensure_project_open', 'ensure_online_connection']
);
// Tool-side timeout = wait window + 30s headroom for actual login work
const ipcTimeoutMs = (waitSec + 30) * 1000;
const result = await executor.executeScript(script, ipcTimeoutMs);
return formatToolResponse(result, `Connected to device for ${args.projectFilePath}.`);
}
);
s.tool(
'disconnect_from_device',
'Disconnects (logs out) from the PLC runtime.',
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'disconnect_from_device', { PROJECT_FILE_PATH: escaped },
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
return formatToolResponse(result, `Disconnected from device for ${args.projectFilePath}.`);
}
);
s.tool(
'get_application_state',
'Gets the current state of the PLC application (running, stopped, exception, etc.).',
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'get_application_state', { PROJECT_FILE_PATH: escaped },
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
// Parse state from output
const stateMatch = result.output.match(/State:\s*(.+)/);
const loggedInMatch = result.output.match(/Logged In:\s*(.+)/);
const appMatch = result.output.match(/Application:\s*(.+)/);
const text = [
`Application: ${appMatch ? appMatch[1].trim() : 'Unknown'}`,
`State: ${stateMatch ? stateMatch[1].trim() : 'Unknown'}`,
`Logged In: ${loggedInMatch ? loggedInMatch[1].trim() : 'Unknown'}`,
].join('\n');
return {
content: [{ type: 'text' as const, text }],
isError: false,
};
}
);
s.tool(
'read_variable',
'Reads the current value of a variable from the running PLC application. Must be connected first.',
{
projectFilePath: z.string().describe("Path to the project file."),
variablePath: z.string().describe("Variable path (e.g., 'PLC_PRG.bMotorRunning', 'GVL.nCounter')."),
},
async (args: { projectFilePath: string; variablePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'read_variable',
{
PROJECT_FILE_PATH: escaped,
VARIABLE_PATH: args.variablePath.trim(),
},
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
const valueMatch = result.output.match(/Value:\s*(.+)/);
const typeMatch = result.output.match(/Type:\s*(.+)/);
const text = `${args.variablePath} = ${valueMatch ? valueMatch[1].trim() : 'N/A'} (${typeMatch ? typeMatch[1].trim() : 'unknown'})`;
return {
content: [{ type: 'text' as const, text }],
isError: false,
};
}
);
s.tool(
'write_variable',
'Writes a value to a variable in the running PLC application. Must be connected first.',
{
projectFilePath: z.string().describe("Path to the project file."),
variablePath: z.string().describe("Variable path (e.g., 'PLC_PRG.bMotorRunning')."),
value: z.string().describe("Value to write (e.g., 'TRUE', '42', '3.14')."),
},
async (args: { projectFilePath: string; variablePath: string; value: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'write_variable',
{
PROJECT_FILE_PATH: escaped,
VARIABLE_PATH: args.variablePath.trim(),
VARIABLE_VALUE: args.value,
},
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
return formatToolResponse(
result,
`Variable '${args.variablePath}' set to '${args.value}'.`
);
}
);
s.tool(
'download_to_device',
'Downloads the compiled application to the PLC device. Attempts online change first, falls back to full download. Same login-dialog handling as connect_to_device: loginWaitSeconds controls how long the script waits for state stabilisation if a credential dialog pops up.',
{
projectFilePath: z.string().describe("Path to the project file."),
loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for application state to stabilise after login() returns. Default: 60. Range 0-600."),
},
async (args: { projectFilePath: string; loginWaitSeconds?: number }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const waitSec = args.loginWaitSeconds ?? 60;
const script = scriptManager.prepareScriptWithHelpers(
'download_to_device',
{
PROJECT_FILE_PATH: escaped,
LOGIN_WAIT_SECONDS: String(waitSec),
},
['ensure_project_open', 'ensure_online_connection']
);
// Tool-side timeout = wait window + 120s headroom for the actual download
const ipcTimeoutMs = (waitSec + 120) * 1000;
const result = await executor.executeScript(script, ipcTimeoutMs);
return formatToolResponse(result, `Application downloaded to device for ${args.projectFilePath}.`);
}
);
s.tool(
'start_stop_application',
'Starts or stops the PLC application on the connected device.',
{
projectFilePath: z.string().describe("Path to the project file."),
action: z.string().describe("Action to perform: 'start' or 'stop'."),
},
async (args: { projectFilePath: string; action: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'start_stop_application',
{
PROJECT_FILE_PATH: escaped,
APP_ACTION: args.action.trim(),
},
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
return formatToolResponse(
result,
`Application ${args.action} executed for ${args.projectFilePath}.`
);
}
);
// ─── Library Management Tools ─────────────────────────────────────────
s.tool(
'list_project_libraries',
"Lists every library referenced anywhere in the CODESYS project AND captures project-level metadata above the library list: Project Information (version, title, company, author), CODESYS Development System version (from IronPython sys.version), and every device's offline target identification triple (type / id / version) -- the offline 'firmware' the project is built against. The library list itself walks the project tree, finds every ScriptLibManObjectContainer (the project + each Application), gets the Library Manager via container.get_library_manager(), and enumerates lm.references for structured per-reference info (name, namespace, system/placeholder/managed flags, effective resolution). Per the helpme-codesys.com docs and the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi.",
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'list_project_libraries', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
// Parse libraries JSON
const libStartMarker = '### LIBRARIES_START ###';
const libEndMarker = '### LIBRARIES_END ###';
const startIdx = result.output.indexOf(libStartMarker);
const endIdx = result.output.indexOf(libEndMarker);
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
return {
content: [{ type: 'text' as const, text: 'Could not parse libraries output.' }],
isError: true,
};
}
try {
const jsonStr = result.output.substring(startIdx + libStartMarker.length, endIdx).trim();
type LibRef = {
id?: string;
name?: string;
namespace?: string;
is_placeholder?: boolean;
is_managed?: boolean;
system_library?: boolean;
qualified_only?: boolean;
optional?: boolean;
placeholder_name?: string;
effective_resolution?: string;
default_resolution?: string;
is_redirected?: boolean;
resolution_info?: string;
source?: string;
};
type Container = { container_name: string; libman_name: string; references: LibRef[] };
type ProjectInfo = { version?: string | null; title?: string | null; company?: string | null; author?: string | null };
type Device = { path: string; name?: string; device_id_type?: string; device_id_id?: string; device_id_version?: string };
const parsed: {
project?: string;
project_info?: ProjectInfo;
ide_version?: string;
compiler_version?: string | null;
devices?: Device[];
containers: Container[];
total_references: number;
} = JSON.parse(jsonStr);
if (!parsed.containers || parsed.containers.length === 0) {
return {
content: [
{
type: 'text' as const,
text:
'No library managers found in the project tree. ' +
'Either the project really has none, or the libman discovery failed -- ' +
'check the script DEBUG output for a tree dump.',
},
],
isError: false,
};
}
if (parsed.total_references === 0) {
const containerNames = parsed.containers.map((c) => c.container_name).join(', ');
return {
content: [
{
type: 'text' as const,
text: `Found ${parsed.containers.length} library manager(s) (${containerNames}) but 0 library references in any of them.`,
},
],
isError: false,
};
}
// Group output by container so the user can see which Application
// owns which libraries.
const sections: string[] = [];
for (const c of parsed.containers) {
const header = `${c.container_name} (libman: ${c.libman_name}) — ${c.references.length} reference(s)`;
const lines = c.references.map((ref) => {
const flags: string[] = [];
if (ref.system_library) flags.push('system');
if (ref.is_placeholder) flags.push('placeholder');
if (ref.is_managed) flags.push('managed');
if (ref.optional) flags.push('optional');
if (ref.is_redirected) flags.push('redirected');
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
const ns = ref.namespace ? ` ns=${ref.namespace}` : '';
const eff = ref.effective_resolution ? ` -> ${ref.effective_resolution}` : '';
return ` - ${ref.name ?? '?'}${flagStr}${ns}${eff}`;
});
sections.push(`${header}\n${lines.join('\n')}`);
}
// Header section: project version (from Project Information),
// CODESYS Development System version (from IronPython sys.version
// inside CODESYS), and every device's offline target id triple.
const headerLines: string[] = [];
const pi = parsed.project_info ?? {};
if (pi.version || pi.title || pi.company) {
headerLines.push('Project info:');
if (pi.version) headerLines.push(` Version: ${pi.version}`);
if (pi.title) headerLines.push(` Title: ${pi.title}`);
if (pi.company) headerLines.push(` Company: ${pi.company}`);
if (pi.author) headerLines.push(` Author: ${pi.author}`);
}
if (parsed.ide_version) {
headerLines.push(`IDE: ${parsed.ide_version.replace(/\s+/g, ' ').trim()}`);
}
if (parsed.compiler_version) {
headerLines.push(`Compiler version: ${parsed.compiler_version}`);
}
if (parsed.devices && parsed.devices.length > 0) {
headerLines.push(`Devices (${parsed.devices.length}):`);
for (const d of parsed.devices) {
const idStr = [d.device_id_type, d.device_id_id, d.device_id_version]
.filter(Boolean)
.join(' / ');
headerLines.push(` ${d.path}${idStr ? ' [' + idStr + ']' : ''}`);
}
}
const summary =
`Project: ${parsed.project ?? '?'}${parsed.total_references} library reference(s) across ${parsed.containers.length} container(s).`;
const blocks: string[] = [summary];
if (headerLines.length > 0) blocks.push(headerLines.join('\n'));
blocks.push(sections.join('\n\n'));
return {
content: [{ type: 'text' as const, text: blocks.join('\n\n') }],
isError: false,
};
} catch (e) {
return {
content: [
{
type: 'text' as const,
text: `Failed to parse libraries JSON: ${e instanceof Error ? e.message : String(e)}`,
},
],
isError: true,
};
}
}
);
s.tool(
'add_library',
'Adds a library reference to the CODESYS project. The library must be installed in the CODESYS library repository.',
{
projectFilePath: z.string().describe("Path to the project file."),
libraryName: z.string().describe("Name of the library to add (e.g., 'Standard', 'Util', 'CAA Memory')."),
},
async (args: { projectFilePath: string; libraryName: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'add_library',
{
PROJECT_FILE_PATH: escaped,
LIBRARY_NAME: args.libraryName.trim(),
},
['ensure_project_open']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Library '${args.libraryName}' added to ${args.projectFilePath}. Project saved.`,
escaped,
mirrorCtx
);
}
);
// ─── Project metadata ────────────────────────────────────────────────
s.tool(
'bump_project_version',
"Bumps one part of the 4-part Project Information.Version field of the primary project (Major.Minor.Revision.Build) and saves the project. Also maintains a `_MCP_PROJECT_VERSION` GVL under Application with the new version as `sVersion : STRING := '<X.Y.Z.W>'` so the running PLC carries the version at a known address (read it via the read_running_version_online tool). The GVL is created on first bump and updated in place thereafter. Convention: major = incompatible API break (rename FB / change public signature / remove method); minor = backward-compatible feature add (new FB / GVL / method); revision = bug fix only; build = internal counter, often 0 for hand-released versions. Bumping a higher part resets all lower parts to 0. FIRST-RUN: if no version is set yet (None/empty/0.0.0.0), seeds at 1.0.0.0 regardless of level so a first-time bump gives a canonical starting point instead of 0.0.0.1. AUTO MODE: if level='auto', the tool diffs the project's mcp-mirror/ folder against the latest v* git tag and classifies the change (deletion/rename -> major; addition -> minor; modification -> revision; no changes -> short-circuits with no bump; first-run -> seed at 1.0.0.0).",
{
projectFilePath: z.string().describe("Path to the project file."),
level: z.enum(['major', 'minor', 'revision', 'build', 'auto']).describe("Which part of the 4-part version to bump. Major = incompatible API break. Minor = backward-compatible feature add. Revision = bug fix only. Build = internal / CI counter. AUTO classifies via git diff of mcp-mirror/ against the latest v* tag."),
},
async (args: { projectFilePath: string; level: 'major' | 'minor' | 'revision' | 'build' | 'auto' }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
if (args.level === 'auto') {
const projectDir = path.dirname(escaped);
const r = classifyMcpMirrorChanges(projectDir);
// Short-circuit: nothing to bump.
if (r.kind === 'no-changes') {
return {
content: [
{
type: 'text' as const,
text:
`bump_project_version (auto): no version change.\n\n` +
r.evidence.map((e) => ` - ${e}`).join('\n'),
},
],
isError: false,
};
}
// First-run resolves to 'build' on the Python side, where seed-at-1.0.0.0
// kicks in if Project Information.Version is None / empty / 0.0.0.0.
const resolvedLevel: 'major' | 'minor' | 'revision' | 'build' =
r.kind === 'first-run' ? 'build' : r.level;
const script = scriptManager.prepareScriptWithHelpers(
'bump_project_version',
{
PROJECT_FILE_PATH: escaped,
LEVEL: resolvedLevel,
},
['ensure_project_open']
);
const result = await executor.executeScript(script);
// Append Changelog entry (soft-fails internally).
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
const { from, to } = parseBumpedVersion(result.output);
if (to) {
const levelLabel = r.kind === 'first-run' ? 'seed' : `auto: ${resolvedLevel}`;
appendChangelogEntry(projectDir, from, to, levelLabel, r.evidence);
}
}
const tag = r.kind === 'first-run' ? 'first-run -> seed' : `auto -> ${resolvedLevel}`;
return formatToolResponse(
result,
`bump_project_version (${tag}) complete for ${args.projectFilePath}.\n\nClassification:\n${r.evidence
.map((e) => ` - ${e}`)
.join('\n')}`
);
}
const script = scriptManager.prepareScriptWithHelpers(
'bump_project_version',
{
PROJECT_FILE_PATH: escaped,
LEVEL: args.level,
},
['ensure_project_open']
);
const result = await executor.executeScript(script);
// Append Changelog entry for manual bumps too.
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
const { from, to } = parseBumpedVersion(result.output);
if (to) {
const projectDir = path.dirname(escaped);
appendChangelogEntry(projectDir, from, to, `manual: ${args.level}`, []);
}
}
return formatToolResponse(
result,
`bump_project_version (${args.level}) complete for ${args.projectFilePath}.`
);
}
);
s.tool(
'release_project_version',
"ONE-SHOT release pipeline. Runs the full sync from a CODESYS code change all the way to a tagged + pushed git commit, with no manual orchestration in between. Sequence: (1) mirror_export refreshes mcp-mirror/; (2) classifier diffs the new mirror against the latest v* tag; (3) if no changes, short-circuits with 'no version change'; (4) otherwise bump_project_version with the resolved level; (5) regenerate library.md as markdown; (6) regenerate pou-dump.md as markdown; (7) regex-replace the version reference in README.md; (8) Changelog.md auto-appended with the new entry (timestamp + classification evidence); (9) git add the controlled paths only (mcp-mirror, the four .md files, .gitignore, the .project binary); (10) git commit; (11) git tag v<new>; (12) git push --follow-tags. Standard workflow: ask Claude to run release_project_version after every confirmed change in CODESYS. Requires the project's parent dir to be a git repo with a configured remote.",
{
projectFilePath: z.string().describe("Path to the project file."),
push: z.boolean().optional().describe("Push to origin after commit/tag. Default true."),
},
async (args: { projectFilePath: string; push?: boolean }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const projectDir = path.dirname(escaped);
const doPush = args.push !== false;
const log: string[] = [];
// 0. SHA fingerprints (BEFORE mirror_export overwrites the mirror).
// Two SHAs tracked per release in the v* tag's annotated body:
// - project-sha256 = sha of the .project binary
// - mirror-sha256 = sha of the mcp-mirror/ tree (sorted file walk)
// Comparing the current SHAs against the latest tag's stored SHAs lets
// us detect three classes of change:
// (a) binary changed AND mirror unchanged (working tree, before re-export)
// -> normal "user edited via IDE" path; classifier handles it.
// (b) binary unchanged AND mirror changed (working tree, before re-export)
// -> user edited mirror files DIRECTLY (text editor on .st files).
// The mirror_export below is about to overwrite those edits.
// Surface a WARNING so it's at least visible in the log.
// (Future: a mirror_import tool could push these back into the
// binary; until then, mirror is one-way.)
// (c) binary changed AND mirror UNCHANGED *after* re-export
// -> non-textual binary change (device tree / library refs / task
// config / visu / Save() touch). Classifier sees no diff but
// binary SHA flipped. Classify as build-level bump so the
// version still ticks and the change isn't silently dropped.
const mirrorDir = path.join(projectDir, 'mcp-mirror');
const projectShaNow = (() => {
try { return sha256OfFile(escaped); } catch { return ''; }
})();
const mirrorShaBeforeExport = sha256OfDirectory(mirrorDir);
let priorTag = '';
try {
priorTag = execSync(
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim();
} catch { /* first-run; no prior tag */ }
const priorShas = readTagShas(projectDir, priorTag);
if (priorTag) {
log.push(`prior tag: ${priorTag} (project-sha256: ${priorShas.project ? priorShas.project.slice(0, 12) + '...' : '(none)'}, mirror-sha256: ${priorShas.mirror ? priorShas.mirror.slice(0, 12) + '...' : '(none)'})`);
}
// (b) detection: mirror was edited directly while binary stood still.
const mirrorEditedDirectly =
priorShas.mirror !== undefined &&
mirrorShaBeforeExport !== '' &&
mirrorShaBeforeExport !== priorShas.mirror &&
priorShas.project !== undefined &&
projectShaNow === priorShas.project;
// 1. Mirror export
const mirrorScript = scriptManager.prepareScriptWithHelpers(
'mirror_export',
{ PROJECT_FILE_PATH: escaped, MIRROR_ROOT: path.join(projectDir, 'mcp-mirror') },
['ensure_project_open']
);
const mirrorRes = await executor.executeScript(mirrorScript);
if (!mirrorRes.success || !mirrorRes.output.includes('SCRIPT_SUCCESS')) {
return formatToolResponse(mirrorRes, 'release_project_version: mirror_export failed');
}
log.push('mirror_export: OK');
if (mirrorEditedDirectly) {
log.push('WARNING: mcp-mirror/ tree differed from the last tag\'s mirror-sha256 BEFORE mirror_export ran, while the .project binary did not change. This usually means you edited .st files in the mirror directly. mirror_export has now overwritten those edits with the current binary state. (mirror_import is not yet implemented; for now, make code changes via the IDE or set_pou_code so they reach the binary first.)');
}
// 2. Classify mirror diff vs latest v* tag
let classification = classifyMcpMirrorChanges(projectDir);
// SHA fallback (case c): mirror diff is empty but the project binary
// SHA changed. Promote a 'no-changes' classification to a build-level
// bump so non-textual changes still tick the version.
let shaPromotedToBuild = false;
if (classification.kind === 'no-changes' && priorShas.project !== undefined && projectShaNow !== priorShas.project) {
shaPromotedToBuild = true;
const evidence = [
...classification.evidence,
`binary .project SHA changed (${priorShas.project.slice(0, 12)}... -> ${projectShaNow.slice(0, 12)}...) but no mirror diff: likely device-tree / library refs / task config / visu / Save() touch -- classifying as build bump`,
];
classification = { kind: 'bump', level: 'build', evidence };
}
if (classification.kind === 'no-changes') {
return {
content: [{ type: 'text' as const, text:
'release_project_version: no version change -- mcp-mirror/ matches latest v* tag and project-sha256 is unchanged.\n\n' +
classification.evidence.map((e) => ` - ${e}`).join('\n')
}],
isError: false,
};
}
const resolvedLevel = classification.kind === 'first-run' ? 'build' : classification.level;
const levelLabel = classification.kind === 'first-run' ? 'seed' : `auto: ${resolvedLevel}${shaPromotedToBuild ? ' (sha-fallback)' : ''}`;
log.push(`classifier: ${levelLabel} (${classification.evidence.length} evidence item(s))`);
// 3. Bump
const bumpScript = scriptManager.prepareScriptWithHelpers(
'bump_project_version',
{ PROJECT_FILE_PATH: escaped, LEVEL: resolvedLevel },
['ensure_project_open']
);
const bumpRes = await executor.executeScript(bumpScript);
if (!bumpRes.success || !bumpRes.output.includes('SCRIPT_SUCCESS')) {
return formatToolResponse(bumpRes, 'release_project_version: bump_project_version failed');
}
const { from, to: newVersion } = parseBumpedVersion(bumpRes.output);
if (!newVersion) {
return { content: [{ type: 'text' as const, text: 'release_project_version: bump succeeded but new version could not be parsed' }], isError: true };
}
log.push(`bump: ${from ?? '(none)'} -> ${newVersion}`);
// 3a. SANITY CHECK: the new version MUST be strictly greater than the
// latest v* tag. Defensive guard against silent version regressions
// caused by stale in-memory CODESYS state.
//
// The version values consumed by bump_project_version (Project
// Information.Version and _MCP_PROJECT_VERSION.sVersion) are read from
// CODESYS's in-memory project tree, NOT directly from the .project
// binary on disk. If the IDE has a stale tree from a prior session, the
// script's pi-vs-GVL cross-check fix can be defeated -- both sides come
// from the same stale source. Two observed regressions on the MCPTest2
// sandbox were undetected by the in-script check:
// - 2026-04-26 v1.0.4.0 (script saw 1.0.3.0, on-disk was 1.2.0.0)
// - 2026-04-26 v1.1.0.0 (script saw 1.0.0.0, on-disk was 1.2.1.0)
// In both cases the orchestrator went on to call git tag, which
// either failed (tag exists) or wrote a wrong-direction tag.
//
// This guard catches *any* cause of misread (in-memory drift, regex
// edge case, future fork bug we haven't seen yet) at the orchestrator
// boundary, before any git commit / tag / push lands. The .project
// binary on disk has already been written with the bad value at this
// point -- recovery is a manual step (shutdown + relaunch + retry, or
// explicit bump_project_version calls until > tag), but no permanent
// damage was published.
let latestTag = '';
try {
latestTag = execSync(
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim();
} catch {
// No prior v* tag -- true first-run; skip the check.
}
if (latestTag) {
const tagV = latestTag.replace(/^v/, '');
const cmpVersion = (a: string, b: string): number => {
const A = a.split('.').map((n) => parseInt(n, 10) || 0);
const B = b.split('.').map((n) => parseInt(n, 10) || 0);
while (A.length < 4) A.push(0);
while (B.length < 4) B.push(0);
for (let i = 0; i < 4; i++) {
if (A[i] !== B[i]) return A[i] - B[i];
}
return 0;
};
if (cmpVersion(newVersion, tagV) <= 0) {
return {
content: [{ type: 'text' as const, text:
`release_project_version: SANITY CHECK FAILED -- new version ${newVersion} is not greater than the latest v* tag (${latestTag}).\n\n` +
`This usually means CODESYS's in-memory project tree was stale when bump_project_version ran -- the version values read by the script (Project Information.Version and/or _MCP_PROJECT_VERSION.sVersion) reflect a prior session's state, not the on-disk binary. The bump computed a value that would silently regress the version sequence.\n\n` +
`The .project binary on disk has been saved with the regressed value (${newVersion}) -- to recover:\n` +
` 1. shutdown_codesys -- clears the stale in-memory tree.\n` +
` 2. launch_codesys + open_project -- reload the binary fresh from disk.\n` +
` 3. Re-run release_project_version -- bump_project_version will now see the correct on-disk values.\n` +
` 4. If the issue persists after a fresh open, the binary itself has the wrong value (the recovery from a previous failed run wrote it). Run bump_project_version with explicit level=minor (or major/revision per your taste) repeatedly until the version exceeds ${tagV}, then re-run release_project_version.\n\n` +
`Pipeline state at abort:\n` +
log.map((l) => ` ${l}`).join('\n') + '\n' +
` bump (rejected): ${from ?? '(none)'} -> ${newVersion} (must be > ${tagV})\n\n` +
`No commit / no tag / no push made. Mirror, library.md, pou-dump.md, README.md, Changelog.md were NOT regenerated for the rejected version.`
}],
isError: true,
};
}
log.push(`sanity check: ${newVersion} > ${tagV} (latest tag) -- OK`);
} else {
log.push('sanity check: skipped (no v* tag baseline)');
}
// 3b. Re-run mirror_export AFTER the bump. The pre-bump mirror
// captured the old _MCP_PROJECT_VERSION.sVersion value and (when
// applicable) the old Project Information.Version. After the bump,
// those values changed in CODESYS in-memory and got saved to the
// .project binary. The mirror needs to reflect the post-bump state
// or the next 'release' call will see _MCP_PROJECT_VERSION.st as
// a real diff vs the just-tagged release and bump again. Surfaced
// on MCPTest2 v1.1.0.0 (commit 8d79193): the binary GVL was
// 1.0.2.0 while the docs said 1.1.0.0 because mirror_export
// didn't re-run after the bump.
try {
const mirrorScript2 = scriptManager.prepareScriptWithHelpers(
'mirror_export',
{ PROJECT_FILE_PATH: escaped, MIRROR_ROOT: path.join(projectDir, 'mcp-mirror') },
['ensure_project_open']
);
const mirror2 = await executor.executeScript(mirrorScript2);
if (mirror2.success && mirror2.output.includes('SCRIPT_SUCCESS')) {
log.push('mirror_export (post-bump): OK');
} else {
log.push('mirror_export (post-bump): WARNING -- post-bump mirror may be stale');
}
} catch (e) {
log.push(`mirror_export (post-bump): WARNING -- ${e instanceof Error ? e.message : String(e)}`);
}
// 4. Append Changelog (the manual-bump path doesn't auto-append; do it here)
appendChangelogEntry(projectDir, from, newVersion, levelLabel, classification.evidence);
log.push(`Changelog.md: appended v${newVersion}`);
// 5. Refresh library.md
try {
const libsScript = scriptManager.prepareScriptWithHelpers(
'list_project_libraries', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const libsRes = await executor.executeScript(libsScript);
const startMarker = '### LIBRARIES_START ###';
const endMarker = '### LIBRARIES_END ###';
const sIdx = libsRes.output.indexOf(startMarker);
const eIdx = libsRes.output.indexOf(endMarker);
if (sIdx >= 0 && eIdx > sIdx) {
const libs: LibrariesData = JSON.parse(libsRes.output.substring(sIdx + startMarker.length, eIdx).trim());
fs.writeFileSync(path.join(projectDir, 'library.md'), renderLibraryMd(libs, newVersion), 'utf-8');
log.push(`library.md: ${libs.total_references} refs`);
} else {
log.push('library.md: skipped (markers not found in output)');
}
} catch (e) {
log.push(`library.md: skipped (${e instanceof Error ? e.message : String(e)})`);
}
// 6. Refresh pou-dump.md
try {
const pouScript = scriptManager.prepareScriptWithHelpers(
'get_all_pou_code', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const pouRes = await executor.executeScript(pouScript);
const startMarker = '### ALL_POU_CODE_START ###';
const endMarker = '### ALL_POU_CODE_END ###';
const sIdx = pouRes.output.indexOf(startMarker);
const eIdx = pouRes.output.indexOf(endMarker);
if (sIdx >= 0 && eIdx > sIdx) {
const pou: PouEntry[] = JSON.parse(pouRes.output.substring(sIdx + startMarker.length, eIdx).trim());
const projName = path.basename(escaped, '.project');
fs.writeFileSync(path.join(projectDir, 'pou-dump.md'), renderPouDumpMd(pou, projName), 'utf-8');
log.push(`pou-dump.md: ${pou.length} POUs`);
} else {
log.push('pou-dump.md: skipped (markers not found in output)');
}
} catch (e) {
log.push(`pou-dump.md: skipped (${e instanceof Error ? e.message : String(e)})`);
}
// 7. Update README.md version header
const readmePath = path.join(projectDir, 'README.md');
if (fs.existsSync(readmePath)) {
try {
const before = fs.readFileSync(readmePath, 'utf-8');
const after = before.replace(/v\d+\.\d+\.\d+\.\d+/g, `v${newVersion}`);
if (after !== before) {
fs.writeFileSync(readmePath, after, 'utf-8');
log.push(`README.md: bumped to v${newVersion}`);
}
} catch (e) {
log.push(`README.md: skipped (${e instanceof Error ? e.message : String(e)})`);
}
}
// 8. Git add / commit / tag / push
try {
const projName = path.basename(escaped);
const candidatePaths = ['mcp-mirror', 'library.md', 'pou-dump.md', 'README.md', 'Changelog.md', '.gitignore', projName];
const addPaths = candidatePaths.filter((p) => fs.existsSync(path.join(projectDir, p)));
const addArgs = addPaths.map((p) => `"${p}"`).join(' ');
execSync(`git -C "${projectDir}" add ${addArgs}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
const summary = classification.evidence.slice(0, 5).join('; ').slice(0, 200);
const commitMsg = `release v${newVersion} (${levelLabel})\n\n${summary}\n`;
execSync(`git -C "${projectDir}" commit -m ${JSON.stringify(commitMsg)}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
// Compute the post-commit SHAs and embed them in the annotated tag.
// These represent the state at this released version: any future
// release_project_version call reads them via readTagShas() to
// detect whether the .project binary or the mirror tree has changed
// since this release. The post-bump mirror_export at step 3b
// refreshed the mirror to match the new GVL value, and the bump
// saved the binary; both reads here should reflect the v<new>
// post-bump state.
const newProjectSha = (() => {
try { return sha256OfFile(escaped); } catch { return ''; }
})();
const newMirrorSha = sha256OfDirectory(mirrorDir);
const tagBody =
`v${newVersion} (${levelLabel})\n\n` +
`project-sha256: ${newProjectSha}\n` +
`mirror-sha256: ${newMirrorSha}\n`;
// Write the tag body via -F <tempfile> so real LF newlines are
// preserved verbatim. Earlier versions used `-m JSON.stringify(body)`
// which the shell passed through with literal "\n" sequences,
// breaking the multiline regex in readTagShas() on the read side.
const tagBodyFile = path.join(os.tmpdir(), `codesys-mcp-tagbody-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.txt`);
fs.writeFileSync(tagBodyFile, tagBody, 'utf-8');
try {
execSync(`git -C "${projectDir}" tag -a v${newVersion} -F "${tagBodyFile}" --cleanup=verbatim`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
} finally {
try { fs.unlinkSync(tagBodyFile); } catch { /* best-effort cleanup */ }
}
log.push(`git: committed + tagged v${newVersion} (project-sha256: ${newProjectSha.slice(0, 12)}..., mirror-sha256: ${newMirrorSha.slice(0, 12)}...)`);
if (doPush) {
execSync(`git -C "${projectDir}" push --follow-tags`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
log.push('git: pushed --follow-tags');
}
} catch (e) {
return {
content: [{ type: 'text' as const, text:
`release_project_version: bumped to v${newVersion} but git ops failed.\n\n` +
log.map((l) => ` ${l}`).join('\n') +
`\n\ngit error: ${e instanceof Error ? e.message : String(e)}`
}],
isError: true,
};
}
return {
content: [{ type: 'text' as const, text:
`release_project_version: v${from ?? '(none)'} -> v${newVersion} (${levelLabel})\n\n` +
log.map((l) => ` ${l}`).join('\n')
}],
isError: false,
};
}
);
s.tool(
'read_running_version_online',
"Reads the running project's version from a connected PLC over the CODESYS online protocol (port 11740 / gateway). Returns the value of `_MCP_PROJECT_VERSION.sVersion` -- the runtime anchor that bump_project_version maintains automatically. Use this to confirm what version the live PLC is actually running, independently of whatever's in the .project file or the mcp-mirror/. Provides actionable error messages when the GVL is missing (project never bumped) or the boot application is stale (downloaded before the last bump). Implementation: ensure_project_open + ensure_online_connection + read_value('_MCP_PROJECT_VERSION.sVersion').",
{
projectFilePath: z.string().describe("Path to the project file. The tool opens it if not already primary, connects to its configured device, and reads the version anchor."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'read_running_version_online',
{ PROJECT_FILE_PATH: escaped },
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
// Pull the version out of the script's RUNNING_VERSION line
const match = /RUNNING_VERSION: (\S+)/.exec(result.output || '');
const version = match ? match[1] : '?';
return formatToolResponse(
result,
`Running version on PLC: ${version}\n(read from _MCP_PROJECT_VERSION.sVersion via CODESYS online protocol)`
);
}
);
s.tool(
'read_running_version_ssh',
"Reads the running project version from a CODESYS Control Linux PLC via SSH, by extracting the X.Y.Z.W literal of `_MCP_PROJECT_VERSION.sVersion` from the boot application binary. Bypasses CODESYS entirely -- no IDE running, no project lock, no online protocol. Requires SSH key auth (one-time setup, see error message if you don't have it) and passwordless sudo on the PLC for `strings`. Linux PLCs only (CODESYS Control on Raspberry Pi, IPC, etc.).",
{
host: z.string().describe('Hostname or IP of the CODESYS Control Linux PLC.'),
user: z.string().optional().describe('SSH user. Defaults to "karstein".'),
bootAppPath: z.string().optional().describe('Path to the boot application binary on the PLC. Defaults to "/var/opt/codesys/PlcLogic/Application/Application.app".'),
},
async (args: { host: string; user?: string; bootAppPath?: string }) => {
try {
const res = await readRunningVersionSsh({
host: args.host,
user: args.user,
bootAppPath: args.bootAppPath,
});
return {
content: [{ type: 'text' as const, text: formatSshVersionResult(res) }],
isError: false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text' as const, text: msg }],
isError: true,
};
}
}
);
// ─── Filesystem mirror (Phase 1: read-only export) ────────────────────
s.tool(
'mirror_export',
"Walks the CODESYS project tree and writes one .st file per code-bearing object into a filesystem mirror, preserving the project tree as nested directories. Programs / Function Blocks / Functions / Methods / Properties / DUTs / GVLs / Interfaces all become text files; structural nodes (Devices, Applications, Folders) become directories. Each file carries a header comment with its original CODESYS project path so a future write-back tool can map it back to set_pou_code's pouPath. Read-only -- does NOT modify the CODESYS project. UTF-8 output. If mirrorRoot is omitted, defaults to '<projectDir>/mcp-mirror'.",
{
projectFilePath: z.string().describe("Path to the project file."),
mirrorRoot: z.string().optional().describe("Filesystem path where the mirror tree gets written. If omitted, defaults to '<projectDir>/mcp-mirror'. Created automatically if missing; existing files at the same paths are overwritten."),
},
async (args: { projectFilePath: string; mirrorRoot?: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const mirrorRoot = args.mirrorRoot
? resolvePath(args.mirrorRoot, workspaceDir)
: path.join(path.dirname(escaped), 'mcp-mirror');
const script = scriptManager.prepareScriptWithHelpers(
'mirror_export',
{
PROJECT_FILE_PATH: escaped,
MIRROR_ROOT: mirrorRoot,
},
['ensure_project_open']
);
const result = await executor.executeScript(script);
return formatToolResponse(
result,
`mirror_export complete for ${args.projectFilePath} -> ${mirrorRoot}.`
);
}
);
// ─── Resources ───────────────────────────────────────────────────────
server.resource(
'project-status',
'codesys://project/status',
async (uri) => {
try {
const script = scriptManager.loadTemplate('check_status');
const result = await executor.executeScript(script);
const outputLines = result.output.split(/[\r\n]+/).filter((l) => l.trim());
const statusData: Record<string, string> = {};
outputLines.forEach((line) => {
const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) statusData[match[1].trim()] = match[2].trim();
});
const statusText = [
'CODESYS Status:',
` - Scripting OK: ${statusData['Scripting OK'] ?? 'Unknown'}`,
` - Project Open: ${statusData['Project Open'] ?? 'Unknown'}`,
` - Project Name: ${statusData['Project Name'] ?? 'Unknown'}`,
` - Project Path: ${statusData['Project Path'] ?? 'N/A'}`,
].join('\n');
const isError =
!result.success ||
statusData['Scripting OK']?.toLowerCase() !== 'true';
return {
contents: [{ uri: uri.href, text: statusText, contentType: 'text/plain' }],
isError,
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
contents: [{ uri: uri.href, text: `Failed status check: ${msg}`, contentType: 'text/plain' }],
isError: true,
};
}
}
);
const projectStructureTemplate = new ResourceTemplate(
'codesys://project/{+project_path}/structure',
{ list: undefined }
);
server.resource(
'project-structure',
projectStructureTemplate,
async (uri, params) => {
const projectPath = params.project_path as string;
if (!projectPath) {
return {
contents: [{ uri: uri.href, text: 'Error: Project path missing.', contentType: 'text/plain' }],
isError: true,
};
}
try {
const escaped = resolvePath(projectPath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'get_project_structure', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
);
const result = await executor.executeScript(script);
let structureText = `Error retrieving structure.\n\n${result.output}`;
let isError = !result.success;
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
const startMarker = '--- PROJECT STRUCTURE START ---';
const endMarker = '--- PROJECT STRUCTURE END ---';
const startIdx = result.output.indexOf(startMarker);
const endIdx = result.output.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
structureText = result.output
.substring(startIdx + startMarker.length, endIdx)
.replace(/\\n/g, '\n')
.trim();
} else {
structureText = `Could not parse structure markers.\n\nOutput:\n${result.output}`;
isError = true;
}
}
return {
contents: [{ uri: uri.href, text: structureText, contentType: 'text/plain' }],
isError,
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
contents: [{ uri: uri.href, text: `Failed: ${msg}`, contentType: 'text/plain' }],
isError: true,
};
}
}
);
const pouCodeTemplate = new ResourceTemplate(
'codesys://project/{+project_path}/pou/{+pou_path}/code',
{ list: undefined }
);
server.resource(
'pou-code',
pouCodeTemplate,
async (uri, params) => {
const projectPath = params.project_path as string;
const pouPath = params.pou_path as string;
if (!projectPath || !pouPath) {
return {
contents: [{ uri: uri.href, text: 'Error: Project or POU path missing.', contentType: 'text/plain' }],
isError: true,
};
}
try {
const escProjPath = resolvePath(projectPath, workspaceDir);
const sanPouPath = sanitizePouPath(pouPath);
const script = scriptManager.prepareScriptWithHelpers(
'get_pou_code',
{ PROJECT_FILE_PATH: escProjPath, POU_FULL_PATH: sanPouPath },
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
let codeText = `Error retrieving code.\n\n${result.output}`;
let isError = !result.success;
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
const declStart = '### POU DECLARATION START ###';
const declEnd = '### POU DECLARATION END ###';
const implStart = '### POU IMPLEMENTATION START ###';
const implEnd = '### POU IMPLEMENTATION END ###';
let declaration = '/* Declaration not found */';
let implementation = '/* Implementation not found */';
const ds = result.output.indexOf(declStart);
const de = result.output.indexOf(declEnd);
if (ds !== -1 && de !== -1 && ds < de) {
declaration = result.output.substring(ds + declStart.length, de).replace(/\\n/g, '\n').trim();
}
const is_ = result.output.indexOf(implStart);
const ie = result.output.indexOf(implEnd);
if (is_ !== -1 && ie !== -1 && is_ < ie) {
implementation = result.output.substring(is_ + implStart.length, ie).replace(/\\n/g, '\n').trim();
}
codeText = `// ----- Declaration -----\n${declaration}\n\n// ----- Implementation -----\n${implementation}`;
}
return {
contents: [{ uri: uri.href, text: codeText, contentType: 'text/plain' }],
isError,
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
contents: [{ uri: uri.href, text: `Failed: ${msg}`, contentType: 'text/plain' }],
isError: true,
};
}
}
);
// ─── Connect ─────────────────────────────────────────────────────────
const transport = new StdioServerTransport();
serverLog.info('Connecting MCP server via stdio...');
server.connect(transport);
serverLog.info('MCP Server connected and listening.');
// ─── Graceful Shutdown ───────────────────────────────────────────────
const shutdown = async () => {
serverLog.info('Shutdown signal received');
if (launcher) {
try {
await launcher.shutdown();
} catch {
serverLog.warn('Launcher shutdown failed during signal handler');
}
}
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('unhandledRejection', (reason) => {
serverLog.error(`Unhandled rejection: ${reason}`);
});
}