0
0
Fork 0
Codesys-MCP-SP21-plus/src/server.ts
Karstein Phobic Nyvold Kvistad 888a035c0c feat(list_project_libraries): capture project compiler version
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.
2026-04-26 19:18:31 +02:00

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