0
0
Fork 0

feat: add install_addon_from_file MCP tool (wraps APInstaller.CLI)

Third and final tool in the library-bring-up trio. Where install_library_file
and install_library_from_url handle plain .library files via the in-IDE
scriptengine, this one handles .package bundles (e.g. WAGO PFC libraries
package, vendor add-ons) by shelling out to the standalone CODESYS
Installer's CLI.

  Command issued:
    APInstaller.CLI.exe --installAddOnFromFile
                        --location <install-folder>
                        --sourcefile <package-file>

  Defaults:
    - APInstaller path: C:\Program Files (x86)\CODESYS\APInstaller\
        APInstaller.CLI.exe (override via CODESYS_APINSTALLER_CLI env var)
    - --location: derived from this MCP's configured codesysPath
        (path.dirname x3) so it matches the install the MCP itself uses.
        Caller can override via the 'installation' tool arg.

Why a separate tool (vs. extending install_library_file): the .package
format and .library format have different installers and different
side-effects on the CODESYS install. Keeping the tools separate keeps the
error surface and the MCP tool description honest about what each does.

Returns full stdout/stderr in the response so failed installs surface the
APInstaller's own diagnostics (dependency conflicts, permission errors)
to the caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 16:04:13 +02:00
parent 7fcca42a53
commit 79c83fd4c3

View file

@ -8,6 +8,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as crypto from 'crypto';
import { URL } from 'url';
import { spawn } from 'child_process';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
@ -17,6 +18,32 @@ import { HeadlessExecutor } from './headless';
import { ScriptManager } from './script-manager';
import { serverLog, setLogLevel } from './logger';
/** Default install path for the standalone CODESYS Installer (APInstaller). */
const DEFAULT_APINSTALLER_CLI = 'C:\\Program Files (x86)\\CODESYS\\APInstaller\\APInstaller.CLI.exe';
/** Locate APInstaller.CLI.exe -- env var override wins, otherwise the default path. */
function locateAPInstallerCli(): string | null {
const fromEnv = process.env.CODESYS_APINSTALLER_CLI;
const candidate = fromEnv && fromEnv.trim().length > 0 ? fromEnv : DEFAULT_APINSTALLER_CLI;
return fs.existsSync(candidate) ? candidate : null;
}
/**
* Run a process to completion, capturing stdout+stderr. Resolves with
* { code, stdout, stderr }. Rejects only on spawn error (e.g. exe missing).
*/
function runProcess(exe: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(exe, args, { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
child.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
child.on('error', reject);
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
/**
* Download a URL to a temp file. Returns the local file path.
* Follows redirects, fails on non-2xx, throws on network errors.
@ -1089,6 +1116,70 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
s.tool(
'install_addon_from_file',
"Installs a CODESYS .package add-on (e.g. WAGO PFC libraries bundle, vendor packages) into a CODESYS installation by shelling out to APInstaller.CLI.exe --installAddOnFromFile. Use this for .package bundles; use install_library_file for plain .library files. Does not need CODESYS UI to be running.",
{
packageFilePath: z.string().describe("Full path to the .package file to install."),
installation: z.string().optional().describe("Installation location (e.g. 'C:\\\\Program Files\\\\CODESYS 3.5.21.50'). Defaults to the parent of this MCP's configured CODESYS install."),
},
async (args: { packageFilePath: string; installation?: string }) => {
const cli = locateAPInstallerCli();
if (!cli) {
return {
content: [{
type: 'text' as const,
text: `APInstaller.CLI.exe not found. Set CODESYS_APINSTALLER_CLI env var, or install the standalone CODESYS Installer (default path: ${DEFAULT_APINSTALLER_CLI}).`,
}],
isError: true,
};
}
const pkg = resolvePath(args.packageFilePath, workspaceDir);
if (!fs.existsSync(pkg)) {
return {
content: [{ type: 'text' as const, text: `Package file not found: ${pkg}` }],
isError: true,
};
}
// Default installation location = parent of CODESYS\Common\CODESYS.exe
// i.e. three dirnames up from config.codesysPath
const defaultLocation = path.dirname(path.dirname(path.dirname(config.codesysPath)));
const location = args.installation && args.installation.trim().length > 0
? path.normalize(args.installation)
: defaultLocation;
const cliArgs = ['--installAddOnFromFile', '--location', location, '--sourcefile', pkg];
let res;
try {
res = await runProcess(cli, cliArgs);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: [{ type: 'text' as const, text: `Failed to spawn ${cli}: ${msg}` }],
isError: true,
};
}
const success = res.code === 0;
const summary = success
? `Add-on installed: ${pkg} -> ${location}`
: `APInstaller exited with code ${res.code}.`;
const detail = [
summary,
'',
`Command: "${cli}" ${cliArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`,
'',
'--- stdout ---',
res.stdout || '(empty)',
'--- stderr ---',
res.stderr || '(empty)',
].join('\n');
return {
content: [{ type: 'text' as const, text: detail }],
isError: !success,
};
}
);
s.tool(
'install_library_from_url',
"Downloads a .library file from a URL (HTTPS supported, follows redirects) and installs it into the CODESYS Library Repository. Companion to install_library_file for fully-automated bring-up from internal shares, vendor download URLs, or GitHub release assets. Does not need a project to be open.",