The wrapper at server.ts:1896 always rendered "Library 'X' added" even
when the script's dedup pre-check no-op'd because the same library was
already referenced. The script itself emits distinct markers
("Library Already Present:" vs "Library Added:"); the wrapper now
inspects result.output to pick wording instead of hardcoding "added".
3068 lines
139 KiB
TypeScript
3068 lines
139 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';
|
|
import {
|
|
restartCodesysRuntime,
|
|
formatRestartRuntimeResult,
|
|
} from './ssh-restart-runtime';
|
|
import { resolveMirrorRoot } from './mirror-paths';
|
|
import { inspectProjectFile } from './inspect';
|
|
import { parseProfileName } from './detect';
|
|
import { decideOpenProjectPreflight } from './preflight';
|
|
import { readSelection } from './state-read';
|
|
import { runApproveGate } from './approve-gate';
|
|
|
|
/**
|
|
* 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, mirrorDirName: string = 'mcp-mirror'): ClassifyResult {
|
|
const evidence: string[] = [];
|
|
// Pathspec for git: posix-style with trailing slash. Caller passes a bare
|
|
// directory name (relative to projectDir, no separators) so we don't need
|
|
// to normalise; the multi-project case is e.g. 'ProjectA_mcp_mirror'.
|
|
const pathspec = `${mirrorDirName}/`;
|
|
|
|
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} -- ${pathspec}`,
|
|
{ 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 -- ${pathspec}`,
|
|
{ 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 ${pathspec} 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;
|
|
// Resolve via the same rule the CODESYS-side mirror_export.py uses, so
|
|
// the dir we ask VSCode to open is the dir mirror_export actually wrote
|
|
// to (single-project parents stay on the legacy mcp-mirror/ path; multi-
|
|
// project parents get a per-project <basename>_mcp_mirror/ path).
|
|
const mirrorDir = resolveMirrorRoot(projectFilePath);
|
|
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 buildGetUserSelectionResponse(stateFilePath: string) {
|
|
const r = await readSelection(stateFilePath);
|
|
if (r.status === 'ok') {
|
|
const lines = [
|
|
`User is currently looking at:`,
|
|
` Device: ${r.payload.device}`,
|
|
` POU: ${r.payload.selection.name} (${r.payload.selection.kind})`,
|
|
` Path: ${r.payload.selection.path}`,
|
|
` AbsPath: ${r.payload.selection.abs_path}`,
|
|
` Project: ${r.payload.project_dir}`,
|
|
` Viewer line: ${r.payload.viewer_line}`,
|
|
` Updated: ${r.payload.updated_at}`,
|
|
];
|
|
return { content: [{ type: 'text' as const, text: lines.join('\n') }], isError: false };
|
|
}
|
|
if (r.status === 'invalid') {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `Invalid TUI state file: ${r.reason}. No active selection.`,
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'No active selection (TUI not running or stale).' }],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
function defaultStateFilePath(): string {
|
|
if (process.platform === 'win32') {
|
|
const localAppData = process.env.LOCALAPPDATA;
|
|
if (!localAppData) {
|
|
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json');
|
|
}
|
|
return path.join(localAppData, 'codesys-mcp', 'tui-state.json');
|
|
}
|
|
const xdg = process.env.XDG_STATE_HOME;
|
|
const base = xdg ?? path.join(os.homedir(), '.local', 'state');
|
|
return path.join(base, 'codesys-mcp', 'tui-state.json');
|
|
}
|
|
|
|
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(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`);
|
|
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 {
|
|
// --no-auto-launch under persistent: launcher exists but isn't started.
|
|
// Keep executionMode = 'persistent' so get_codesys_status reflects the
|
|
// configured intent (State: stopped tells the user it isn't running).
|
|
// Tool calls before launch_codesys go through HeadlessExecutor as a
|
|
// best-effort fallback so the server stays useful.
|
|
executor = new HeadlessExecutor(config);
|
|
}
|
|
} 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,
|
|
};
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'get_user_selection',
|
|
'Get the POU the user is currently looking at in the phobiCS-tui browser, if any. Returns a freshness-checked snapshot from the TUI state file. Useful for grounding modifying tool calls in what the user has selected.',
|
|
async () => buildGetUserSelectionResponse(defaultStateFilePath())
|
|
);
|
|
|
|
// ─── 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);
|
|
|
|
// Pre-flight: compare the project's saved profile against this
|
|
// server's configured profile. Refuses on SP mismatch, warns on
|
|
// patch mismatch, silent on exact match. Falls through silently
|
|
// (logs only) if anything in the chain fails -- the existing open
|
|
// path then surfaces CODESYS's native error.
|
|
let preflightWarning = '';
|
|
try {
|
|
const serverProfile = parseProfileName(config.profileName);
|
|
if (serverProfile) {
|
|
const insp = await inspectProjectFile(escaped);
|
|
const decision = decideOpenProjectPreflight(
|
|
{ sp: insp.sp, patch: insp.patch, profileName: insp.profileName, profileVersion: insp.profileVersion },
|
|
serverProfile,
|
|
escaped
|
|
);
|
|
if (decision.action === 'refuse') {
|
|
return {
|
|
content: [{ type: 'text' as const, text: decision.message ?? 'Refused' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (decision.action === 'proceed-with-warning' && decision.message) {
|
|
preflightWarning = decision.message + '\n';
|
|
}
|
|
}
|
|
// serverProfile null -> non-standard profile name, can't compare; fall through.
|
|
} catch (e) {
|
|
serverLog.warn(
|
|
`open_project pre-flight inspection failed (proceeding anyway): ${
|
|
e instanceof Error ? e.message : String(e)
|
|
}`
|
|
);
|
|
}
|
|
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'open_project', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return await formatModifyingResponse(
|
|
result,
|
|
`${preflightWarning}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']
|
|
);
|
|
if (config.approveEdits) {
|
|
const gate = await runApproveGate({
|
|
projectFilePath: escProjPath,
|
|
pouPath: sanPouPath,
|
|
args: {
|
|
declarationCode: args.declarationCode,
|
|
implementationCode: args.implementationCode,
|
|
},
|
|
});
|
|
if (gate.status === 'rejected') {
|
|
return {
|
|
content: [{ type: 'text' as const, text: gate.message }],
|
|
isError: false,
|
|
};
|
|
}
|
|
if (gate.status === 'error') {
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Approve gate error: ${gate.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
// 'accepted' or 'no-existing' → fall through and apply the change.
|
|
}
|
|
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. By default also updates references in every other POU/DUT/GVL by word-boundary regex (\\bOldName\\b -> NewName) so the rename behaves like the IDE's Rename refactor. Pass updateReferences=false to opt out (keep the legacy minimal-rename behaviour, which leaves callers stale and breaks compilation when renaming a type/FB).",
|
|
{
|
|
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)."),
|
|
updateReferences: z.boolean().optional().describe("If true (default), regex-replace \\bOldName\\b -> NewName in every other POU/DUT/GVL declaration AND implementation. False: rename target only -- callers will be stale and the project may stop compiling."),
|
|
},
|
|
async (args: { projectFilePath: string; objectPath: string; newName: string; updateReferences?: boolean }) => {
|
|
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
|
|
const sanObjPath = sanitizePouPath(args.objectPath);
|
|
// Default updateReferences=true (the safer behaviour). Caller must
|
|
// explicitly pass false to disable.
|
|
const updateRefs = args.updateReferences === false ? false : true;
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'rename_object',
|
|
{
|
|
PROJECT_FILE_PATH: escProjPath,
|
|
OBJECT_PATH: sanObjPath,
|
|
NEW_NAME: args.newName.trim(),
|
|
UPDATE_REFERENCES: updateRefs ? '1' : '0',
|
|
},
|
|
['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. By default the IDE pops a modal "Device User Login" dialog the user must fill in. Pass deviceUser+devicePassword (or set CODESYS_DEVICE_USER/CODESYS_DEVICE_PASSWORD env vars in the MCP server config) to pre-register credentials via ScriptOnline.set_default_credentials and skip the dialog entirely.',
|
|
{
|
|
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. Default: 60. Range 0-600."),
|
|
deviceUser: z.string().optional().describe("Device user account name. Pre-registered via set_default_credentials so the modal Device User Login dialog is suppressed. Falls back to env var CODESYS_DEVICE_USER. If neither is set, the dialog will pop (current behaviour)."),
|
|
devicePassword: z.string().optional().describe("Device user password. Same fallback chain as deviceUser via env CODESYS_DEVICE_PASSWORD."),
|
|
},
|
|
async (args: { projectFilePath: string; loginWaitSeconds?: number; deviceUser?: string; devicePassword?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const waitSec = args.loginWaitSeconds ?? 60;
|
|
const deviceUser = args.deviceUser ?? process.env.CODESYS_DEVICE_USER ?? '';
|
|
const devicePassword = args.devicePassword ?? process.env.CODESYS_DEVICE_PASSWORD ?? '';
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'connect_to_device',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LOGIN_WAIT_SECONDS: String(waitSec),
|
|
DEVICE_USER: deviceUser,
|
|
DEVICE_PASSWORD: devicePassword,
|
|
},
|
|
['register_device_credentials', '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 credential-injection support as connect_to_device: pass deviceUser+devicePassword (or set CODESYS_DEVICE_USER/CODESYS_DEVICE_PASSWORD env vars on the MCP) to suppress the Device User Login dialog that the IDE otherwise pops on every download.',
|
|
{
|
|
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."),
|
|
deviceUser: z.string().optional().describe("Device user account. Pre-registered via set_default_credentials so the modal Device User Login dialog is suppressed. Falls back to env CODESYS_DEVICE_USER."),
|
|
devicePassword: z.string().optional().describe("Device user password. Falls back to env CODESYS_DEVICE_PASSWORD."),
|
|
},
|
|
async (args: { projectFilePath: string; loginWaitSeconds?: number; deviceUser?: string; devicePassword?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const waitSec = args.loginWaitSeconds ?? 60;
|
|
const deviceUser = args.deviceUser ?? process.env.CODESYS_DEVICE_USER ?? '';
|
|
const devicePassword = args.devicePassword ?? process.env.CODESYS_DEVICE_PASSWORD ?? '';
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'download_to_device',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LOGIN_WAIT_SECONDS: String(waitSec),
|
|
DEVICE_USER: deviceUser,
|
|
DEVICE_PASSWORD: devicePassword,
|
|
},
|
|
['register_device_credentials', '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. **Refuses upfront** if the library name does not resolve in the installed library repository (Tools > Library Repository) -- this prevents the silent-broken-placeholder bug where add_placeholder() creates a hollow reference that bricks the next project open with 'placeholder library X could not be resolved'. Install the library first (Library Repository for stock libs, CODESYS Installer for SL/add-on packages), or pass allowUnresolved=true to opt into the dangerous behaviour. Default add path is add_placeholder() for the modern '<Name>, * (System)' convention; pass direct=true for the legacy specific-version pin. Pre-checks lm.references and no-ops with a confirmation message if a reference with the same name already exists, unless force=true.",
|
|
{
|
|
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'). Must match exactly an installed library in the CODESYS Library Repository unless allowUnresolved=true."),
|
|
direct: z.boolean().optional().describe("If true, use direct add_library() (specific-version pin) instead of the default add_placeholder() (resolves at compile)."),
|
|
force: z.boolean().optional().describe("If true, add even if a reference with the same name already exists (creates a duplicate). Default: dedup -- silently no-op with a confirmation message."),
|
|
allowUnresolved: z.boolean().optional().describe("DANGEROUS. If true, skip the pre-flight 'is this library installed?' check and add a placeholder anyway. Will brick the next project open if the library is genuinely not installed. Only use when you intentionally want a placeholder for a not-yet-installed library."),
|
|
},
|
|
async (args: { projectFilePath: string; libraryName: string; direct?: boolean; force?: boolean; allowUnresolved?: boolean }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'add_library',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LIBRARY_NAME: args.libraryName.trim(),
|
|
USE_DIRECT: args.direct ? '1' : '0',
|
|
FORCE_DUP: args.force ? '1' : '0',
|
|
ALLOW_UNRESOLVED: args.allowUnresolved ? '1' : '0',
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
// Pick wording from the script's branch (dedup vs add) instead of
|
|
// always saying "added" -- script emits "Library Already Present"
|
|
// on the dedup no-op path and "Library Added" on actual add.
|
|
const dedupHit = result.output.includes('Library Already Present:');
|
|
const successMessage = dedupHit
|
|
? `Library '${args.libraryName}' already referenced in ${args.projectFilePath}. No-op (use force=true to add a duplicate).`
|
|
: `Library '${args.libraryName}' added to ${args.projectFilePath}. Project saved.`;
|
|
return await formatModifyingResponse(result, successMessage, escaped, mirrorCtx);
|
|
}
|
|
);
|
|
|
|
// ─── Symbol Configuration Tools ───────────────────────────────────────
|
|
//
|
|
// Wraps ScriptSymbolConfigObject (since CODESYS 3.5.10.0). The Symbol
|
|
// Configuration object controls which IEC variables / FBs / methods are
|
|
// exposed to OPC UA, web visualisations, and other external clients.
|
|
// Reference: helpme-codesys.com/en/ScriptingEngine/ScriptSymbolConfigObject.html
|
|
// and SP22 stub Stubs/scriptengine/ScriptSymbolConfigObject.pyi.
|
|
|
|
// Bitmask members of SymbolConfigContentFeatureFlags (per the SP22 stub).
|
|
const CONTENT_FEATURE_FLAG_VALUES: Record<string, number> = {
|
|
None: 0,
|
|
SupportOPCUA: 1,
|
|
IncludeComments: 2,
|
|
IncludeAttributes: 4,
|
|
IncludeTypeNodeAttributes: 8,
|
|
IncludeExecutables: 16,
|
|
UseEmptyNamespaceByDefault: 32,
|
|
XmlIncludeNodeFlags: 1 << 16,
|
|
XmlIncludeComments: 2 << 16,
|
|
XmlIncludeAttributes: 4 << 16,
|
|
XmlIncludeTypeNodeAttributes: 8 << 16,
|
|
XmlIncludeExecutables: 16 << 16,
|
|
};
|
|
|
|
function combineContentFeatureFlags(names: string[] | undefined): number | null {
|
|
if (!names || names.length === 0) return null;
|
|
let bits = 0;
|
|
for (const n of names) {
|
|
const trimmed = n.trim();
|
|
if (!(trimmed in CONTENT_FEATURE_FLAG_VALUES)) {
|
|
throw new Error(
|
|
`Unknown contentFeatureFlag '${trimmed}'. Allowed: ${Object.keys(CONTENT_FEATURE_FLAG_VALUES).join(', ')}`
|
|
);
|
|
}
|
|
bits |= CONTENT_FEATURE_FLAG_VALUES[trimmed];
|
|
}
|
|
return bits;
|
|
}
|
|
|
|
const SYMCONF_HELPERS = ['ensure_project_open', 'find_symbol_config_object'];
|
|
|
|
s.tool(
|
|
'find_symbol_config',
|
|
"Locate the Symbol Configuration object(s) in a CODESYS project. Walks the project tree and reports every node where is_symbol_config==True (one per Application typically). Returns object name, slash-separated path, and id. If no Symbol Configuration exists, the tool returns count=0 and hints at create_symbol_config. Read-only.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'find_symbol_config',
|
|
{ PROJECT_FILE_PATH: escaped },
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, result.output);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'list_all_signatures',
|
|
"List every signature (POU / FunctionBlock / Method / Function) the Symbol Configuration could potentially export. Wraps ScriptSymbolConfigObject.get_all_signatures(compile=bool). Default compile=false returns the cached list (may be empty if the application has not been built since opening). Pass compile=true to force application.build() first -- authoritative but slow on large projects.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
compile: z.boolean().optional().describe("If true, build the application before generating the list (slow but authoritative). Default false uses the cached list."),
|
|
},
|
|
async (args: { projectFilePath: string; compile?: boolean }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'list_all_signatures',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
COMPILE_FLAG: args.compile ? '1' : '0',
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, result.output);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'list_all_datatypes',
|
|
"List every data type (struct / enum / alias / union) the Symbol Configuration could potentially export. Wraps ScriptSymbolConfigObject.get_all_datatypes(compile=bool). Same compile=true semantics as list_all_signatures.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
compile: z.boolean().optional().describe("If true, build the application before generating the list. Default false uses the cached list."),
|
|
},
|
|
async (args: { projectFilePath: string; compile?: boolean }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'list_all_datatypes',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
COMPILE_FLAG: args.compile ? '1' : '0',
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, result.output);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'list_configured_symbols',
|
|
"List the signatures + datatypes that are CURRENTLY configured for export by the Symbol Configuration (i.e. the user has ticked them in the IDE grid, or set_symbol_access has set their configured_access). For each variable: configured_access (what's set), maximal_access (the upper bound), effective_access (post-clamp), and exported_via_attribute (true if the export was driven by a {attribute 'symbol' := ...} pragma). Read-only.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'list_configured_symbols',
|
|
{ PROJECT_FILE_PATH: escaped },
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, result.output);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'get_symbol_config_settings',
|
|
"Read every knob on the Symbol Configuration object: content_feature_flags (OPC UA / IncludeComments / IncludeAttributes / IncludeExecutables / etc.), symbol_attribute_filter_type and _data, symbol_comment_filter_type, enable_direct_io_access (plus any obstacles that would block enabling it), and the client-side layout calculator. Both 'configured' and 'effective' values are reported where the API distinguishes them. Read-only.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'get_symbol_config_settings',
|
|
{ PROJECT_FILE_PATH: escaped },
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, result.output);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'create_symbol_config',
|
|
"Add a Symbol Configuration object under an Application. Wraps ScriptApplicationSymbolConfigExtension.create_symbol_config(export_comments_to_xml, support_opc_ua, layout_guid). IDEMPOTENT: if a Symbol Configuration already exists anywhere in the project tree, the tool no-ops with success and returns the existing object's path -- it does NOT add a duplicate. Saves the project on actual creation.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
applicationPath: z.string().optional().describe("Slash-separated path to the Application (e.g. 'CodesysRpi/Plc Logic/Application'), or just 'Application'. Empty/omitted = use the project's active application."),
|
|
exportCommentsToXml: z.boolean().optional().describe("Pass to the API's export_comments_to_xml flag. Default true."),
|
|
supportOpcUa: z.boolean().optional().describe("Pass to the API's support_opc_ua flag. Default true."),
|
|
layoutCalculator: z.enum(['compatibility', 'optimized']).optional().describe("Which client-side layout calculator to register. 'compatibility' (default) uses Guid.Empty (always available). 'optimized' uses {0141eb75-141b-4ea1-9a8c-75f952b22a6c} (V3.5.7.0+, requires compiler version 3.5.7.0+)."),
|
|
},
|
|
async (args: {
|
|
projectFilePath: string;
|
|
applicationPath?: string;
|
|
exportCommentsToXml?: boolean;
|
|
supportOpcUa?: boolean;
|
|
layoutCalculator?: 'compatibility' | 'optimized';
|
|
}) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'create_symbol_config',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
APPLICATION_PATH: (args.applicationPath ?? '').trim(),
|
|
EXPORT_COMMENTS_TO_XML: args.exportCommentsToXml === false ? '0' : '1',
|
|
SUPPORT_OPC_UA: args.supportOpcUa === false ? '0' : '1',
|
|
LAYOUT_CALCULATOR: args.layoutCalculator ?? 'compatibility',
|
|
},
|
|
[...SYMCONF_HELPERS, 'find_object_by_path']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return await formatModifyingResponse(
|
|
result,
|
|
`Symbol Configuration ensured under '${args.applicationPath ?? '<active application>'}' in ${args.projectFilePath}.`,
|
|
escaped,
|
|
mirrorCtx
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'set_symbol_config_settings',
|
|
"Partial-update of any subset of Symbol Configuration knobs. Only fields you supply are written; others are left alone. Saves the project after applying changes. Refuses to enable direct I/O access if check_effective_direct_io_access reports obstacles.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
contentFeatureFlags: z.array(z.string()).optional().describe("Bitmask members to combine into content_feature_flags. Allowed members: SupportOPCUA, IncludeComments, IncludeAttributes, IncludeTypeNodeAttributes, IncludeExecutables, UseEmptyNamespaceByDefault, XmlIncludeNodeFlags, XmlIncludeComments, XmlIncludeAttributes, XmlIncludeTypeNodeAttributes, XmlIncludeExecutables. Pass [] or omit to leave unchanged. Pass ['None'] to clear all flags."),
|
|
attributeFilterType: z.enum(['None', 'All', 'SimpleIdentifiers', 'Prefix', 'Regex']).optional().describe("symbol_attribute_filter_type. None disables, All matches every attribute, SimpleIdentifiers matches single IEC identifiers, Prefix/Regex use attributeFilterData."),
|
|
attributeFilterData: z.string().optional().describe("Filter pattern for attributeFilterType=Prefix or Regex. Ignored for None/All/SimpleIdentifiers."),
|
|
commentFilterType: z.enum(['None', 'NormalComments', 'DocuComments', 'Both', 'PreferNormalComments', 'PreferDocuComments']).optional().describe("symbol_comment_filter_type. NormalComments = // and (* *) only. DocuComments = /// only. Both = combine. Prefer* picks one with the other as fallback."),
|
|
enableDirectIoAccess: z.boolean().optional().describe("enable_direct_io_access. Refused if obstacles exist (compiler version too old, or symbol config is configured as a child object)."),
|
|
layoutCalculator: z.enum(['compatibility', 'optimized']).optional().describe("Which client-side layout calculator GUID to set."),
|
|
},
|
|
async (args: {
|
|
projectFilePath: string;
|
|
contentFeatureFlags?: string[];
|
|
attributeFilterType?: 'None' | 'All' | 'SimpleIdentifiers' | 'Prefix' | 'Regex';
|
|
attributeFilterData?: string;
|
|
commentFilterType?: 'None' | 'NormalComments' | 'DocuComments' | 'Both' | 'PreferNormalComments' | 'PreferDocuComments';
|
|
enableDirectIoAccess?: boolean;
|
|
layoutCalculator?: 'compatibility' | 'optimized';
|
|
}) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
|
|
const cffInt = combineContentFeatureFlags(args.contentFeatureFlags);
|
|
const applyContentFlags = cffInt !== null;
|
|
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'set_symbol_config_settings',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
APPLY_CONTENT_FLAGS: applyContentFlags ? '1' : '0',
|
|
CONTENT_FLAGS_INT: applyContentFlags ? String(cffInt) : '0',
|
|
APPLY_ATTR_FILTER_TYPE: args.attributeFilterType !== undefined ? '1' : '0',
|
|
ATTR_FILTER_TYPE: args.attributeFilterType ?? 'None',
|
|
APPLY_ATTR_FILTER_DATA: args.attributeFilterData !== undefined ? '1' : '0',
|
|
ATTR_FILTER_DATA: args.attributeFilterData ?? '',
|
|
APPLY_COMMENT_FILTER_TYPE: args.commentFilterType !== undefined ? '1' : '0',
|
|
COMMENT_FILTER_TYPE: args.commentFilterType ?? 'None',
|
|
APPLY_DIRECT_IO: args.enableDirectIoAccess !== undefined ? '1' : '0',
|
|
DIRECT_IO: args.enableDirectIoAccess ? '1' : '0',
|
|
APPLY_LAYOUT: args.layoutCalculator !== undefined ? '1' : '0',
|
|
LAYOUT_CALCULATOR: args.layoutCalculator ?? 'compatibility',
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return await formatModifyingResponse(
|
|
result,
|
|
`Symbol Configuration settings updated for ${args.projectFilePath}.`,
|
|
escaped,
|
|
mirrorCtx
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'set_symbol_access',
|
|
"Set the configured_access for a single variable inside one signature. Locates the signature by full-qualified name (FQN), e.g. 'Application.PLC_PRG'. If the signature isn't yet in the configured set the tool tries the all-signatures view too -- so you can use this to TICK a not-yet-exported variable. Saves the project.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
signatureFqn: z.string().describe("Full-qualified name of the signature, e.g. 'Application.PLC_PRG' or 'Standard.TON'."),
|
|
variableName: z.string().describe("The variable's name as it appears in sig.variables, e.g. 'nCounter'."),
|
|
access: z.enum(['None', 'ReadOnly', 'WriteOnly', 'ReadWrite']).describe("Desired access. None=hide. ReadOnly=expose for read. WriteOnly=expose for write. ReadWrite=both."),
|
|
libraryId: z.string().optional().describe("Optional library_id to disambiguate when the FQN exists in multiple namespaces."),
|
|
},
|
|
async (args: {
|
|
projectFilePath: string;
|
|
signatureFqn: string;
|
|
variableName: string;
|
|
access: 'None' | 'ReadOnly' | 'WriteOnly' | 'ReadWrite';
|
|
libraryId?: string;
|
|
}) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'set_symbol_access',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
SIGNATURE_FQN: args.signatureFqn,
|
|
VARIABLE_NAME: args.variableName,
|
|
ACCESS: args.access,
|
|
LIBRARY_ID: args.libraryId ?? '',
|
|
ENSURE_CONFIGURED: '1',
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return await formatModifyingResponse(
|
|
result,
|
|
`Symbol access set: ${args.signatureFqn}.${args.variableName} = ${args.access}.`,
|
|
escaped,
|
|
mirrorCtx
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'set_signature_access_bulk',
|
|
"Set configured_access for EVERY variable inside a signature to the same value. Variables whose maximal_access doesn't permit the requested level are skipped (reported in the response). Useful for 'expose all of PLC_PRG as ReadWrite' in one shot.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
signatureFqn: z.string().describe("Full-qualified name of the signature, e.g. 'Application.PLC_PRG'."),
|
|
access: z.enum(['None', 'ReadOnly', 'WriteOnly', 'ReadWrite']).describe("Access level to apply to every variable."),
|
|
libraryId: z.string().optional().describe("Optional library_id to disambiguate."),
|
|
},
|
|
async (args: {
|
|
projectFilePath: string;
|
|
signatureFqn: string;
|
|
access: 'None' | 'ReadOnly' | 'WriteOnly' | 'ReadWrite';
|
|
libraryId?: string;
|
|
}) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'set_signature_access_bulk',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
SIGNATURE_FQN: args.signatureFqn,
|
|
ACCESS: args.access,
|
|
LIBRARY_ID: args.libraryId ?? '',
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return await formatModifyingResponse(
|
|
result,
|
|
`Signature ${args.signatureFqn} bulk access set to ${args.access}.`,
|
|
escaped,
|
|
mirrorCtx
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'export_symbol_xsd',
|
|
"Write the Symbol Configuration XSD schema (the bytes returned by get_symbol_configuration_xsd()) to a file. The schema describes the XML the runtime emits at download. Use it to validate downstream symbol XML in CI. Refuses if the parent directory of outputFilePath doesn't exist.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
outputFilePath: z.string().describe("Where to write the XSD bytes (UTF-8). Parent directory must exist."),
|
|
},
|
|
async (args: { projectFilePath: string; outputFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const outEscaped = resolvePath(args.outputFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'export_symbol_xsd',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
OUTPUT_FILE_PATH: outEscaped,
|
|
},
|
|
SYMCONF_HELPERS
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Symbol Configuration XSD written to ${args.outputFilePath}.`
|
|
);
|
|
}
|
|
);
|
|
|
|
// ─── 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.
|
|
// Resolve the mirror dir via the same rule mirror_export.py uses, so
|
|
// multi-project parent dirs land in <basename>_mcp_mirror/ and we
|
|
// SHA-fingerprint / classify / git-add the right tree (legacy single-
|
|
// project layouts continue to use mcp-mirror/, preserving v* tag
|
|
// history).
|
|
const mirrorDir = resolveMirrorRoot(escaped);
|
|
const mirrorDirName = path.basename(mirrorDir);
|
|
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: mirrorDir },
|
|
['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: ${mirrorDirName}/ 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, mirrorDirName);
|
|
// 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 -- ${mirrorDirName}/ 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: mirrorDir },
|
|
['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 = [mirrorDirName, '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,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'restart_runtime_ssh',
|
|
"Restart the CODESYS Control runtime on a Linux PLC (default: codesys-pi.local) via SSH. Uses password auth and feeds the sudo password to `sudo -S`, so it works in environments where pubkey auth is broken (e.g. Pi sshd 10.x signature-rejection bug) or sudoers NOPASSWD isn't configured. After issuing `systemctl restart`, polls `ss -tln` for the runtime's listen port (default 11740) until it comes up or the liveness window expires -- this is the real liveness signal, since `systemctl is-active` reports 'active' even after the runtime binary has died from license-demo expiry. Defaults match the only Pi we currently target (codesys-pi.local / karstein / codesys123 / service codesyscontrol); every field is overridable for other deployments.",
|
|
{
|
|
host: z.string().optional().describe('Hostname or IP of the PLC. Default: codesys-pi.local.'),
|
|
port: z.number().optional().describe('SSH port. Default: 22.'),
|
|
user: z.string().optional().describe('SSH user. Default: karstein.'),
|
|
password: z.string().optional().describe('SSH password. Default: codesys123. Also used as the sudo password if sudoPassword is omitted.'),
|
|
sudoPassword: z.string().optional().describe('Override the sudo password if it differs from the SSH password.'),
|
|
service: z.string().optional().describe('systemd unit to restart. Default: codesyscontrol.'),
|
|
livenessWaitSeconds: z.number().optional().describe('How long to poll for the runtime listen port to come up after restart. 0 disables the check. Default: 30.'),
|
|
livenessPort: z.number().optional().describe('TCP port to probe after restart. Default: 11740 (CODESYS gateway).'),
|
|
connectTimeoutMs: z.number().optional().describe('SSH connect/exec timeout per attempt. Default: 15000.'),
|
|
},
|
|
async (args: {
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
password?: string;
|
|
sudoPassword?: string;
|
|
service?: string;
|
|
livenessWaitSeconds?: number;
|
|
livenessPort?: number;
|
|
connectTimeoutMs?: number;
|
|
}) => {
|
|
try {
|
|
const res = await restartCodesysRuntime(args);
|
|
const ok = res.restartExitCode === 0 && (res.listening === true || res.listening === null);
|
|
return {
|
|
content: [{ type: 'text' as const, text: formatRestartRuntimeResult(res) }],
|
|
isError: !ok,
|
|
};
|
|
} 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' (or '<projectDir>/<projectname>_mcp_mirror' when several .project files share the parent dir).",
|
|
{
|
|
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' (or '<projectDir>/<projectname>_mcp_mirror' when several .project files share the parent dir). 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)
|
|
: resolveMirrorRoot(escaped);
|
|
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}`);
|
|
});
|
|
}
|