From 1f23cb0498fce674bf33737f550443d57ef4d8a9 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Sat, 25 Apr 2026 15:52:04 +0200 Subject: [PATCH] feat: add install_library_file MCP tool for system-wide library installs Adds a new tool that installs a .library file into the CODESYS Library Repository via the scriptengine API, independent of any open project. Useful for automating library bring-up on a fresh machine when the CODESYS Store auto-download is broken (the immediate motivation: WAGO libraries 404 against store.codesys.com / store-archive.codesys.com). - src/scripts/install_library_file.py: Single LIBRARY_FILE_PATH parameter. Defensive multi-pattern API probe -- libraries can be reached via either script_engine.libraries or script_engine.system.libraries depending on SP19/SP21/SP22, and the install method may be install_library / install / add_library / add with either (path,) or (path, overwrite) signatures. Picks the first writable (non-system) repository it finds. Reports installed name + version + repository on success. - src/server.ts: Wires the script as MCP tool 'install_library_file' immediately after add_library. Does NOT use the ensure_project_open helper, because repository installs work without a project loaded. ASCII-only source (per the IronPython 2.7 lesson from 0.4.1). Verification pending -- needs a real .library file to install. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/scripts/install_library_file.py | 133 ++++++++++++++++++++++++++++ src/server.ts | 20 +++++ 2 files changed, 153 insertions(+) create mode 100644 src/scripts/install_library_file.py diff --git a/src/scripts/install_library_file.py b/src/scripts/install_library_file.py new file mode 100644 index 0000000..dfe0636 --- /dev/null +++ b/src/scripts/install_library_file.py @@ -0,0 +1,133 @@ +import sys, scriptengine as script_engine, os, traceback + +LIBRARY_FILE_PATH = r"{LIBRARY_FILE_PATH}" + +try: + print("DEBUG: install_library_file: File='%s'" % LIBRARY_FILE_PATH) + if not LIBRARY_FILE_PATH: + raise ValueError("Library file path is empty.") + if not os.path.exists(LIBRARY_FILE_PATH): + raise IOError("Library file not found: %s" % LIBRARY_FILE_PATH) + + # Locate a writable Library Repository handle. + # CODESYS scripting exposes libraries either as a top-level alias + # (script_engine.libraries) or under the system module + # (script_engine.system.libraries). Both shapes have been observed + # across SP19/SP21/SP22. + repo = None + repo_name = "?" + + candidates = [] + try: + if hasattr(script_engine, 'libraries'): + candidates.append(("script_engine.libraries", script_engine.libraries)) + except Exception as e: + print("DEBUG: probing script_engine.libraries failed: %s" % e) + try: + sys_mod = getattr(script_engine, 'system', None) + if sys_mod is not None and hasattr(sys_mod, 'libraries'): + candidates.append(("script_engine.system.libraries", sys_mod.libraries)) + except Exception as e: + print("DEBUG: probing script_engine.system.libraries failed: %s" % e) + + for src_name, libs in candidates: + print("DEBUG: candidate %s -> %s" % (src_name, type(libs).__name__)) + # Case A: object exposes install_library() directly (acts as a single repo) + if hasattr(libs, 'install_library') or hasattr(libs, 'install'): + repo = libs + repo_name = src_name + break + # Case B: object exposes a 'repositories' collection. Pick the first + # non-system, writable-looking repository. + if hasattr(libs, 'repositories'): + try: + rlist = list(libs.repositories) + except Exception: + rlist = [] + print("DEBUG: %s.repositories -> %d repos" % (src_name, len(rlist))) + chosen = None + for r in rlist: + is_sys = False + try: + flag = getattr(r, 'is_system', None) + if callable(flag): + is_sys = bool(flag()) + elif flag is not None: + is_sys = bool(flag) + except Exception: + pass + if not is_sys: + chosen = r + break + if chosen is None and rlist: + chosen = rlist[0] + if chosen is not None: + repo = chosen + try: + nm = getattr(repo, 'get_name', None) + repo_name = nm() if callable(nm) else getattr(repo, 'name', '?') + except Exception: + repo_name = "?" + break + + if repo is None: + raise RuntimeError("Could not locate a Library Repository via scripting API. Tried script_engine.libraries and script_engine.system.libraries.") + + print("DEBUG: target repository: %s" % repo_name) + + # Try install methods in order of likelihood. Some accept (path, overwrite), + # some accept just (path). Try both arities for each name. + installed = None + last_err = None + for method_name in ('install_library', 'install', 'add_library', 'add'): + if not hasattr(repo, method_name): + continue + method = getattr(repo, method_name) + for args in ((LIBRARY_FILE_PATH,), (LIBRARY_FILE_PATH, False), (LIBRARY_FILE_PATH, True)): + try: + installed = method(*args) + print("DEBUG: %s%s succeeded" % (method_name, args)) + break + except TypeError as e: + last_err = e + continue + except Exception as e: + last_err = e + print("DEBUG: %s%s failed: %s" % (method_name, args, e)) + break + if installed is not None: + break + + if installed is None: + raise RuntimeError("Install failed via all known method patterns. Last error: %s" % last_err) + + # Best-effort name/version extraction + def _attr(obj, name, default='?'): + try: + v = getattr(obj, name, None) + if callable(v): + return v() + if v is not None: + return v + except Exception: + pass + return default + + lib_name = _attr(installed, 'get_name') + if lib_name == '?': + lib_name = _attr(installed, 'name') + lib_version = _attr(installed, 'get_version') + if lib_version == '?': + lib_version = _attr(installed, 'version') + + print("Installed: %s %s" % (lib_name, lib_version)) + print("Repository: %s" % repo_name) + print("File: %s" % LIBRARY_FILE_PATH) + print("SCRIPT_SUCCESS: Library installed.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error installing library file '%s': %s\n%s" % (LIBRARY_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/server.ts b/src/server.ts index 23f4af2..d53adcd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1044,6 +1044,26 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + s.tool( + 'install_library_file', + "Installs a .library file into the CODESYS Library Repository (system-wide). Useful for automating library bring-up on a fresh machine when the CODESYS Store auto-download is broken (e.g. WAGO libraries). Does not need a project to be open.", + { + libraryFilePath: z.string().describe("Full path to the .library file on disk to install."), + }, + async (args: { libraryFilePath: string }) => { + const escaped = resolvePath(args.libraryFilePath, workspaceDir); + const script = scriptManager.prepareScript( + 'install_library_file', + { LIBRARY_FILE_PATH: escaped } + ); + const result = await executor.executeScript(script); + return formatToolResponse( + result, + `Library file installed: ${args.libraryFilePath}` + ); + } + ); + // ─── Resources ─────────────────────────────────────────────────────── server.resource(