Calls primary_project.get_compilerversion() (ScriptEngine 4.2.0.0+) and
emits the result through the JSON payload. Renders as:
- library.md: a 'Project compiler version' row in the Versions table
- list_project_libraries chat output: a 'Compiler version: X.Y.Z.W'
line in the header section
Motivation: changing the project's compiler version (Project > Project
Settings > Compiler version, or set_compilerversion_to_newest()) only
touched the .project binary -- mirror_export couldn't see it, so the
release classifier had to fall back to SHA comparison and emitted the
generic 'device-tree / library refs / task config / visu / Save() touch'
classification. Compiler-version changes now leave a textual diff in
mcp-mirror/library.md, letting the classifier issue an honest revision
bump instead of the bare build-bump SHA fallback.
Defensive: get_compilerversion() is wrapped in try/except so older
ScriptEngines (< 4.2.0.0) that lack the method don't crash the tool;
they just emit compiler_version=null and the field is omitted from
output.
2498 lines
113 KiB
TypeScript
2498 lines
113 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';
|
|
|
|
/**
|
|
* 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-migration-notes`](https://github.com/phobicdotno/Codesys-MCP/tree/sp21-plus-migration-notes) 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-migration-notes\`](https://github.com/phobicdotno/Codesys-MCP/tree/sp21-plus-migration-notes) fork.`);
|
|
L.push('');
|
|
L.push(`**Total:** ${pou.length} objects with textual code.`);
|
|
L.push('');
|
|
L.push('## Index');
|
|
L.push('');
|
|
for (let i = 0; i < pou.length; i++) L.push(`- [${pou[i].path}](#${slugs[i]})`);
|
|
L.push(''); L.push('---'); L.push('');
|
|
for (const e of pou) {
|
|
L.push(`## ${e.path}`);
|
|
L.push('');
|
|
if (e.declaration) { L.push('### Declaration'); L.push('```iecst'); L.push(e.declaration.replace(/\r\n/g, '\n').trimEnd()); L.push('```'); L.push(''); }
|
|
if (e.implementation) { L.push('### Implementation'); L.push('```iecst'); L.push(e.implementation.replace(/\r\n/g, '\n').trimEnd()); L.push('```'); L.push(''); }
|
|
L.push('');
|
|
}
|
|
return L.join('\n');
|
|
}
|
|
|
|
function classifyMcpMirrorChanges(projectDir: string): ClassifyResult {
|
|
const evidence: string[] = [];
|
|
|
|
const isGit = (() => {
|
|
try {
|
|
return (
|
|
execSync(`git -C "${projectDir}" rev-parse --is-inside-work-tree`, {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim() === 'true'
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
if (!isGit) {
|
|
evidence.push(`'${projectDir}' is not a git repo -- can't classify, treating as first-run`);
|
|
return { kind: 'first-run', evidence };
|
|
}
|
|
|
|
let baseRef = '';
|
|
try {
|
|
baseRef = execSync(
|
|
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
).trim();
|
|
} catch {
|
|
evidence.push('no v* tag found -- first-run');
|
|
return { kind: 'first-run', evidence };
|
|
}
|
|
evidence.push(`baseline: tag ${baseRef}`);
|
|
|
|
let raw = '';
|
|
try {
|
|
// --ignore-cr-at-eol: ignore CRLF<->LF normalisation noise. CODESYS
|
|
// lives on Windows, the share lives on Linux/Samba, and git
|
|
// autocrlf settings can flip line endings on every checkout. Without
|
|
// this flag the classifier reported every .st file as M after a fresh
|
|
// checkout even though the content was identical, triggering a
|
|
// phantom release on X33 (commit 6c23e38, reverted in 3e6f12f).
|
|
// -w: also ignore whitespace-only changes (defensive; phantom releases
|
|
// shouldn't fire on a stray blank line either).
|
|
raw = execSync(
|
|
`git -C "${projectDir}" diff --name-status --ignore-cr-at-eol -w -M50% ${baseRef} -- mcp-mirror/`,
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
);
|
|
} catch {
|
|
evidence.push(`git diff against ${baseRef} failed -- treating as no-changes`);
|
|
return { kind: 'no-changes', evidence };
|
|
}
|
|
|
|
// git diff only reports TRACKED changes. New files that mirror_export just
|
|
// wrote are untracked from git's POV until added, and would otherwise be
|
|
// invisible to the classifier (they wouldn't trigger a 'minor' bump even
|
|
// though they're new public symbols). Pull them in via ls-files --others.
|
|
// Surfaced on MCPTest2 today: adding FB_Position + FB_Random5s via
|
|
// create_pou caused the classifier to see only 1 modified file (PLC_PRG)
|
|
// and classify as 'revision' instead of 'minor'. The added FBs were
|
|
// untracked at classify time.
|
|
let untracked = '';
|
|
try {
|
|
untracked = execSync(
|
|
`git -C "${projectDir}" ls-files --others --exclude-standard -- mcp-mirror/`,
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
);
|
|
} catch {
|
|
// ls-files failure shouldn't block classification on tracked diff alone
|
|
}
|
|
|
|
if (!raw.trim() && !untracked.trim()) {
|
|
evidence.push('no changes in mcp-mirror/ since baseline');
|
|
return { kind: 'no-changes', evidence };
|
|
}
|
|
|
|
let hasDelete = false;
|
|
let hasRename = false;
|
|
let hasAdd = false;
|
|
let hasModify = false;
|
|
for (const line of raw.split('\n').filter((l) => l.trim())) {
|
|
const status = line[0];
|
|
const tab = line.indexOf('\t');
|
|
const rest = tab >= 0 ? line.substring(tab + 1) : '';
|
|
if (status === 'D') {
|
|
hasDelete = true;
|
|
evidence.push(`deleted: ${rest}`);
|
|
} else if (status === 'R') {
|
|
hasRename = true;
|
|
evidence.push(`renamed: ${rest}`);
|
|
} else if (status === 'A') {
|
|
hasAdd = true;
|
|
evidence.push(`added: ${rest}`);
|
|
} else if (status === 'M') {
|
|
hasModify = true;
|
|
evidence.push(`modified: ${rest}`);
|
|
}
|
|
}
|
|
for (const line of untracked.split('\n').filter((l) => l.trim())) {
|
|
hasAdd = true;
|
|
evidence.push(`added (untracked): ${line}`);
|
|
}
|
|
|
|
if (hasDelete || hasRename) return { kind: 'bump', level: 'major', evidence };
|
|
if (hasAdd) return { kind: 'bump', level: 'minor', evidence };
|
|
if (hasModify) return { kind: 'bump', level: 'revision', evidence };
|
|
return { kind: 'no-changes', evidence };
|
|
}
|
|
|
|
/**
|
|
* IEC 61131-3 identifiers that are reserved for time-literal suffixes or
|
|
* standard-block I/O conventions. Using these as variable names produces
|
|
* red-underlined warnings or compile errors in CODESYS.
|
|
*
|
|
* s/t/d/m/h/ms/us/ns -> time-literal suffixes (T#5s, T#100ms, etc.)
|
|
* S/R -> SR/RS flip-flop input names
|
|
*
|
|
* The set is lowercased separately from the original casing -- we check
|
|
* exact-match (case-sensitive) so we catch both 's' and 'S' separately.
|
|
*/
|
|
const RESERVED_IEC_IDENTIFIERS = new Set([
|
|
's', 't', 'd', 'm', 'h', 'ms', 'us', 'ns',
|
|
'S', 'R',
|
|
]);
|
|
|
|
/**
|
|
* Scan an IEC declarationCode block for VAR declarations whose variable
|
|
* name collides with a reserved identifier. Returns one warning string
|
|
* per offending name. Empty list if the input is empty/safe.
|
|
*
|
|
* Pattern matches lines of the form `<name> : <type>` and is line-anchored
|
|
* so it ignores struct member access (`fb.s`) and similar non-declarations.
|
|
* Catches the first name in each line; multi-name lists like
|
|
* `s, t : BOOL;` only catch the last comma-separated name (rare but
|
|
* worth a future tightening).
|
|
*/
|
|
function findReservedIecIdentifiers(declarationCode: string | undefined): string[] {
|
|
if (!declarationCode) return [];
|
|
const warnings: string[] = [];
|
|
const seen = new Set<string>();
|
|
const pattern = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Za-z_]/gm;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(declarationCode)) !== null) {
|
|
const name = match[1];
|
|
if (RESERVED_IEC_IDENTIFIERS.has(name) && !seen.has(name)) {
|
|
seen.add(name);
|
|
warnings.push(
|
|
`Reserved IEC identifier '${name}' used as variable name. ` +
|
|
`Single-letter names like s/t/d/m/h/ms/us/ns are time-literal suffixes (T#5s, T#100ms); ` +
|
|
`S/R conflict with SR/RS flip-flop semantics. ` +
|
|
`Rename to a meaningful identifier (e.g. '${name}Inst', '${name}Sample', or use a Hungarian-style prefix like 'st'/'fb'/'b'/'n').`
|
|
);
|
|
}
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
// Zod enums for POU tools
|
|
const PouTypeEnum = z.enum(['Program', 'FunctionBlock', 'Function']);
|
|
const ImplementationLanguageEnum = z.enum([
|
|
'ST', 'LD', 'FBD', 'SFC', 'IL', 'CFC',
|
|
'StructuredText', 'LadderDiagram', 'FunctionBlockDiagram',
|
|
'SequentialFunctionChart', 'InstructionList', 'ContinuousFunctionChart',
|
|
]);
|
|
|
|
/** Resolve a file path to an absolute normalized path */
|
|
function resolvePath(filePath: string, workspaceDir: string): string {
|
|
return path.normalize(
|
|
path.isAbsolute(filePath) ? filePath : path.join(workspaceDir, filePath)
|
|
);
|
|
}
|
|
|
|
/** Sanitize a POU path (forward slashes, no leading/trailing slashes) */
|
|
function sanitizePouPath(pouPath: string): string {
|
|
return pouPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
/** Format an IpcResult into an MCP tool response */
|
|
function formatToolResponse(
|
|
result: IpcResult,
|
|
successMessage: string
|
|
): { content: Array<{ type: 'text'; text: string }>; isError: boolean } {
|
|
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: success
|
|
? successMessage
|
|
: `Operation failed. Output:\n${result.output}${result.error ? '\nError: ' + result.error : ''}`,
|
|
},
|
|
],
|
|
isError: !success,
|
|
};
|
|
}
|
|
|
|
/** Check if a file exists (async) */
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
fs.statSync(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|
// Set log level
|
|
if (config.debug) setLogLevel('debug');
|
|
else if (config.verbose) setLogLevel('info');
|
|
|
|
serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`);
|
|
serverLog.info(`Mode: ${config.mode}`);
|
|
serverLog.info(`CODESYS Path: ${config.codesysPath}`);
|
|
serverLog.info(`Profile: ${config.profileName}`);
|
|
serverLog.info(`Workspace: ${config.workspaceDir}`);
|
|
|
|
// Validate CODESYS path
|
|
if (!fs.existsSync(config.codesysPath)) {
|
|
throw new Error(`CODESYS executable not found: ${config.codesysPath}`);
|
|
}
|
|
|
|
// Initialize executor based on mode
|
|
let executor: ScriptExecutor;
|
|
let launcher: CodesysLauncher | null = null;
|
|
let executionMode: ExecutionMode = config.mode;
|
|
|
|
if (config.mode === 'persistent') {
|
|
launcher = new CodesysLauncher(config);
|
|
|
|
if (config.autoLaunch) {
|
|
try {
|
|
await launcher.launch();
|
|
executor = launcher;
|
|
} catch (err) {
|
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
serverLog.error(`Persistent launch failed: ${errMsg}`);
|
|
if (config.fallbackHeadless) {
|
|
serverLog.warn('Falling back to headless mode');
|
|
executor = new HeadlessExecutor(config);
|
|
executionMode = 'headless';
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
} else {
|
|
// Launcher exists but not yet launched — will use headless until manually launched
|
|
executor = new HeadlessExecutor(config);
|
|
executionMode = 'headless';
|
|
}
|
|
} else {
|
|
executor = new HeadlessExecutor(config);
|
|
}
|
|
|
|
const scriptManager = new ScriptManager();
|
|
const workspaceDir = config.workspaceDir;
|
|
|
|
// Create MCP server
|
|
const server = new McpServer(
|
|
{
|
|
name: 'CODESYS Persistent MCP Server',
|
|
version: '0.1.0',
|
|
},
|
|
{
|
|
capabilities: {
|
|
resources: { listChanged: true },
|
|
tools: { listChanged: true },
|
|
},
|
|
}
|
|
);
|
|
|
|
// Note: using 'as any' cast on server for tool() calls to work around
|
|
// TS2589 deep type instantiation with MCP SDK generics + Zod.
|
|
const s = server as any;
|
|
|
|
// ─── Management Tools ────────────────────────────────────────────────
|
|
|
|
s.tool(
|
|
'launch_codesys',
|
|
'Manually launch CODESYS with UI. Use when --no-auto-launch was set.',
|
|
async () => {
|
|
if (!launcher) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'Persistent mode not configured. Use --mode persistent.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
try {
|
|
await launcher.launch();
|
|
executor = launcher;
|
|
executionMode = 'persistent';
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'CODESYS launched successfully in persistent mode.' }],
|
|
isError: false,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Launch failed: ${msg}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'shutdown_codesys',
|
|
'Shut down the persistent CODESYS instance.',
|
|
async () => {
|
|
if (!launcher) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'No persistent CODESYS instance to shut down.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
try {
|
|
await launcher.shutdown();
|
|
executor = new HeadlessExecutor(config);
|
|
executionMode = 'headless';
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'CODESYS shut down successfully.' }],
|
|
isError: false,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Shutdown failed: ${msg}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'get_codesys_status',
|
|
'Get the current status of the CODESYS instance (state, PID, mode).',
|
|
async () => {
|
|
const status = launcher ? launcher.getStatus() : {
|
|
state: 'stopped',
|
|
pid: null,
|
|
sessionId: null,
|
|
ipcDir: null,
|
|
startedAt: null,
|
|
lastError: null,
|
|
};
|
|
const text = [
|
|
`State: ${status.state}`,
|
|
`Mode: ${executionMode}`,
|
|
`PID: ${status.pid ?? 'N/A'}`,
|
|
`Session: ${status.sessionId ?? 'N/A'}`,
|
|
`Started: ${status.startedAt ? new Date(status.startedAt).toISOString() : 'N/A'}`,
|
|
status.lastError ? `Last Error: ${status.lastError}` : null,
|
|
].filter(Boolean).join('\n');
|
|
return {
|
|
content: [{ type: 'text' as const, text }],
|
|
isError: false,
|
|
};
|
|
}
|
|
);
|
|
|
|
// ─── Project Tools ───────────────────────────────────────────────────
|
|
|
|
s.tool(
|
|
'open_project',
|
|
'Opens an existing CODESYS project file.',
|
|
{
|
|
filePath: z.string().describe("Path to the project file (e.g., 'C:/Projects/MyPLC.project')."),
|
|
},
|
|
async (args: { filePath: string }) => {
|
|
const escaped = resolvePath(args.filePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'open_project', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `Project opened: ${args.filePath}`);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(result, `Project created from template: ${absPath}`);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(result, `Project saved: ${args.projectFilePath}`);
|
|
}
|
|
);
|
|
|
|
// ─── 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 formatToolResponse(
|
|
result,
|
|
`POU '${args.name}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'set_pou_code',
|
|
'Sets the declaration and/or implementation code for a specific POU, Method, or Property.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
pouPath: z.string().describe("Full relative path to the target object (e.g., 'Application/MyPOU')."),
|
|
declarationCode: z.string().optional().describe("Code for the declaration part (VAR...END_VAR). If omitted, not changed."),
|
|
implementationCode: z.string().optional().describe("Code for the implementation logic. If omitted, not changed."),
|
|
},
|
|
async (args: { projectFilePath: string; pouPath: string; declarationCode?: string; implementationCode?: string }) => {
|
|
if (args.declarationCode === undefined && args.implementationCode === undefined) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'Error: At least one of declarationCode or implementationCode must be provided.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
// Block on IEC reserved identifiers in declarationCode BEFORE
|
|
// touching the project. Better to refuse than to half-set then
|
|
// surface a soft warning the caller might miss.
|
|
const reservedWarnings = findReservedIecIdentifiers(args.declarationCode);
|
|
if (reservedWarnings.length > 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Refused: declarationCode contains IEC reserved identifier(s). Project NOT modified. Fix and retry.\n\n - ${reservedWarnings.join('\n - ')}`,
|
|
}],
|
|
isError: true,
|
|
};
|
|
}
|
|
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
|
|
const sanPouPath = sanitizePouPath(args.pouPath);
|
|
// Escape for triple-quoted Python strings
|
|
const sanDecl = (args.declarationCode ?? '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
|
|
const sanImpl = (args.implementationCode ?? '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
|
|
// Distinguish "argument provided" from "argument is empty string". An
|
|
// omitted declaration must NOT reach decl_obj.replace('') -- doing so
|
|
// wipes the POU's PROGRAM/VAR...END_VAR block, leaving an UNKNOWN POU.
|
|
const setDecl = args.declarationCode !== undefined ? 'True' : 'False';
|
|
const setImpl = args.implementationCode !== undefined ? 'True' : 'False';
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'set_pou_code',
|
|
{
|
|
PROJECT_FILE_PATH: escProjPath,
|
|
POU_FULL_PATH: sanPouPath,
|
|
DECLARATION_CONTENT: sanDecl,
|
|
IMPLEMENTATION_CONTENT: sanImpl,
|
|
SET_DECLARATION: setDecl,
|
|
SET_IMPLEMENTATION: setImpl,
|
|
},
|
|
['ensure_project_open', 'find_object_by_path']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Code set for '${sanPouPath}' in ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`Property '${args.propertyName}' created under '${sanParentPath}' in ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`Method '${args.methodName}' created under '${sanParentPath}' in ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`DUT '${args.name}' (${args.dutType}) created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`GVL '${args.name}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`Folder '${args.folderName}' created in '${sanParentPath}' of ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
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 formatToolResponse(
|
|
result,
|
|
`Object '${sanObjPath}' deleted from ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'rename_object',
|
|
'Renames a project object (POU, DUT, GVL, folder, etc.) in the CODESYS project.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
objectPath: z.string().describe("Full relative path to the object to rename (e.g., 'Application/MyPOU')."),
|
|
newName: z.string().describe("New name for the object (must be a valid IEC identifier)."),
|
|
},
|
|
async (args: { projectFilePath: string; objectPath: string; newName: string }) => {
|
|
const escProjPath = resolvePath(args.projectFilePath, workspaceDir);
|
|
const sanObjPath = sanitizePouPath(args.objectPath);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'rename_object',
|
|
{
|
|
PROJECT_FILE_PATH: escProjPath,
|
|
OBJECT_PATH: sanObjPath,
|
|
NEW_NAME: args.newName.trim(),
|
|
},
|
|
['ensure_project_open', 'find_object_by_path']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Object '${sanObjPath}' renamed to '${args.newName}' in ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'get_all_pou_code',
|
|
'Reads the declaration and implementation code of every POU/DUT/GVL in the project. Returns all code in a single response for bulk review.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'get_all_pou_code', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script, 120_000); // 120s for large projects
|
|
|
|
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
|
if (!success) {
|
|
return formatToolResponse(result, '');
|
|
}
|
|
|
|
// Parse the JSON output
|
|
const codeStartMarker = '### ALL_POU_CODE_START ###';
|
|
const codeEndMarker = '### ALL_POU_CODE_END ###';
|
|
const startIdx = result.output.indexOf(codeStartMarker);
|
|
const endIdx = result.output.indexOf(codeEndMarker);
|
|
|
|
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'Could not parse POU code output.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const jsonStr = result.output.substring(startIdx + codeStartMarker.length, endIdx).trim();
|
|
const allCode: Array<{ path: string; type: string; declaration?: string; implementation?: string }> = JSON.parse(jsonStr);
|
|
|
|
if (allCode.length === 0) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'No POUs with code found in the project.' }],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
// Format output
|
|
const sections = allCode.map((item) => {
|
|
let section = `\n=== ${item.path} (${item.type}) ===`;
|
|
if (item.declaration) {
|
|
section += `\n// ----- Declaration -----\n${item.declaration}`;
|
|
}
|
|
if (item.implementation) {
|
|
section += `\n// ----- Implementation -----\n${item.implementation}`;
|
|
}
|
|
return section;
|
|
});
|
|
|
|
return {
|
|
content: [{ type: 'text' as const, text: `${allCode.length} object(s) with code:\n${sections.join('\n')}` }],
|
|
isError: false,
|
|
};
|
|
} catch {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'Failed to parse POU code JSON.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// ─── Online/Runtime Tools ─────────────────────────────────────────────
|
|
|
|
s.tool(
|
|
'connect_to_device',
|
|
'Connects (logs in) to the PLC runtime for the active application. Requires a configured device/gateway in the project. The first connect to a password-protected runtime pops a credential dialog in CODESYS that the user must fill in -- the loginWaitSeconds parameter controls how long the script polls for state stabilisation while that dialog is up.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for the application state to stabilise after login() returns. Used to give the user time to fill in a credential dialog. Default: 60. Range 0-600."),
|
|
},
|
|
async (args: { projectFilePath: string; loginWaitSeconds?: number }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const waitSec = args.loginWaitSeconds ?? 60;
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'connect_to_device',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LOGIN_WAIT_SECONDS: String(waitSec),
|
|
},
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
// Tool-side timeout = wait window + 30s headroom for actual login work
|
|
const ipcTimeoutMs = (waitSec + 30) * 1000;
|
|
const result = await executor.executeScript(script, ipcTimeoutMs);
|
|
return formatToolResponse(result, `Connected to device for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'disconnect_from_device',
|
|
'Disconnects (logs out) from the PLC runtime.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'disconnect_from_device', { PROJECT_FILE_PATH: escaped },
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `Disconnected from device for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'get_application_state',
|
|
'Gets the current state of the PLC application (running, stopped, exception, etc.).',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'get_application_state', { PROJECT_FILE_PATH: escaped },
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
|
if (!success) {
|
|
return formatToolResponse(result, '');
|
|
}
|
|
|
|
// Parse state from output
|
|
const stateMatch = result.output.match(/State:\s*(.+)/);
|
|
const loggedInMatch = result.output.match(/Logged In:\s*(.+)/);
|
|
const appMatch = result.output.match(/Application:\s*(.+)/);
|
|
|
|
const text = [
|
|
`Application: ${appMatch ? appMatch[1].trim() : 'Unknown'}`,
|
|
`State: ${stateMatch ? stateMatch[1].trim() : 'Unknown'}`,
|
|
`Logged In: ${loggedInMatch ? loggedInMatch[1].trim() : 'Unknown'}`,
|
|
].join('\n');
|
|
|
|
return {
|
|
content: [{ type: 'text' as const, text }],
|
|
isError: false,
|
|
};
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'read_variable',
|
|
'Reads the current value of a variable from the running PLC application. Must be connected first.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
variablePath: z.string().describe("Variable path (e.g., 'PLC_PRG.bMotorRunning', 'GVL.nCounter')."),
|
|
},
|
|
async (args: { projectFilePath: string; variablePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'read_variable',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
VARIABLE_PATH: args.variablePath.trim(),
|
|
},
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
|
if (!success) {
|
|
return formatToolResponse(result, '');
|
|
}
|
|
|
|
const valueMatch = result.output.match(/Value:\s*(.+)/);
|
|
const typeMatch = result.output.match(/Type:\s*(.+)/);
|
|
const text = `${args.variablePath} = ${valueMatch ? valueMatch[1].trim() : 'N/A'} (${typeMatch ? typeMatch[1].trim() : 'unknown'})`;
|
|
|
|
return {
|
|
content: [{ type: 'text' as const, text }],
|
|
isError: false,
|
|
};
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'write_variable',
|
|
'Writes a value to a variable in the running PLC application. Must be connected first.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
variablePath: z.string().describe("Variable path (e.g., 'PLC_PRG.bMotorRunning')."),
|
|
value: z.string().describe("Value to write (e.g., 'TRUE', '42', '3.14')."),
|
|
},
|
|
async (args: { projectFilePath: string; variablePath: string; value: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'write_variable',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
VARIABLE_PATH: args.variablePath.trim(),
|
|
VARIABLE_VALUE: args.value,
|
|
},
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Variable '${args.variablePath}' set to '${args.value}'.`
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'download_to_device',
|
|
'Downloads the compiled application to the PLC device. Attempts online change first, falls back to full download. Same login-dialog handling as connect_to_device: loginWaitSeconds controls how long the script waits for state stabilisation if a credential dialog pops up.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for application state to stabilise after login() returns. Default: 60. Range 0-600."),
|
|
},
|
|
async (args: { projectFilePath: string; loginWaitSeconds?: number }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const waitSec = args.loginWaitSeconds ?? 60;
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'download_to_device',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LOGIN_WAIT_SECONDS: String(waitSec),
|
|
},
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
// Tool-side timeout = wait window + 120s headroom for the actual download
|
|
const ipcTimeoutMs = (waitSec + 120) * 1000;
|
|
const result = await executor.executeScript(script, ipcTimeoutMs);
|
|
return formatToolResponse(result, `Application downloaded to device for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'start_stop_application',
|
|
'Starts or stops the PLC application on the connected device.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
action: z.string().describe("Action to perform: 'start' or 'stop'."),
|
|
},
|
|
async (args: { projectFilePath: string; action: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'start_stop_application',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
APP_ACTION: args.action.trim(),
|
|
},
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Application ${args.action} executed for ${args.projectFilePath}.`
|
|
);
|
|
}
|
|
);
|
|
|
|
// ─── Git Tools (CODESYS Git plug-in via project.git) ──────────────────
|
|
|
|
s.tool(
|
|
'git_status',
|
|
"Reports the project's git status: current branch, plus a probe of any status/changes/diff methods exposed on project.git. Read-only. Requires the CODESYS Git plug-in (ships with CODESYS) AND an active CODESYS Professional Developer Edition subscription license -- without the subscription, every project.git.* operation is gated by the runtime 'HasGitLicense' rule and the tool fails fast with a clear PDE-required message. Also requires the project to be bound to a git working tree (use git_init if not). Diagnostic dump of project.git surface is included.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_status',
|
|
{ PROJECT_FILE_PATH: escaped },
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_status for ${args.projectFilePath} (see output for branch and probe results).`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'git_init',
|
|
"Initialises a Git working tree for the project via project.git.init(). CODESYS Git uses a dual-storage model: the .project file stays where it is, the git repo lives in a SEPARATE empty directory. If localRepoPath is omitted (or equals the project's own folder), the tool auto-defaults to a '<project_basename>_git' sibling and auto-creates it; if it exists and is non-empty, the tool fails with a clear hint. One-shot setup; use git_status afterwards to confirm. Requires the CODESYS Git plug-in AND an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message (the runtime 'HasGitLicense' rule gates every project.git.* call).",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
localRepoPath: z.string().optional().describe("Filesystem path for the git working tree. Must be a separate empty directory, NOT the project's own folder. If omitted, defaults to a '<project_basename>_git' sibling and auto-creates it."),
|
|
},
|
|
async (args: { projectFilePath: string; localRepoPath?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const repoPath = args.localRepoPath ? resolvePath(args.localRepoPath, workspaceDir) : '';
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_init',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LOCAL_REPO_PATH: repoPath,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_init complete for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'git_commit',
|
|
"Stages all working-tree changes and commits them via project.git.commit_complete(message, user, mail). Requires the project to already be bound to a git repo (use git_init first if needed) AND an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message (the runtime 'HasGitLicense' rule gates every project.git.* call).",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
message: z.string().min(1).describe("Commit message. Multiline OK."),
|
|
authorName: z.string().min(1).describe("Author name (used as the 'user' parameter to commit_complete)."),
|
|
authorEmail: z.string().email().describe("Author email."),
|
|
},
|
|
async (args: { projectFilePath: string; message: string; authorName: string; authorEmail: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
// Escape message for triple-quoted Python string injection
|
|
const safeMessage = args.message.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_commit',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
COMMIT_MESSAGE: safeMessage,
|
|
AUTHOR_NAME: args.authorName,
|
|
AUTHOR_EMAIL: args.authorEmail,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_commit complete for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'git_remote_add',
|
|
"Adds a named git remote to the project's repository via project.git.remote_add(name, url). One-shot. Pair with git_push afterwards. Requires the project to already be bound to a git repo (run git_init first if needed) AND an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message (the runtime 'HasGitLicense' rule gates every project.git.* call).",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
remoteName: z.string().min(1).describe("Remote name. Conventionally 'origin' for the primary upstream."),
|
|
remoteUrl: z.string().min(1).describe("Remote URL. HTTPS recommended (e.g. https://gitlab.usv.no/<user>/<repo>.git); SSH also accepted if the CODESYS process has key access."),
|
|
},
|
|
async (args: { projectFilePath: string; remoteName: string; remoteUrl: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_remote_add',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
REMOTE_NAME: args.remoteName,
|
|
REMOTE_URL: args.remoteUrl,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_remote_add complete for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'git_branch_set_upstream_to',
|
|
"Sets the upstream tracking ref of a local branch to a configured remote via project.git.branch_set_upstream_to(remoteName, branchName?). MANDATORY before the first push to a fresh remote -- per the helpme-codesys.com Git scripting docs (https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html), 'After adding a remote, use project.git.branch_set_upstream_to(origin_remote) before pushing'. Without this call, push() fails with 'The branch X does not track an upstream branch.' Requires an existing git binding on the project, a configured remote (use git_remote_add), and an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
remoteName: z.string().min(1).describe("Remote name to track. Conventionally 'origin'."),
|
|
branchName: z.string().optional().describe("Local branch to configure. Optional; defaults to the current branch."),
|
|
},
|
|
async (args: { projectFilePath: string; remoteName: string; branchName?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_branch_set_upstream_to',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
REMOTE_NAME: args.remoteName,
|
|
BRANCH_NAME: args.branchName || '',
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_branch_set_upstream_to complete for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'git_push',
|
|
"Pushes the local branch to a configured remote via project.git.push(). If username + token are both provided, uses the 3-arg overload push(branch, user, SecureString(token)) and derives the current branch when branchName is omitted; otherwise calls push(branch) or push() and relies on git config / Windows Credential Manager / cached credentials. SECURITY NOTE: when token is supplied, it is templated into the IronPython script that the watcher executes -- briefly resident in the watcher's command file on disk. Prefer cached credentials (omit token) when feasible. Requires an existing git binding on the project, a configured remote (use git_remote_add), and an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
branchName: z.string().optional().describe("Branch to push. Optional; derived from the current branch when token is supplied, or left to push()'s default upstream resolution otherwise."),
|
|
username: z.string().optional().describe("Optional HTTPS username. For GitLab personal access tokens any non-empty username works (commonly 'oauth2' or the GitLab username). Pair with token."),
|
|
token: z.string().optional().describe("Optional HTTPS password / personal access token. Sensitive -- prefer cached credentials when possible. If supplied, converted to System.Security.SecureString before being handed to project.git.push."),
|
|
},
|
|
async (args: { projectFilePath: string; branchName?: string; username?: string; token?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
// The script wraps these values inside double-quoted Python strings,
|
|
// so we need to neutralise backslashes and double-quotes. Newlines
|
|
// would also break the string -- reject them up front rather than
|
|
// attempting to escape, since legitimate auth values never contain them.
|
|
const sanitiseForPyDouble = (s: string | undefined, label: string): string => {
|
|
const v = s || '';
|
|
if (/\r|\n/.test(v)) throw new Error(`${label} must not contain newlines`);
|
|
return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
};
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'git_push',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
BRANCH_NAME: sanitiseForPyDouble(args.branchName, 'branchName'),
|
|
USERNAME: sanitiseForPyDouble(args.username, 'username'),
|
|
TOKEN: sanitiseForPyDouble(args.token, 'token'),
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(result, `git_push complete for ${args.projectFilePath}.`);
|
|
}
|
|
);
|
|
|
|
// ─── Library Management Tools ─────────────────────────────────────────
|
|
|
|
s.tool(
|
|
'list_project_libraries',
|
|
"Lists every library referenced anywhere in the CODESYS project AND captures project-level metadata above the library list: Project Information (version, title, company, author), CODESYS Development System version (from IronPython sys.version), and every device's offline target identification triple (type / id / version) -- the offline 'firmware' the project is built against. The library list itself walks the project tree, finds every ScriptLibManObjectContainer (the project + each Application), gets the Library Manager via container.get_library_manager(), and enumerates lm.references for structured per-reference info (name, namespace, system/placeholder/managed flags, effective resolution). Per the helpme-codesys.com docs and the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'list_project_libraries', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
|
if (!success) {
|
|
return formatToolResponse(result, '');
|
|
}
|
|
|
|
// Parse libraries JSON
|
|
const libStartMarker = '### LIBRARIES_START ###';
|
|
const libEndMarker = '### LIBRARIES_END ###';
|
|
const startIdx = result.output.indexOf(libStartMarker);
|
|
const endIdx = result.output.indexOf(libEndMarker);
|
|
|
|
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: 'Could not parse libraries output.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const jsonStr = result.output.substring(startIdx + libStartMarker.length, endIdx).trim();
|
|
type LibRef = {
|
|
id?: string;
|
|
name?: string;
|
|
namespace?: string;
|
|
is_placeholder?: boolean;
|
|
is_managed?: boolean;
|
|
system_library?: boolean;
|
|
qualified_only?: boolean;
|
|
optional?: boolean;
|
|
placeholder_name?: string;
|
|
effective_resolution?: string;
|
|
default_resolution?: string;
|
|
is_redirected?: boolean;
|
|
resolution_info?: string;
|
|
source?: string;
|
|
};
|
|
type Container = { container_name: string; libman_name: string; references: LibRef[] };
|
|
type ProjectInfo = { version?: string | null; title?: string | null; company?: string | null; author?: string | null };
|
|
type Device = { path: string; name?: string; device_id_type?: string; device_id_id?: string; device_id_version?: string };
|
|
const parsed: {
|
|
project?: string;
|
|
project_info?: ProjectInfo;
|
|
ide_version?: string;
|
|
compiler_version?: string | null;
|
|
devices?: Device[];
|
|
containers: Container[];
|
|
total_references: number;
|
|
} = JSON.parse(jsonStr);
|
|
|
|
if (!parsed.containers || parsed.containers.length === 0) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text:
|
|
'No library managers found in the project tree. ' +
|
|
'Either the project really has none, or the libman discovery failed -- ' +
|
|
'check the script DEBUG output for a tree dump.',
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
if (parsed.total_references === 0) {
|
|
const containerNames = parsed.containers.map((c) => c.container_name).join(', ');
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `Found ${parsed.containers.length} library manager(s) (${containerNames}) but 0 library references in any of them.`,
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
// Group output by container so the user can see which Application
|
|
// owns which libraries.
|
|
const sections: string[] = [];
|
|
for (const c of parsed.containers) {
|
|
const header = `${c.container_name} (libman: ${c.libman_name}) — ${c.references.length} reference(s)`;
|
|
const lines = c.references.map((ref) => {
|
|
const flags: string[] = [];
|
|
if (ref.system_library) flags.push('system');
|
|
if (ref.is_placeholder) flags.push('placeholder');
|
|
if (ref.is_managed) flags.push('managed');
|
|
if (ref.optional) flags.push('optional');
|
|
if (ref.is_redirected) flags.push('redirected');
|
|
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
const ns = ref.namespace ? ` ns=${ref.namespace}` : '';
|
|
const eff = ref.effective_resolution ? ` -> ${ref.effective_resolution}` : '';
|
|
return ` - ${ref.name ?? '?'}${flagStr}${ns}${eff}`;
|
|
});
|
|
sections.push(`${header}\n${lines.join('\n')}`);
|
|
}
|
|
|
|
// Header section: project version (from Project Information),
|
|
// CODESYS Development System version (from IronPython sys.version
|
|
// inside CODESYS), and every device's offline target id triple.
|
|
const headerLines: string[] = [];
|
|
const pi = parsed.project_info ?? {};
|
|
if (pi.version || pi.title || pi.company) {
|
|
headerLines.push('Project info:');
|
|
if (pi.version) headerLines.push(` Version: ${pi.version}`);
|
|
if (pi.title) headerLines.push(` Title: ${pi.title}`);
|
|
if (pi.company) headerLines.push(` Company: ${pi.company}`);
|
|
if (pi.author) headerLines.push(` Author: ${pi.author}`);
|
|
}
|
|
if (parsed.ide_version) {
|
|
headerLines.push(`IDE: ${parsed.ide_version.replace(/\s+/g, ' ').trim()}`);
|
|
}
|
|
if (parsed.compiler_version) {
|
|
headerLines.push(`Compiler version: ${parsed.compiler_version}`);
|
|
}
|
|
if (parsed.devices && parsed.devices.length > 0) {
|
|
headerLines.push(`Devices (${parsed.devices.length}):`);
|
|
for (const d of parsed.devices) {
|
|
const idStr = [d.device_id_type, d.device_id_id, d.device_id_version]
|
|
.filter(Boolean)
|
|
.join(' / ');
|
|
headerLines.push(` ${d.path}${idStr ? ' [' + idStr + ']' : ''}`);
|
|
}
|
|
}
|
|
|
|
const summary =
|
|
`Project: ${parsed.project ?? '?'} — ${parsed.total_references} library reference(s) across ${parsed.containers.length} container(s).`;
|
|
const blocks: string[] = [summary];
|
|
if (headerLines.length > 0) blocks.push(headerLines.join('\n'));
|
|
blocks.push(sections.join('\n\n'));
|
|
return {
|
|
content: [{ type: 'text' as const, text: blocks.join('\n\n') }],
|
|
isError: false,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `Failed to parse libraries JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'add_library',
|
|
'Adds a library reference to the CODESYS project. The library must be installed in the CODESYS library repository.',
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
libraryName: z.string().describe("Name of the library to add (e.g., 'Standard', 'Util', 'CAA Memory')."),
|
|
},
|
|
async (args: { projectFilePath: string; libraryName: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'add_library',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LIBRARY_NAME: args.libraryName.trim(),
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`Library '${args.libraryName}' added to ${args.projectFilePath}. Project saved.`
|
|
);
|
|
}
|
|
);
|
|
|
|
// ─── Project metadata ────────────────────────────────────────────────
|
|
|
|
s.tool(
|
|
'bump_project_version',
|
|
"Bumps one part of the 4-part Project Information.Version field of the primary project (Major.Minor.Revision.Build) and saves the project. Also maintains a `_MCP_PROJECT_VERSION` GVL under Application with the new version as `sVersion : STRING := '<X.Y.Z.W>'` so the running PLC carries the version at a known address (read it via the read_running_version_online tool). The GVL is created on first bump and updated in place thereafter. Convention: major = incompatible API break (rename FB / change public signature / remove method); minor = backward-compatible feature add (new FB / GVL / method); revision = bug fix only; build = internal counter, often 0 for hand-released versions. Bumping a higher part resets all lower parts to 0. FIRST-RUN: if no version is set yet (None/empty/0.0.0.0), seeds at 1.0.0.0 regardless of level so a first-time bump gives a canonical starting point instead of 0.0.0.1. AUTO MODE: if level='auto', the tool diffs the project's mcp-mirror/ folder against the latest v* git tag and classifies the change (deletion/rename -> major; addition -> minor; modification -> revision; no changes -> short-circuits with no bump; first-run -> seed at 1.0.0.0).",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
level: z.enum(['major', 'minor', 'revision', 'build', 'auto']).describe("Which part of the 4-part version to bump. Major = incompatible API break. Minor = backward-compatible feature add. Revision = bug fix only. Build = internal / CI counter. AUTO classifies via git diff of mcp-mirror/ against the latest v* tag."),
|
|
},
|
|
async (args: { projectFilePath: string; level: 'major' | 'minor' | 'revision' | 'build' | 'auto' }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
|
|
if (args.level === 'auto') {
|
|
const projectDir = path.dirname(escaped);
|
|
const r = classifyMcpMirrorChanges(projectDir);
|
|
|
|
// Short-circuit: nothing to bump.
|
|
if (r.kind === 'no-changes') {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text:
|
|
`bump_project_version (auto): no version change.\n\n` +
|
|
r.evidence.map((e) => ` - ${e}`).join('\n'),
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
// First-run resolves to 'build' on the Python side, where seed-at-1.0.0.0
|
|
// kicks in if Project Information.Version is None / empty / 0.0.0.0.
|
|
const resolvedLevel: 'major' | 'minor' | 'revision' | 'build' =
|
|
r.kind === 'first-run' ? 'build' : r.level;
|
|
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'bump_project_version',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LEVEL: resolvedLevel,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
// Append Changelog entry (soft-fails internally).
|
|
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
|
|
const { from, to } = parseBumpedVersion(result.output);
|
|
if (to) {
|
|
const levelLabel = r.kind === 'first-run' ? 'seed' : `auto: ${resolvedLevel}`;
|
|
appendChangelogEntry(projectDir, from, to, levelLabel, r.evidence);
|
|
}
|
|
}
|
|
|
|
const tag = r.kind === 'first-run' ? 'first-run -> seed' : `auto -> ${resolvedLevel}`;
|
|
return formatToolResponse(
|
|
result,
|
|
`bump_project_version (${tag}) complete for ${args.projectFilePath}.\n\nClassification:\n${r.evidence
|
|
.map((e) => ` - ${e}`)
|
|
.join('\n')}`
|
|
);
|
|
}
|
|
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'bump_project_version',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
LEVEL: args.level,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
// Append Changelog entry for manual bumps too.
|
|
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
|
|
const { from, to } = parseBumpedVersion(result.output);
|
|
if (to) {
|
|
const projectDir = path.dirname(escaped);
|
|
appendChangelogEntry(projectDir, from, to, `manual: ${args.level}`, []);
|
|
}
|
|
}
|
|
|
|
return formatToolResponse(
|
|
result,
|
|
`bump_project_version (${args.level}) complete for ${args.projectFilePath}.`
|
|
);
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'release_project_version',
|
|
"ONE-SHOT release pipeline. Runs the full sync from a CODESYS code change all the way to a tagged + pushed git commit, with no manual orchestration in between. Sequence: (1) mirror_export refreshes mcp-mirror/; (2) classifier diffs the new mirror against the latest v* tag; (3) if no changes, short-circuits with 'no version change'; (4) otherwise bump_project_version with the resolved level; (5) regenerate library.md as markdown; (6) regenerate pou-dump.md as markdown; (7) regex-replace the version reference in README.md; (8) Changelog.md auto-appended with the new entry (timestamp + classification evidence); (9) git add the controlled paths only (mcp-mirror, the four .md files, .gitignore, the .project binary); (10) git commit; (11) git tag v<new>; (12) git push --follow-tags. Standard workflow: ask Claude to run release_project_version after every confirmed change in CODESYS. Requires the project's parent dir to be a git repo with a configured remote.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
push: z.boolean().optional().describe("Push to origin after commit/tag. Default true."),
|
|
},
|
|
async (args: { projectFilePath: string; push?: boolean }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const projectDir = path.dirname(escaped);
|
|
const doPush = args.push !== false;
|
|
const log: string[] = [];
|
|
|
|
// 0. SHA fingerprints (BEFORE mirror_export overwrites the mirror).
|
|
// Two SHAs tracked per release in the v* tag's annotated body:
|
|
// - project-sha256 = sha of the .project binary
|
|
// - mirror-sha256 = sha of the mcp-mirror/ tree (sorted file walk)
|
|
// Comparing the current SHAs against the latest tag's stored SHAs lets
|
|
// us detect three classes of change:
|
|
// (a) binary changed AND mirror unchanged (working tree, before re-export)
|
|
// -> normal "user edited via IDE" path; classifier handles it.
|
|
// (b) binary unchanged AND mirror changed (working tree, before re-export)
|
|
// -> user edited mirror files DIRECTLY (text editor on .st files).
|
|
// The mirror_export below is about to overwrite those edits.
|
|
// Surface a WARNING so it's at least visible in the log.
|
|
// (Future: a mirror_import tool could push these back into the
|
|
// binary; until then, mirror is one-way.)
|
|
// (c) binary changed AND mirror UNCHANGED *after* re-export
|
|
// -> non-textual binary change (device tree / library refs / task
|
|
// config / visu / Save() touch). Classifier sees no diff but
|
|
// binary SHA flipped. Classify as build-level bump so the
|
|
// version still ticks and the change isn't silently dropped.
|
|
const mirrorDir = path.join(projectDir, 'mcp-mirror');
|
|
const projectShaNow = (() => {
|
|
try { return sha256OfFile(escaped); } catch { return ''; }
|
|
})();
|
|
const mirrorShaBeforeExport = sha256OfDirectory(mirrorDir);
|
|
let priorTag = '';
|
|
try {
|
|
priorTag = execSync(
|
|
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
).trim();
|
|
} catch { /* first-run; no prior tag */ }
|
|
const priorShas = readTagShas(projectDir, priorTag);
|
|
if (priorTag) {
|
|
log.push(`prior tag: ${priorTag} (project-sha256: ${priorShas.project ? priorShas.project.slice(0, 12) + '...' : '(none)'}, mirror-sha256: ${priorShas.mirror ? priorShas.mirror.slice(0, 12) + '...' : '(none)'})`);
|
|
}
|
|
// (b) detection: mirror was edited directly while binary stood still.
|
|
const mirrorEditedDirectly =
|
|
priorShas.mirror !== undefined &&
|
|
mirrorShaBeforeExport !== '' &&
|
|
mirrorShaBeforeExport !== priorShas.mirror &&
|
|
priorShas.project !== undefined &&
|
|
projectShaNow === priorShas.project;
|
|
|
|
// 1. Mirror export
|
|
const mirrorScript = scriptManager.prepareScriptWithHelpers(
|
|
'mirror_export',
|
|
{ PROJECT_FILE_PATH: escaped, MIRROR_ROOT: path.join(projectDir, 'mcp-mirror') },
|
|
['ensure_project_open']
|
|
);
|
|
const mirrorRes = await executor.executeScript(mirrorScript);
|
|
if (!mirrorRes.success || !mirrorRes.output.includes('SCRIPT_SUCCESS')) {
|
|
return formatToolResponse(mirrorRes, 'release_project_version: mirror_export failed');
|
|
}
|
|
log.push('mirror_export: OK');
|
|
if (mirrorEditedDirectly) {
|
|
log.push('WARNING: mcp-mirror/ tree differed from the last tag\'s mirror-sha256 BEFORE mirror_export ran, while the .project binary did not change. This usually means you edited .st files in the mirror directly. mirror_export has now overwritten those edits with the current binary state. (mirror_import is not yet implemented; for now, make code changes via the IDE or set_pou_code so they reach the binary first.)');
|
|
}
|
|
|
|
// 2. Classify mirror diff vs latest v* tag
|
|
let classification = classifyMcpMirrorChanges(projectDir);
|
|
// SHA fallback (case c): mirror diff is empty but the project binary
|
|
// SHA changed. Promote a 'no-changes' classification to a build-level
|
|
// bump so non-textual changes still tick the version.
|
|
let shaPromotedToBuild = false;
|
|
if (classification.kind === 'no-changes' && priorShas.project !== undefined && projectShaNow !== priorShas.project) {
|
|
shaPromotedToBuild = true;
|
|
const evidence = [
|
|
...classification.evidence,
|
|
`binary .project SHA changed (${priorShas.project.slice(0, 12)}... -> ${projectShaNow.slice(0, 12)}...) but no mirror diff: likely device-tree / library refs / task config / visu / Save() touch -- classifying as build bump`,
|
|
];
|
|
classification = { kind: 'bump', level: 'build', evidence };
|
|
}
|
|
if (classification.kind === 'no-changes') {
|
|
return {
|
|
content: [{ type: 'text' as const, text:
|
|
'release_project_version: no version change -- mcp-mirror/ matches latest v* tag and project-sha256 is unchanged.\n\n' +
|
|
classification.evidence.map((e) => ` - ${e}`).join('\n')
|
|
}],
|
|
isError: false,
|
|
};
|
|
}
|
|
const resolvedLevel = classification.kind === 'first-run' ? 'build' : classification.level;
|
|
const levelLabel = classification.kind === 'first-run' ? 'seed' : `auto: ${resolvedLevel}${shaPromotedToBuild ? ' (sha-fallback)' : ''}`;
|
|
log.push(`classifier: ${levelLabel} (${classification.evidence.length} evidence item(s))`);
|
|
|
|
// 3. Bump
|
|
const bumpScript = scriptManager.prepareScriptWithHelpers(
|
|
'bump_project_version',
|
|
{ PROJECT_FILE_PATH: escaped, LEVEL: resolvedLevel },
|
|
['ensure_project_open']
|
|
);
|
|
const bumpRes = await executor.executeScript(bumpScript);
|
|
if (!bumpRes.success || !bumpRes.output.includes('SCRIPT_SUCCESS')) {
|
|
return formatToolResponse(bumpRes, 'release_project_version: bump_project_version failed');
|
|
}
|
|
const { from, to: newVersion } = parseBumpedVersion(bumpRes.output);
|
|
if (!newVersion) {
|
|
return { content: [{ type: 'text' as const, text: 'release_project_version: bump succeeded but new version could not be parsed' }], isError: true };
|
|
}
|
|
log.push(`bump: ${from ?? '(none)'} -> ${newVersion}`);
|
|
|
|
// 3a. SANITY CHECK: the new version MUST be strictly greater than the
|
|
// latest v* tag. Defensive guard against silent version regressions
|
|
// caused by stale in-memory CODESYS state.
|
|
//
|
|
// The version values consumed by bump_project_version (Project
|
|
// Information.Version and _MCP_PROJECT_VERSION.sVersion) are read from
|
|
// CODESYS's in-memory project tree, NOT directly from the .project
|
|
// binary on disk. If the IDE has a stale tree from a prior session, the
|
|
// script's pi-vs-GVL cross-check fix can be defeated -- both sides come
|
|
// from the same stale source. Two observed regressions on the MCPTest2
|
|
// sandbox were undetected by the in-script check:
|
|
// - 2026-04-26 v1.0.4.0 (script saw 1.0.3.0, on-disk was 1.2.0.0)
|
|
// - 2026-04-26 v1.1.0.0 (script saw 1.0.0.0, on-disk was 1.2.1.0)
|
|
// In both cases the orchestrator went on to call git tag, which
|
|
// either failed (tag exists) or wrote a wrong-direction tag.
|
|
//
|
|
// This guard catches *any* cause of misread (in-memory drift, regex
|
|
// edge case, future fork bug we haven't seen yet) at the orchestrator
|
|
// boundary, before any git commit / tag / push lands. The .project
|
|
// binary on disk has already been written with the bad value at this
|
|
// point -- recovery is a manual step (shutdown + relaunch + retry, or
|
|
// explicit bump_project_version calls until > tag), but no permanent
|
|
// damage was published.
|
|
let latestTag = '';
|
|
try {
|
|
latestTag = execSync(
|
|
`git -C "${projectDir}" describe --tags --abbrev=0 --match "v*"`,
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
).trim();
|
|
} catch {
|
|
// No prior v* tag -- true first-run; skip the check.
|
|
}
|
|
if (latestTag) {
|
|
const tagV = latestTag.replace(/^v/, '');
|
|
const cmpVersion = (a: string, b: string): number => {
|
|
const A = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
const B = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
while (A.length < 4) A.push(0);
|
|
while (B.length < 4) B.push(0);
|
|
for (let i = 0; i < 4; i++) {
|
|
if (A[i] !== B[i]) return A[i] - B[i];
|
|
}
|
|
return 0;
|
|
};
|
|
if (cmpVersion(newVersion, tagV) <= 0) {
|
|
return {
|
|
content: [{ type: 'text' as const, text:
|
|
`release_project_version: SANITY CHECK FAILED -- new version ${newVersion} is not greater than the latest v* tag (${latestTag}).\n\n` +
|
|
`This usually means CODESYS's in-memory project tree was stale when bump_project_version ran -- the version values read by the script (Project Information.Version and/or _MCP_PROJECT_VERSION.sVersion) reflect a prior session's state, not the on-disk binary. The bump computed a value that would silently regress the version sequence.\n\n` +
|
|
`The .project binary on disk has been saved with the regressed value (${newVersion}) -- to recover:\n` +
|
|
` 1. shutdown_codesys -- clears the stale in-memory tree.\n` +
|
|
` 2. launch_codesys + open_project -- reload the binary fresh from disk.\n` +
|
|
` 3. Re-run release_project_version -- bump_project_version will now see the correct on-disk values.\n` +
|
|
` 4. If the issue persists after a fresh open, the binary itself has the wrong value (the recovery from a previous failed run wrote it). Run bump_project_version with explicit level=minor (or major/revision per your taste) repeatedly until the version exceeds ${tagV}, then re-run release_project_version.\n\n` +
|
|
`Pipeline state at abort:\n` +
|
|
log.map((l) => ` ${l}`).join('\n') + '\n' +
|
|
` bump (rejected): ${from ?? '(none)'} -> ${newVersion} (must be > ${tagV})\n\n` +
|
|
`No commit / no tag / no push made. Mirror, library.md, pou-dump.md, README.md, Changelog.md were NOT regenerated for the rejected version.`
|
|
}],
|
|
isError: true,
|
|
};
|
|
}
|
|
log.push(`sanity check: ${newVersion} > ${tagV} (latest tag) -- OK`);
|
|
} else {
|
|
log.push('sanity check: skipped (no v* tag baseline)');
|
|
}
|
|
|
|
// 3b. Re-run mirror_export AFTER the bump. The pre-bump mirror
|
|
// captured the old _MCP_PROJECT_VERSION.sVersion value and (when
|
|
// applicable) the old Project Information.Version. After the bump,
|
|
// those values changed in CODESYS in-memory and got saved to the
|
|
// .project binary. The mirror needs to reflect the post-bump state
|
|
// or the next 'release' call will see _MCP_PROJECT_VERSION.st as
|
|
// a real diff vs the just-tagged release and bump again. Surfaced
|
|
// on MCPTest2 v1.1.0.0 (commit 8d79193): the binary GVL was
|
|
// 1.0.2.0 while the docs said 1.1.0.0 because mirror_export
|
|
// didn't re-run after the bump.
|
|
try {
|
|
const mirrorScript2 = scriptManager.prepareScriptWithHelpers(
|
|
'mirror_export',
|
|
{ PROJECT_FILE_PATH: escaped, MIRROR_ROOT: path.join(projectDir, 'mcp-mirror') },
|
|
['ensure_project_open']
|
|
);
|
|
const mirror2 = await executor.executeScript(mirrorScript2);
|
|
if (mirror2.success && mirror2.output.includes('SCRIPT_SUCCESS')) {
|
|
log.push('mirror_export (post-bump): OK');
|
|
} else {
|
|
log.push('mirror_export (post-bump): WARNING -- post-bump mirror may be stale');
|
|
}
|
|
} catch (e) {
|
|
log.push(`mirror_export (post-bump): WARNING -- ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
|
|
// 4. Append Changelog (the manual-bump path doesn't auto-append; do it here)
|
|
appendChangelogEntry(projectDir, from, newVersion, levelLabel, classification.evidence);
|
|
log.push(`Changelog.md: appended v${newVersion}`);
|
|
|
|
// 5. Refresh library.md
|
|
try {
|
|
const libsScript = scriptManager.prepareScriptWithHelpers(
|
|
'list_project_libraries', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const libsRes = await executor.executeScript(libsScript);
|
|
const startMarker = '### LIBRARIES_START ###';
|
|
const endMarker = '### LIBRARIES_END ###';
|
|
const sIdx = libsRes.output.indexOf(startMarker);
|
|
const eIdx = libsRes.output.indexOf(endMarker);
|
|
if (sIdx >= 0 && eIdx > sIdx) {
|
|
const libs: LibrariesData = JSON.parse(libsRes.output.substring(sIdx + startMarker.length, eIdx).trim());
|
|
fs.writeFileSync(path.join(projectDir, 'library.md'), renderLibraryMd(libs, newVersion), 'utf-8');
|
|
log.push(`library.md: ${libs.total_references} refs`);
|
|
} else {
|
|
log.push('library.md: skipped (markers not found in output)');
|
|
}
|
|
} catch (e) {
|
|
log.push(`library.md: skipped (${e instanceof Error ? e.message : String(e)})`);
|
|
}
|
|
|
|
// 6. Refresh pou-dump.md
|
|
try {
|
|
const pouScript = scriptManager.prepareScriptWithHelpers(
|
|
'get_all_pou_code', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const pouRes = await executor.executeScript(pouScript);
|
|
const startMarker = '### ALL_POU_CODE_START ###';
|
|
const endMarker = '### ALL_POU_CODE_END ###';
|
|
const sIdx = pouRes.output.indexOf(startMarker);
|
|
const eIdx = pouRes.output.indexOf(endMarker);
|
|
if (sIdx >= 0 && eIdx > sIdx) {
|
|
const pou: PouEntry[] = JSON.parse(pouRes.output.substring(sIdx + startMarker.length, eIdx).trim());
|
|
const projName = path.basename(escaped, '.project');
|
|
fs.writeFileSync(path.join(projectDir, 'pou-dump.md'), renderPouDumpMd(pou, projName), 'utf-8');
|
|
log.push(`pou-dump.md: ${pou.length} POUs`);
|
|
} else {
|
|
log.push('pou-dump.md: skipped (markers not found in output)');
|
|
}
|
|
} catch (e) {
|
|
log.push(`pou-dump.md: skipped (${e instanceof Error ? e.message : String(e)})`);
|
|
}
|
|
|
|
// 7. Update README.md version header
|
|
const readmePath = path.join(projectDir, 'README.md');
|
|
if (fs.existsSync(readmePath)) {
|
|
try {
|
|
const before = fs.readFileSync(readmePath, 'utf-8');
|
|
const after = before.replace(/v\d+\.\d+\.\d+\.\d+/g, `v${newVersion}`);
|
|
if (after !== before) {
|
|
fs.writeFileSync(readmePath, after, 'utf-8');
|
|
log.push(`README.md: bumped to v${newVersion}`);
|
|
}
|
|
} catch (e) {
|
|
log.push(`README.md: skipped (${e instanceof Error ? e.message : String(e)})`);
|
|
}
|
|
}
|
|
|
|
// 8. Git add / commit / tag / push
|
|
try {
|
|
const projName = path.basename(escaped);
|
|
const candidatePaths = ['mcp-mirror', 'library.md', 'pou-dump.md', 'README.md', 'Changelog.md', '.gitignore', projName];
|
|
const addPaths = candidatePaths.filter((p) => fs.existsSync(path.join(projectDir, p)));
|
|
const addArgs = addPaths.map((p) => `"${p}"`).join(' ');
|
|
execSync(`git -C "${projectDir}" add ${addArgs}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
const summary = classification.evidence.slice(0, 5).join('; ').slice(0, 200);
|
|
const commitMsg = `release v${newVersion} (${levelLabel})\n\n${summary}\n`;
|
|
execSync(`git -C "${projectDir}" commit -m ${JSON.stringify(commitMsg)}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
// Compute the post-commit SHAs and embed them in the annotated tag.
|
|
// These represent the state at this released version: any future
|
|
// release_project_version call reads them via readTagShas() to
|
|
// detect whether the .project binary or the mirror tree has changed
|
|
// since this release. The post-bump mirror_export at step 3b
|
|
// refreshed the mirror to match the new GVL value, and the bump
|
|
// saved the binary; both reads here should reflect the v<new>
|
|
// post-bump state.
|
|
const newProjectSha = (() => {
|
|
try { return sha256OfFile(escaped); } catch { return ''; }
|
|
})();
|
|
const newMirrorSha = sha256OfDirectory(mirrorDir);
|
|
const tagBody =
|
|
`v${newVersion} (${levelLabel})\n\n` +
|
|
`project-sha256: ${newProjectSha}\n` +
|
|
`mirror-sha256: ${newMirrorSha}\n`;
|
|
// Write the tag body via -F <tempfile> so real LF newlines are
|
|
// preserved verbatim. Earlier versions used `-m JSON.stringify(body)`
|
|
// which the shell passed through with literal "\n" sequences,
|
|
// breaking the multiline regex in readTagShas() on the read side.
|
|
const tagBodyFile = path.join(os.tmpdir(), `codesys-mcp-tagbody-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.txt`);
|
|
fs.writeFileSync(tagBodyFile, tagBody, 'utf-8');
|
|
try {
|
|
execSync(`git -C "${projectDir}" tag -a v${newVersion} -F "${tagBodyFile}" --cleanup=verbatim`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} finally {
|
|
try { fs.unlinkSync(tagBodyFile); } catch { /* best-effort cleanup */ }
|
|
}
|
|
log.push(`git: committed + tagged v${newVersion} (project-sha256: ${newProjectSha.slice(0, 12)}..., mirror-sha256: ${newMirrorSha.slice(0, 12)}...)`);
|
|
|
|
if (doPush) {
|
|
execSync(`git -C "${projectDir}" push --follow-tags`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
log.push('git: pushed --follow-tags');
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
content: [{ type: 'text' as const, text:
|
|
`release_project_version: bumped to v${newVersion} but git ops failed.\n\n` +
|
|
log.map((l) => ` ${l}`).join('\n') +
|
|
`\n\ngit error: ${e instanceof Error ? e.message : String(e)}`
|
|
}],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: 'text' as const, text:
|
|
`release_project_version: v${from ?? '(none)'} -> v${newVersion} (${levelLabel})\n\n` +
|
|
log.map((l) => ` ${l}`).join('\n')
|
|
}],
|
|
isError: false,
|
|
};
|
|
}
|
|
);
|
|
|
|
s.tool(
|
|
'read_running_version_online',
|
|
"Reads the running project's version from a connected PLC over the CODESYS online protocol (port 11740 / gateway). Returns the value of `_MCP_PROJECT_VERSION.sVersion` -- the runtime anchor that bump_project_version maintains automatically. Use this to confirm what version the live PLC is actually running, independently of whatever's in the .project file or the mcp-mirror/. Provides actionable error messages when the GVL is missing (project never bumped) or the boot application is stale (downloaded before the last bump). Implementation: ensure_project_open + ensure_online_connection + read_value('_MCP_PROJECT_VERSION.sVersion').",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file. The tool opens it if not already primary, connects to its configured device, and reads the version anchor."),
|
|
},
|
|
async (args: { projectFilePath: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'read_running_version_online',
|
|
{ PROJECT_FILE_PATH: escaped },
|
|
['ensure_project_open', 'ensure_online_connection']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
// Pull the version out of the script's RUNNING_VERSION line
|
|
const match = /RUNNING_VERSION: (\S+)/.exec(result.output || '');
|
|
const version = match ? match[1] : '?';
|
|
return formatToolResponse(
|
|
result,
|
|
`Running version on PLC: ${version}\n(read from _MCP_PROJECT_VERSION.sVersion via CODESYS online protocol)`
|
|
);
|
|
}
|
|
);
|
|
|
|
// ─── Filesystem mirror (Phase 1: read-only export) ────────────────────
|
|
|
|
s.tool(
|
|
'mirror_export',
|
|
"Walks the CODESYS project tree and writes one .st file per code-bearing object into a filesystem mirror, preserving the project tree as nested directories. Programs / Function Blocks / Functions / Methods / Properties / DUTs / GVLs / Interfaces all become text files; structural nodes (Devices, Applications, Folders) become directories. Each file carries a header comment with its original CODESYS project path so a future write-back tool can map it back to set_pou_code's pouPath. Read-only -- does NOT modify the CODESYS project. UTF-8 output. If mirrorRoot is omitted, defaults to '<projectDir>/mcp-mirror'.",
|
|
{
|
|
projectFilePath: z.string().describe("Path to the project file."),
|
|
mirrorRoot: z.string().optional().describe("Filesystem path where the mirror tree gets written. If omitted, defaults to '<projectDir>/mcp-mirror'. Created automatically if missing; existing files at the same paths are overwritten."),
|
|
},
|
|
async (args: { projectFilePath: string; mirrorRoot?: string }) => {
|
|
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
|
const mirrorRoot = args.mirrorRoot
|
|
? resolvePath(args.mirrorRoot, workspaceDir)
|
|
: path.join(path.dirname(escaped), 'mcp-mirror');
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'mirror_export',
|
|
{
|
|
PROJECT_FILE_PATH: escaped,
|
|
MIRROR_ROOT: mirrorRoot,
|
|
},
|
|
['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
return formatToolResponse(
|
|
result,
|
|
`mirror_export complete for ${args.projectFilePath} -> ${mirrorRoot}.`
|
|
);
|
|
}
|
|
);
|
|
|
|
// ─── Resources ───────────────────────────────────────────────────────
|
|
|
|
server.resource(
|
|
'project-status',
|
|
'codesys://project/status',
|
|
async (uri) => {
|
|
try {
|
|
const script = scriptManager.loadTemplate('check_status');
|
|
const result = await executor.executeScript(script);
|
|
|
|
const outputLines = result.output.split(/[\r\n]+/).filter((l) => l.trim());
|
|
const statusData: Record<string, string> = {};
|
|
outputLines.forEach((line) => {
|
|
const match = line.match(/^([^:]+):\s*(.*)$/);
|
|
if (match) statusData[match[1].trim()] = match[2].trim();
|
|
});
|
|
|
|
const statusText = [
|
|
'CODESYS Status:',
|
|
` - Scripting OK: ${statusData['Scripting OK'] ?? 'Unknown'}`,
|
|
` - Project Open: ${statusData['Project Open'] ?? 'Unknown'}`,
|
|
` - Project Name: ${statusData['Project Name'] ?? 'Unknown'}`,
|
|
` - Project Path: ${statusData['Project Path'] ?? 'N/A'}`,
|
|
].join('\n');
|
|
|
|
const isError =
|
|
!result.success ||
|
|
statusData['Scripting OK']?.toLowerCase() !== 'true';
|
|
|
|
return {
|
|
contents: [{ uri: uri.href, text: statusText, contentType: 'text/plain' }],
|
|
isError,
|
|
};
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
contents: [{ uri: uri.href, text: `Failed status check: ${msg}`, contentType: 'text/plain' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
const projectStructureTemplate = new ResourceTemplate(
|
|
'codesys://project/{+project_path}/structure',
|
|
{ list: undefined }
|
|
);
|
|
|
|
server.resource(
|
|
'project-structure',
|
|
projectStructureTemplate,
|
|
async (uri, params) => {
|
|
const projectPath = params.project_path as string;
|
|
if (!projectPath) {
|
|
return {
|
|
contents: [{ uri: uri.href, text: 'Error: Project path missing.', contentType: 'text/plain' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
try {
|
|
const escaped = resolvePath(projectPath, workspaceDir);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'get_project_structure', { PROJECT_FILE_PATH: escaped }, ['ensure_project_open']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
let structureText = `Error retrieving structure.\n\n${result.output}`;
|
|
let isError = !result.success;
|
|
|
|
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
|
|
const startMarker = '--- PROJECT STRUCTURE START ---';
|
|
const endMarker = '--- PROJECT STRUCTURE END ---';
|
|
const startIdx = result.output.indexOf(startMarker);
|
|
const endIdx = result.output.indexOf(endMarker);
|
|
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
|
|
structureText = result.output
|
|
.substring(startIdx + startMarker.length, endIdx)
|
|
.replace(/\\n/g, '\n')
|
|
.trim();
|
|
} else {
|
|
structureText = `Could not parse structure markers.\n\nOutput:\n${result.output}`;
|
|
isError = true;
|
|
}
|
|
}
|
|
|
|
return {
|
|
contents: [{ uri: uri.href, text: structureText, contentType: 'text/plain' }],
|
|
isError,
|
|
};
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
contents: [{ uri: uri.href, text: `Failed: ${msg}`, contentType: 'text/plain' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
const pouCodeTemplate = new ResourceTemplate(
|
|
'codesys://project/{+project_path}/pou/{+pou_path}/code',
|
|
{ list: undefined }
|
|
);
|
|
|
|
server.resource(
|
|
'pou-code',
|
|
pouCodeTemplate,
|
|
async (uri, params) => {
|
|
const projectPath = params.project_path as string;
|
|
const pouPath = params.pou_path as string;
|
|
if (!projectPath || !pouPath) {
|
|
return {
|
|
contents: [{ uri: uri.href, text: 'Error: Project or POU path missing.', contentType: 'text/plain' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
try {
|
|
const escProjPath = resolvePath(projectPath, workspaceDir);
|
|
const sanPouPath = sanitizePouPath(pouPath);
|
|
const script = scriptManager.prepareScriptWithHelpers(
|
|
'get_pou_code',
|
|
{ PROJECT_FILE_PATH: escProjPath, POU_FULL_PATH: sanPouPath },
|
|
['ensure_project_open', 'find_object_by_path']
|
|
);
|
|
const result = await executor.executeScript(script);
|
|
|
|
let codeText = `Error retrieving code.\n\n${result.output}`;
|
|
let isError = !result.success;
|
|
|
|
if (result.success && result.output.includes('SCRIPT_SUCCESS')) {
|
|
const declStart = '### POU DECLARATION START ###';
|
|
const declEnd = '### POU DECLARATION END ###';
|
|
const implStart = '### POU IMPLEMENTATION START ###';
|
|
const implEnd = '### POU IMPLEMENTATION END ###';
|
|
|
|
let declaration = '/* Declaration not found */';
|
|
let implementation = '/* Implementation not found */';
|
|
|
|
const ds = result.output.indexOf(declStart);
|
|
const de = result.output.indexOf(declEnd);
|
|
if (ds !== -1 && de !== -1 && ds < de) {
|
|
declaration = result.output.substring(ds + declStart.length, de).replace(/\\n/g, '\n').trim();
|
|
}
|
|
|
|
const is_ = result.output.indexOf(implStart);
|
|
const ie = result.output.indexOf(implEnd);
|
|
if (is_ !== -1 && ie !== -1 && is_ < ie) {
|
|
implementation = result.output.substring(is_ + implStart.length, ie).replace(/\\n/g, '\n').trim();
|
|
}
|
|
|
|
codeText = `// ----- Declaration -----\n${declaration}\n\n// ----- Implementation -----\n${implementation}`;
|
|
}
|
|
|
|
return {
|
|
contents: [{ uri: uri.href, text: codeText, contentType: 'text/plain' }],
|
|
isError,
|
|
};
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
contents: [{ uri: uri.href, text: `Failed: ${msg}`, contentType: 'text/plain' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// ─── Connect ─────────────────────────────────────────────────────────
|
|
|
|
const transport = new StdioServerTransport();
|
|
serverLog.info('Connecting MCP server via stdio...');
|
|
server.connect(transport);
|
|
serverLog.info('MCP Server connected and listening.');
|
|
|
|
// ─── Graceful Shutdown ───────────────────────────────────────────────
|
|
|
|
const shutdown = async () => {
|
|
serverLog.info('Shutdown signal received');
|
|
if (launcher) {
|
|
try {
|
|
await launcher.shutdown();
|
|
} catch {
|
|
serverLog.warn('Launcher shutdown failed during signal handler');
|
|
}
|
|
}
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('unhandledRejection', (reason) => {
|
|
serverLog.error(`Unhandled rejection: ${reason}`);
|
|
});
|
|
}
|