0
0
Fork 0

feat: add install_library_from_url MCP tool (download + install in one step)

Builds on install_library_file (1f23cb0) by adding the missing download
side. Takes any direct .library URL, downloads to a temp file using the
runtime's built-in fetch (HTTPS + redirects), then runs the same
scriptengine install path as install_library_file. Temp file is removed
after install unless keepDownload=true is set.

Use cases:
  - Pulling a single .library off an internal HTTP/file share by URL
  - Vendor-direct download links (e.g. WAGO publishes individual .library
    files alongside their .package bundles)
  - GitHub release assets

Out of scope for this tool: .package-style bundles (zip-of-libraries).
Those need APInstaller.CLI.exe and will be added as a separate tool
(install_addon_from_file) so the responsibilities stay clear.

Implementation note: shared downloadToTempFile() helper added at the top
of server.ts. Cleanup in a finally so a failed install still removes the
temp file (unless keepDownload).

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:02:36 +02:00
parent 1f23cb0498
commit 7fcca42a53

View file

@ -5,6 +5,9 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import * as crypto from 'crypto';
import { URL } from 'url';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
@ -14,6 +17,28 @@ import { HeadlessExecutor } from './headless';
import { ScriptManager } from './script-manager';
import { serverLog, setLogLevel } from './logger';
/**
* Download a URL to a temp file. Returns the local file path.
* Follows redirects, fails on non-2xx, throws on network errors.
* Caller is responsible for removing the file when done.
*/
async function downloadToTempFile(url: string, suggestedExt = '.library'): Promise<string> {
const u = new URL(url);
const tmpDir = path.join(os.tmpdir(), 'codesys-mcp-downloads');
await fs.promises.mkdir(tmpDir, { recursive: true });
const baseName = path.basename(u.pathname) || `download-${crypto.randomBytes(6).toString('hex')}`;
const ext = path.extname(baseName) ? '' : suggestedExt;
const tmpPath = path.join(tmpDir, `${Date.now()}-${baseName}${ext}`);
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
}
const buf = Buffer.from(await res.arrayBuffer());
await fs.promises.writeFile(tmpPath, buf);
return tmpPath;
}
// Zod enums for POU tools
const PouTypeEnum = z.enum(['Program', 'FunctionBlock', 'Function']);
const ImplementationLanguageEnum = z.enum([
@ -1064,6 +1089,43 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
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.",
{
url: z.string().describe("Direct URL to the .library file. Must return 2xx; redirects are followed."),
keepDownload: z.boolean().optional().describe("If true, the downloaded file is kept on disk (path reported in the response). Default: false (temp file deleted after install)."),
},
async (args: { url: string; keepDownload?: boolean }) => {
let tmpPath: string | null = null;
try {
tmpPath = await downloadToTempFile(args.url, '.library');
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: [{ type: 'text' as const, text: `Download failed for ${args.url}: ${msg}` }],
isError: true,
};
}
try {
const script = scriptManager.prepareScript(
'install_library_file',
{ LIBRARY_FILE_PATH: tmpPath }
);
const result = await executor.executeScript(script);
const successMsg = args.keepDownload
? `Library installed from ${args.url} (download kept at ${tmpPath}).`
: `Library installed from ${args.url}.`;
const response = formatToolResponse(result, successMsg);
return response;
} finally {
if (!args.keepDownload && tmpPath) {
try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ }
}
}
}
);
// ─── Resources ───────────────────────────────────────────────────────
server.resource(