puppet-mcp 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 nathan <137482711+nathwn12@users.noreply.github.com>
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nathan <137482711+nathwn12@users.noreply.github.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,44 +1,44 @@
1
- # puppet-mcp
2
-
3
- Windows desktop-control MCP server: a native Rust binary served over stdio.
4
- This package is a dependency-free Node launcher plus a pinned server
5
- manifest — it contains no server bytes.
6
-
7
- ## Run
8
-
9
- ```sh
10
- npx puppet-mcp@latest
11
- ```
12
-
13
- Or pin it in an MCP client as a stdio server with command and args:
14
-
15
- ```json
16
- ["cmd", "/c", "npx", "-y", "puppet-mcp@latest"]
17
- ```
18
-
19
- First run provisions the pinned server from the tagged GitHub Release into
20
- `%LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\`
21
- (sha256-verified before and after extraction), then spawns it with stdio
22
- passthrough. The cache is reused on later runs (re-verified each time); any
23
- mismatch refuses and writes nothing. All launcher chatter goes to stderr;
24
- stdout belongs to the MCP channel.
25
-
26
- ## Diagnostics (no network)
27
-
28
- ```sh
29
- npx puppet-mcp@latest --doctor
30
- ```
31
-
32
- Prints the pinned manifest and the resolved cache path, exits 0, performs
33
- no fetch. `--print-manifest` is an alias.
34
-
35
- ## Requirements
36
-
37
- - Windows x86_64. Any other platform refuses at startup
38
- (override for diagnostics only: `PUPPET_MCP_ALLOW_NON_WINDOWS=1`).
39
- - Node.js >= 22.14.0 (global `fetch` plus `crypto`/`zlib` builtins only;
40
- zero dependencies).
41
-
42
- ## License
43
-
44
- MIT — see LICENSE.
1
+ # puppet-mcp
2
+
3
+ Windows desktop-control MCP server: a native Rust binary served over stdio.
4
+ This package is a dependency-free Node launcher plus a pinned server
5
+ manifest — it contains no server bytes.
6
+
7
+ ## Run
8
+
9
+ ```sh
10
+ npx puppet-mcp@latest
11
+ ```
12
+
13
+ Or pin it in an MCP client as a stdio server with command and args:
14
+
15
+ ```json
16
+ ["cmd", "/c", "npx", "-y", "puppet-mcp@latest"]
17
+ ```
18
+
19
+ First run provisions the pinned server from the tagged GitHub Release into
20
+ `%LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\`
21
+ (sha256-verified before and after extraction), then spawns it with stdio
22
+ passthrough. The cache is reused on later runs (re-verified each time); any
23
+ mismatch refuses and writes nothing. All launcher chatter goes to stderr;
24
+ stdout belongs to the MCP channel.
25
+
26
+ ## Diagnostics (no network)
27
+
28
+ ```sh
29
+ npx puppet-mcp@latest --doctor
30
+ ```
31
+
32
+ Prints the pinned manifest and the resolved cache path, exits 0, performs
33
+ no fetch. `--print-manifest` is an alias.
34
+
35
+ ## Requirements
36
+
37
+ - Windows x86_64. Any other platform refuses at startup
38
+ (override for diagnostics only: `PUPPET_MCP_ALLOW_NON_WINDOWS=1`).
39
+ - Node.js >= 22.14.0 (global `fetch` plus `crypto`/`zlib` builtins only;
40
+ zero dependencies).
41
+
42
+ ## License
43
+
44
+ MIT — see LICENSE.
package/bin/puppet-mcp.js CHANGED
@@ -1,413 +1,413 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
- //
4
- // puppet-mcp launcher.
5
- //
6
- // Published package model (see docs/PUBLISHING.md): this tarball holds ONLY
7
- // this launcher plus the pinned server manifest (engine-manifest.json).
8
- // It holds NO server bytes, NO `os` field, NO `scripts`/postinstall field.
9
- // npm 12 blocks dependency install scripts by default (measured
10
- // `allow-scripts=false`), and npx has no project package.json for
11
- // `allowScripts` to consult, so a postinstall download would silently never
12
- // run. Everything is therefore acquired on first RUN by the code below.
13
- //
14
- // Provisioning, one cache, verified:
15
- // - The server (manifest runtime.archive) downloads from the tagged GitHub
16
- // Release, verifies zip sha256+size BEFORE extraction, extracts
17
- // atomically (staging dir, then rename) into
18
- // %LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\, verifies
19
- // the server binary against the manifest, writes a .ready marker, and is
20
- // reused on later runs (re-verified each time). Any mismatch refuses and
21
- // writes nothing.
22
- // - The server (puppet.exe, no args = MCP stdio serve) spawns with stdio
23
- // inherited. ALL launcher chatter goes to stderr; stdout belongs to the
24
- // MCP channel. No stdout writes exist on the run path.
25
- // - `--doctor` / `--print-manifest` is the no-network diagnostic: it
26
- // prints the manifest plus the resolved cache path and exits 0. It
27
- // returns BEFORE any fetch call (all fetch use lives in
28
- // downloadToMemory, reachable only from ensureRuntime, which the
29
- // diagnostic path never calls).
30
- //
31
- // Builtins only: node:fs, node:path, node:os, node:crypto,
32
- // node:child_process, node:zlib, and the global fetch (Node >= 22.14).
33
- // No dependencies, so the published tarball stays tiny and auditable.
34
- //
35
-
36
- const fs = require('node:fs');
37
- const path = require('node:path');
38
- const os = require('node:os');
39
- const crypto = require('node:crypto');
40
- const zlib = require('node:zlib');
41
- const { spawn } = require('node:child_process');
42
-
43
- const PKG_DIR = path.dirname(path.dirname(__filename));
44
- const MANIFEST_PATH = path.join(PKG_DIR, 'engine-manifest.json');
45
- const PLATFORM_OVERRIDE_ENV = 'PUPPET_MCP_ALLOW_NON_WINDOWS';
46
- const RUNTIME_CACHE_OVERRIDE_ENV = 'PUPPET_ENGINE_CACHE';
47
- const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000;
48
- // Staging/extraction guards: the real runtime bundle is ~2 MB. These caps
49
- // reject zip bombs, never the real artifact.
50
- const MAX_ENTRIES = 4096;
51
- const MAX_TOTAL_UNCOMPRESSED = 2 * 1024 * 1024 * 1024;
52
- const MAX_ENTRY_SIZE = 1 * 1024 * 1024 * 1024;
53
-
54
- function fail(message) {
55
- process.stderr.write(`puppet-mcp: error: ${message}\n`);
56
- process.exit(1);
57
- }
58
-
59
- function log(message) {
60
- process.stderr.write(`puppet-mcp: ${message}\n`);
61
- }
62
-
63
- function loadManifest() {
64
- let raw;
65
- try {
66
- raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
67
- } catch (err) {
68
- fail(`cannot read engine manifest at ${MANIFEST_PATH}: ${err.message}`);
69
- }
70
- try {
71
- return JSON.parse(raw);
72
- } catch (err) {
73
- fail(`engine manifest is not valid JSON: ${err.message}`);
74
- }
75
- }
76
-
77
- // A sha256 slot the release workflow has not filled yet. The committed
78
- // manifest carries the sentinel for every hash computed at release time;
79
- // the workflow replaces each with the real 64-hex digest. Anything else
80
- // shaped (64 hex chars) is release-provisioned.
81
- function isPlaceholderSha(sha) {
82
- return typeof sha !== 'string' || !/^[0-9a-fA-F]{64}$/.test(sha);
83
- }
84
-
85
- function shortHash(sha) {
86
- return isPlaceholderSha(sha) ? 'unpublished' : sha.toLowerCase().slice(0, 8);
87
- }
88
-
89
- // Resolved runtime cache directory. Never throws: the diagnostic path must
90
- // print SOMETHING and exit 0 even where provisioning could never run.
91
- function resolveRuntimeDir(manifest) {
92
- const notes = [];
93
- let root = process.env[RUNTIME_CACHE_OVERRIDE_ENV];
94
- let overridden = Boolean(root);
95
- if (!root) {
96
- root = process.env.LOCALAPPDATA;
97
- if (!root) notes.push('LOCALAPPDATA is unset; set it or ' + RUNTIME_CACHE_OVERRIDE_ENV);
98
- }
99
- const leaf = `${manifest.serverVersion}-${shortHash(manifest.runtime.archive.sha256)}`;
100
- if (!root) return { dir: path.join('<unresolvable>', 'puppet', 'engine', leaf), overridden, notes };
101
- return { dir: path.join(root, 'puppet', 'engine', leaf), overridden, notes };
102
- }
103
-
104
- function printDoctor(manifest) {
105
- const runtime = resolveRuntimeDir(manifest);
106
- const report = {
107
- package: manifest.package,
108
- serverVersion: manifest.serverVersion,
109
- platform: process.platform,
110
- node: process.version,
111
- runtimeCacheDir: runtime.dir,
112
- runtimeCacheOverridden: runtime.overridden,
113
- runtimeCacheNotes: runtime.notes,
114
- runtime: manifest.runtime,
115
- network: 'none: this diagnostic performs no fetch and exits before any download code',
116
- };
117
- process.stdout.write(JSON.stringify(report, null, 2) + '\n');
118
- process.exit(0);
119
- }
120
-
121
- function usage(exitCode, stream) {
122
- const text = [
123
- 'usage: puppet-mcp [--doctor | --print-manifest | --help]',
124
- '',
125
- ' (no args) provision the pinned server on first run, then serve MCP over stdio',
126
- ' --doctor, print the pinned manifest and the resolved cache',
127
- ' --print-manifest path, perform no network access, exit 0',
128
- ' --help, -h print this usage',
129
- '',
130
- 'runtime cache: ' + path.join('%LOCALAPPDATA%', 'puppet', 'engine', '<serverVersion>-<bundleSha8>') + ` (override: ${RUNTIME_CACHE_OVERRIDE_ENV})`,
131
- `platform: windows-x86_64 only (override: ${PLATFORM_OVERRIDE_ENV}=1, diagnostics only)`,
132
- ].join('\n') + '\n';
133
- stream.write(text);
134
- process.exit(exitCode);
135
- }
136
-
137
- // --- minimal zip reader (stored + deflate, builtins only) -----------------
138
- // The runtime bundle is an ordinary deflate zip written by PowerShell's
139
- // Compress-Archive. Node has no builtin unzip, and this package is
140
- // dependency-free by design, so the container parse is hand-rolled here
141
- // (~120 lines) over zlib.inflateRawSync. Anything outside the artifact's
142
- // actual shape (encryption, data descriptors, zip64, unknown methods) is an
143
- // explicit error, never a silent misparse.
144
-
145
- const CRC_TABLE = (() => {
146
- const t = new Int32Array(256);
147
- for (let n = 0; n < 256; n++) {
148
- let c = n;
149
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
150
- t[n] = c;
151
- }
152
- return t;
153
- })();
154
-
155
- function crc32(buf) {
156
- let c = 0xffffffff;
157
- for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
158
- return (c ^ 0xffffffff) >>> 0;
159
- }
160
-
161
- function findEocd(buf) {
162
- // EOCD is >= 22 bytes at the very end, with up to 64 KiB of comment.
163
- const lo = Math.max(0, buf.length - (65557 + 22));
164
- for (let i = buf.length - 22; i >= lo; i--) {
165
- if (buf.readUInt32LE(i) === 0x06054b50) return i;
166
- }
167
- return -1;
168
- }
169
-
170
- function extractZip(buf, destDir) {
171
- const eocd = findEocd(buf);
172
- if (eocd < 0) throw new Error('not a zip archive: end-of-central-directory not found');
173
- const entries = buf.readUInt16LE(eocd + 10);
174
- const cdSize = buf.readUInt32LE(eocd + 12);
175
- const cdOff = buf.readUInt32LE(eocd + 16);
176
- if (entries === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) {
177
- throw new Error('zip64 archives are not supported');
178
- }
179
- if (entries > MAX_ENTRIES) throw new Error(`too many entries: ${entries}`);
180
- if (cdOff + cdSize > buf.length) throw new Error('central directory runs past end of buffer');
181
-
182
- let total = 0;
183
- const extracted = [];
184
- let p = cdOff;
185
- for (let n = 0; n < entries; n++) {
186
- if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`bad central-directory entry ${n}`);
187
- const flags = buf.readUInt16LE(p + 8);
188
- const method = buf.readUInt16LE(p + 10);
189
- const crc = buf.readUInt32LE(p + 16);
190
- const compSize = buf.readUInt32LE(p + 20);
191
- const uncompSize = buf.readUInt32LE(p + 24);
192
- const nameLen = buf.readUInt16LE(p + 28);
193
- const extraLen = buf.readUInt16LE(p + 30);
194
- const commentLen = buf.readUInt16LE(p + 32);
195
- const localOff = buf.readUInt32LE(p + 42);
196
- const rawName = buf.toString('utf8', p + 46, p + 46 + nameLen);
197
- p += 46 + nameLen + extraLen + commentLen;
198
-
199
- if (flags & 0x0001) throw new Error(`encrypted entry unsupported: ${rawName}`);
200
- if (flags & 0x0008) throw new Error(`data-descriptor entry unsupported: ${rawName}`);
201
- if (method !== 0 && method !== 8) throw new Error(`compression method ${method} unsupported: ${rawName}`);
202
- if (compSize === 0xffffffff || uncompSize === 0xffffffff) throw new Error(`zip64 entry unsupported: ${rawName}`);
203
- if (uncompSize > MAX_ENTRY_SIZE) throw new Error(`entry too large: ${rawName}`);
204
- total += uncompSize;
205
- if (total > MAX_TOTAL_UNCOMPRESSED) throw new Error('archive uncompressed total exceeds cap');
206
-
207
- // Traversal guard: zip names normally use forward slashes, but
208
- // PowerShell's Compress-Archive can write nested entry names
209
- // with backslashes. Normalize every backslash to `/` FIRST, then run
210
- // the unchanged checks below on the normalized name: absolute paths,
211
- // drive letters, and `..` segments stay rejected, and the resolved
212
- // target must still sit under destDir.
213
- // (Spelled via char code so the source never holds a literal
214
- // double-backslash.)
215
- const BACKSLASH = String.fromCharCode(92);
216
- const name = rawName.split(BACKSLASH).join('/');
217
- if (name.startsWith('/') || /^[A-Za-z]:/.test(name)) {
218
- throw new Error(`unsafe entry name: ${rawName}`);
219
- }
220
- const parts = name.split('/').filter((s) => s !== '' && s !== '.');
221
- if (parts.length === 0 || parts.includes('..')) throw new Error(`unsafe entry name: ${rawName}`);
222
- const target = path.join(destDir, ...parts);
223
- const rel = path.relative(destDir, target);
224
- if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`entry escapes destination: ${rawName}`);
225
- const isDir = name.endsWith('/');
226
- if (isDir) {
227
- fs.mkdirSync(target, { recursive: true });
228
- continue;
229
- }
230
-
231
- if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error(`bad local header: ${rawName}`);
232
- const lhNameLen = buf.readUInt16LE(localOff + 26);
233
- const lhExtraLen = buf.readUInt16LE(localOff + 28);
234
- const dataOff = localOff + 30 + lhNameLen + lhExtraLen;
235
- const comp = buf.subarray(dataOff, dataOff + compSize);
236
- if (comp.length !== compSize) throw new Error(`truncated entry data: ${rawName}`);
237
- const data = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
238
- if (data.length !== uncompSize) throw new Error(`size mismatch after inflate: ${rawName}`);
239
- if (crc32(data) !== crc) throw new Error(`crc mismatch: ${rawName}`);
240
- fs.mkdirSync(path.dirname(target), { recursive: true });
241
- fs.writeFileSync(target, data);
242
- extracted.push(parts.join('/'));
243
- }
244
- return extracted;
245
- }
246
-
247
- // --- provisioning ---------------------------------------------------------
248
-
249
- function sha256File(filePath) {
250
- const h = crypto.createHash('sha256');
251
- h.update(fs.readFileSync(filePath));
252
- return h.digest('hex');
253
- }
254
-
255
- function checkFile(filePath, want) {
256
- // want: {name, sha256, size}. Returns null when good, else a reason.
257
- let st;
258
- try {
259
- st = fs.statSync(filePath);
260
- } catch {
261
- return 'missing';
262
- }
263
- if (!st.isFile()) return 'not a file';
264
- if (st.size !== want.size) return `size ${st.size} != manifest ${want.size}`;
265
- const got = sha256File(filePath);
266
- if (got !== want.sha256.toLowerCase()) return 'sha256 mismatch';
267
- return null;
268
- }
269
-
270
- // The ONLY fetch call site in this package. The diagnostic path returns
271
- // before the ensure* functions, so --doctor/--print-manifest provably reach
272
- // no network: grep `fetch(` — it appears exactly once, here.
273
- async function downloadToMemory(url) {
274
- const ctrl = new AbortController();
275
- const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
276
- let res;
277
- try {
278
- res = await fetch(url, { signal: ctrl.signal });
279
- } catch (err) {
280
- throw new Error(`download failed: ${err.message}`);
281
- } finally {
282
- clearTimeout(timer);
283
- }
284
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
285
- return Buffer.from(await res.arrayBuffer());
286
- }
287
-
288
- async function ensureRuntime(manifest, cacheDir) {
289
- const rt = manifest.runtime;
290
- if (isPlaceholderSha(rt.archive.sha256) || !(rt.archive.size > 0)) {
291
- fail(
292
- 'this package was not published by the release workflow: runtime hashes are placeholders. ' +
293
- 'See docs/PUBLISHING.md: publish only the workflow-built npm-package artifact.'
294
- );
295
- }
296
- if (isPlaceholderSha(rt.server.sha256) || !(rt.server.size > 0)) {
297
- fail('engine manifest has no release-provisioned server hash; refusing to run.');
298
- }
299
-
300
- const markerPath = path.join(cacheDir, '.ready');
301
- const reuse = (() => {
302
- try {
303
- const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
304
- if (marker.serverVersion !== manifest.serverVersion) return false;
305
- if (String(marker.bundleSha256).toLowerCase() !== rt.archive.sha256.toLowerCase()) return false;
306
- } catch {
307
- return false;
308
- }
309
- const bad = checkFile(path.join(cacheDir, rt.server.name), rt.server);
310
- if (bad) {
311
- log(`cached ${rt.server.name} invalid (${bad}); re-provisioning.`);
312
- return false;
313
- }
314
- return true;
315
- })();
316
- if (reuse) {
317
- log(`runtime cache ready (reused): ${cacheDir}`);
318
- return cacheDir;
319
- }
320
-
321
- const url = `${rt.source}/${rt.archive.name}`;
322
- log(`provisioning runtime cache: ${cacheDir}`);
323
- log(`downloading ${url} (${rt.archive.size} bytes)`);
324
- let data;
325
- try {
326
- data = await downloadToMemory(url);
327
- } catch (err) {
328
- fail(err.message);
329
- }
330
- if (data.length !== rt.archive.size) {
331
- fail(`download size ${data.length} != manifest ${rt.archive.size}; wrote nothing.`);
332
- }
333
- const digest = crypto.createHash('sha256').update(data).digest('hex');
334
- if (digest !== rt.archive.sha256.toLowerCase()) {
335
- fail('download sha256 mismatch; wrote nothing.');
336
- }
337
-
338
- // Extract into a staging dir, verify, then rename: the target dir is
339
- // never observed half-written.
340
- const staging = `${cacheDir}.staging-${process.pid}`;
341
- fs.rmSync(staging, { recursive: true, force: true });
342
- fs.rmSync(cacheDir, { recursive: true, force: true });
343
- fs.mkdirSync(staging, { recursive: true });
344
- try {
345
- const names = extractZip(data, staging);
346
- log(`extracted ${names.length} entries`);
347
- const bad = checkFile(path.join(staging, rt.server.name), rt.server);
348
- if (bad) throw new Error(`extracted ${rt.server.name} invalid (${bad})`);
349
- fs.renameSync(staging, cacheDir);
350
- fs.writeFileSync(
351
- markerPath,
352
- JSON.stringify({ serverVersion: manifest.serverVersion, bundleSha256: rt.archive.sha256.toLowerCase() }, null, 2)
353
- );
354
- } catch (err) {
355
- fs.rmSync(staging, { recursive: true, force: true });
356
- fs.rmSync(cacheDir, { recursive: true, force: true });
357
- fail(`provisioning failed (${err.message}); wrote nothing.`);
358
- }
359
- log(`runtime cache ready (provisioned): ${cacheDir}`);
360
- return cacheDir;
361
- }
362
-
363
- function runServer(cacheDir, manifest) {
364
- const exe = path.join(cacheDir, manifest.runtime.server.name);
365
- if (!fs.existsSync(exe)) {
366
- fail(`server binary missing after provisioning: ${exe}`);
367
- }
368
- // Bare puppet with no args serves MCP over stdio (src/main.rs: the server
369
- // path below the --version early-return). stdio is inherited so the child
370
- // IS the MCP channel; the launcher writes nothing to stdout on this path.
371
- // Ctrl+C reaches both processes on Windows (shared console), so no
372
- // signal relay is needed; the exit code is forwarded below.
373
- const child = spawn(exe, [], { stdio: 'inherit', env: { ...process.env } });
374
- child.on('error', (err) => fail(`cannot start ${exe}: ${err.message}`));
375
- child.on('exit', (code, signal) => {
376
- if (signal) {
377
- log(`server terminated by signal ${signal}`);
378
- process.exit(1);
379
- }
380
- process.exit(code === null ? 1 : code);
381
- });
382
- }
383
-
384
- async function main() {
385
- const args = process.argv.slice(2);
386
- const manifest = loadManifest();
387
- if (manifest.package !== 'puppet-mcp') fail('engine manifest names the wrong package.');
388
- if (args.includes('--help') || args.includes('-h')) usage(0, process.stdout);
389
- if (args.includes('--doctor') || args.includes('--print-manifest')) {
390
- const known = new Set(['--doctor', '--print-manifest']);
391
- const unknown = args.filter((a) => !known.has(a));
392
- if (unknown.length > 0) usage(2, process.stderr);
393
- printDoctor(manifest); // exits 0; no network on this path by construction
394
- }
395
- // Every known flag returns above; anything left is unknown.
396
- if (args.length > 0) usage(2, process.stderr);
397
-
398
- if (process.platform !== 'win32' && process.env[PLATFORM_OVERRIDE_ENV] !== '1') {
399
- fail(
400
- `unsupported platform '${process.platform}': puppet-mcp ships the windows-x86_64 server only. ` +
401
- `Set ${PLATFORM_OVERRIDE_ENV}=1 to bypass this gate for diagnostics (the server still will not run elsewhere).`
402
- );
403
- }
404
-
405
- const runtime = resolveRuntimeDir(manifest);
406
- if (runtime.dir.startsWith('<unresolvable>')) {
407
- fail(`cannot resolve runtime cache dir: ${runtime.notes.join('; ')}. Set ${RUNTIME_CACHE_OVERRIDE_ENV} to an absolute directory.`);
408
- }
409
- const readyRuntime = await ensureRuntime(manifest, runtime.dir);
410
- runServer(readyRuntime, manifest);
411
- }
412
-
413
- main().catch((err) => fail(err && err.message ? err.message : String(err)));
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // puppet-mcp launcher.
5
+ //
6
+ // Published package model (see docs/PUBLISHING.md): this tarball holds ONLY
7
+ // this launcher plus the pinned server manifest (engine-manifest.json).
8
+ // It holds NO server bytes, NO `os` field, NO `scripts`/postinstall field.
9
+ // npm 12 blocks dependency install scripts by default (measured
10
+ // `allow-scripts=false`), and npx has no project package.json for
11
+ // `allowScripts` to consult, so a postinstall download would silently never
12
+ // run. Everything is therefore acquired on first RUN by the code below.
13
+ //
14
+ // Provisioning, one cache, verified:
15
+ // - The server (manifest runtime.archive) downloads from the tagged GitHub
16
+ // Release, verifies zip sha256+size BEFORE extraction, extracts
17
+ // atomically (staging dir, then rename) into
18
+ // %LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\, verifies
19
+ // the server binary against the manifest, writes a .ready marker, and is
20
+ // reused on later runs (re-verified each time). Any mismatch refuses and
21
+ // writes nothing.
22
+ // - The server (puppet.exe, no args = MCP stdio serve) spawns with stdio
23
+ // inherited. ALL launcher chatter goes to stderr; stdout belongs to the
24
+ // MCP channel. No stdout writes exist on the run path.
25
+ // - `--doctor` / `--print-manifest` is the no-network diagnostic: it
26
+ // prints the manifest plus the resolved cache path and exits 0. It
27
+ // returns BEFORE any fetch call (all fetch use lives in
28
+ // downloadToMemory, reachable only from ensureRuntime, which the
29
+ // diagnostic path never calls).
30
+ //
31
+ // Builtins only: node:fs, node:path, node:os, node:crypto,
32
+ // node:child_process, node:zlib, and the global fetch (Node >= 22.14).
33
+ // No dependencies, so the published tarball stays tiny and auditable.
34
+ //
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+ const os = require('node:os');
39
+ const crypto = require('node:crypto');
40
+ const zlib = require('node:zlib');
41
+ const { spawn } = require('node:child_process');
42
+
43
+ const PKG_DIR = path.dirname(path.dirname(__filename));
44
+ const MANIFEST_PATH = path.join(PKG_DIR, 'engine-manifest.json');
45
+ const PLATFORM_OVERRIDE_ENV = 'PUPPET_MCP_ALLOW_NON_WINDOWS';
46
+ const RUNTIME_CACHE_OVERRIDE_ENV = 'PUPPET_ENGINE_CACHE';
47
+ const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000;
48
+ // Staging/extraction guards: the real runtime bundle is ~2 MB. These caps
49
+ // reject zip bombs, never the real artifact.
50
+ const MAX_ENTRIES = 4096;
51
+ const MAX_TOTAL_UNCOMPRESSED = 2 * 1024 * 1024 * 1024;
52
+ const MAX_ENTRY_SIZE = 1 * 1024 * 1024 * 1024;
53
+
54
+ function fail(message) {
55
+ process.stderr.write(`puppet-mcp: error: ${message}\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ function log(message) {
60
+ process.stderr.write(`puppet-mcp: ${message}\n`);
61
+ }
62
+
63
+ function loadManifest() {
64
+ let raw;
65
+ try {
66
+ raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
67
+ } catch (err) {
68
+ fail(`cannot read engine manifest at ${MANIFEST_PATH}: ${err.message}`);
69
+ }
70
+ try {
71
+ return JSON.parse(raw);
72
+ } catch (err) {
73
+ fail(`engine manifest is not valid JSON: ${err.message}`);
74
+ }
75
+ }
76
+
77
+ // A sha256 slot the release workflow has not filled yet. The committed
78
+ // manifest carries the sentinel for every hash computed at release time;
79
+ // the workflow replaces each with the real 64-hex digest. Anything else
80
+ // shaped (64 hex chars) is release-provisioned.
81
+ function isPlaceholderSha(sha) {
82
+ return typeof sha !== 'string' || !/^[0-9a-fA-F]{64}$/.test(sha);
83
+ }
84
+
85
+ function shortHash(sha) {
86
+ return isPlaceholderSha(sha) ? 'unpublished' : sha.toLowerCase().slice(0, 8);
87
+ }
88
+
89
+ // Resolved runtime cache directory. Never throws: the diagnostic path must
90
+ // print SOMETHING and exit 0 even where provisioning could never run.
91
+ function resolveRuntimeDir(manifest) {
92
+ const notes = [];
93
+ let root = process.env[RUNTIME_CACHE_OVERRIDE_ENV];
94
+ let overridden = Boolean(root);
95
+ if (!root) {
96
+ root = process.env.LOCALAPPDATA;
97
+ if (!root) notes.push('LOCALAPPDATA is unset; set it or ' + RUNTIME_CACHE_OVERRIDE_ENV);
98
+ }
99
+ const leaf = `${manifest.serverVersion}-${shortHash(manifest.runtime.archive.sha256)}`;
100
+ if (!root) return { dir: path.join('<unresolvable>', 'puppet', 'engine', leaf), overridden, notes };
101
+ return { dir: path.join(root, 'puppet', 'engine', leaf), overridden, notes };
102
+ }
103
+
104
+ function printDoctor(manifest) {
105
+ const runtime = resolveRuntimeDir(manifest);
106
+ const report = {
107
+ package: manifest.package,
108
+ serverVersion: manifest.serverVersion,
109
+ platform: process.platform,
110
+ node: process.version,
111
+ runtimeCacheDir: runtime.dir,
112
+ runtimeCacheOverridden: runtime.overridden,
113
+ runtimeCacheNotes: runtime.notes,
114
+ runtime: manifest.runtime,
115
+ network: 'none: this diagnostic performs no fetch and exits before any download code',
116
+ };
117
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
118
+ process.exit(0);
119
+ }
120
+
121
+ function usage(exitCode, stream) {
122
+ const text = [
123
+ 'usage: puppet-mcp [--doctor | --print-manifest | --help]',
124
+ '',
125
+ ' (no args) provision the pinned server on first run, then serve MCP over stdio',
126
+ ' --doctor, print the pinned manifest and the resolved cache',
127
+ ' --print-manifest path, perform no network access, exit 0',
128
+ ' --help, -h print this usage',
129
+ '',
130
+ 'runtime cache: ' + path.join('%LOCALAPPDATA%', 'puppet', 'engine', '<serverVersion>-<bundleSha8>') + ` (override: ${RUNTIME_CACHE_OVERRIDE_ENV})`,
131
+ `platform: windows-x86_64 only (override: ${PLATFORM_OVERRIDE_ENV}=1, diagnostics only)`,
132
+ ].join('\n') + '\n';
133
+ stream.write(text);
134
+ process.exit(exitCode);
135
+ }
136
+
137
+ // --- minimal zip reader (stored + deflate, builtins only) -----------------
138
+ // The runtime bundle is an ordinary deflate zip written by PowerShell's
139
+ // Compress-Archive. Node has no builtin unzip, and this package is
140
+ // dependency-free by design, so the container parse is hand-rolled here
141
+ // (~120 lines) over zlib.inflateRawSync. Anything outside the artifact's
142
+ // actual shape (encryption, data descriptors, zip64, unknown methods) is an
143
+ // explicit error, never a silent misparse.
144
+
145
+ const CRC_TABLE = (() => {
146
+ const t = new Int32Array(256);
147
+ for (let n = 0; n < 256; n++) {
148
+ let c = n;
149
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
150
+ t[n] = c;
151
+ }
152
+ return t;
153
+ })();
154
+
155
+ function crc32(buf) {
156
+ let c = 0xffffffff;
157
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
158
+ return (c ^ 0xffffffff) >>> 0;
159
+ }
160
+
161
+ function findEocd(buf) {
162
+ // EOCD is >= 22 bytes at the very end, with up to 64 KiB of comment.
163
+ const lo = Math.max(0, buf.length - (65557 + 22));
164
+ for (let i = buf.length - 22; i >= lo; i--) {
165
+ if (buf.readUInt32LE(i) === 0x06054b50) return i;
166
+ }
167
+ return -1;
168
+ }
169
+
170
+ function extractZip(buf, destDir) {
171
+ const eocd = findEocd(buf);
172
+ if (eocd < 0) throw new Error('not a zip archive: end-of-central-directory not found');
173
+ const entries = buf.readUInt16LE(eocd + 10);
174
+ const cdSize = buf.readUInt32LE(eocd + 12);
175
+ const cdOff = buf.readUInt32LE(eocd + 16);
176
+ if (entries === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) {
177
+ throw new Error('zip64 archives are not supported');
178
+ }
179
+ if (entries > MAX_ENTRIES) throw new Error(`too many entries: ${entries}`);
180
+ if (cdOff + cdSize > buf.length) throw new Error('central directory runs past end of buffer');
181
+
182
+ let total = 0;
183
+ const extracted = [];
184
+ let p = cdOff;
185
+ for (let n = 0; n < entries; n++) {
186
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`bad central-directory entry ${n}`);
187
+ const flags = buf.readUInt16LE(p + 8);
188
+ const method = buf.readUInt16LE(p + 10);
189
+ const crc = buf.readUInt32LE(p + 16);
190
+ const compSize = buf.readUInt32LE(p + 20);
191
+ const uncompSize = buf.readUInt32LE(p + 24);
192
+ const nameLen = buf.readUInt16LE(p + 28);
193
+ const extraLen = buf.readUInt16LE(p + 30);
194
+ const commentLen = buf.readUInt16LE(p + 32);
195
+ const localOff = buf.readUInt32LE(p + 42);
196
+ const rawName = buf.toString('utf8', p + 46, p + 46 + nameLen);
197
+ p += 46 + nameLen + extraLen + commentLen;
198
+
199
+ if (flags & 0x0001) throw new Error(`encrypted entry unsupported: ${rawName}`);
200
+ if (flags & 0x0008) throw new Error(`data-descriptor entry unsupported: ${rawName}`);
201
+ if (method !== 0 && method !== 8) throw new Error(`compression method ${method} unsupported: ${rawName}`);
202
+ if (compSize === 0xffffffff || uncompSize === 0xffffffff) throw new Error(`zip64 entry unsupported: ${rawName}`);
203
+ if (uncompSize > MAX_ENTRY_SIZE) throw new Error(`entry too large: ${rawName}`);
204
+ total += uncompSize;
205
+ if (total > MAX_TOTAL_UNCOMPRESSED) throw new Error('archive uncompressed total exceeds cap');
206
+
207
+ // Traversal guard: zip names normally use forward slashes, but
208
+ // PowerShell's Compress-Archive can write nested entry names
209
+ // with backslashes. Normalize every backslash to `/` FIRST, then run
210
+ // the unchanged checks below on the normalized name: absolute paths,
211
+ // drive letters, and `..` segments stay rejected, and the resolved
212
+ // target must still sit under destDir.
213
+ // (Spelled via char code so the source never holds a literal
214
+ // double-backslash.)
215
+ const BACKSLASH = String.fromCharCode(92);
216
+ const name = rawName.split(BACKSLASH).join('/');
217
+ if (name.startsWith('/') || /^[A-Za-z]:/.test(name)) {
218
+ throw new Error(`unsafe entry name: ${rawName}`);
219
+ }
220
+ const parts = name.split('/').filter((s) => s !== '' && s !== '.');
221
+ if (parts.length === 0 || parts.includes('..')) throw new Error(`unsafe entry name: ${rawName}`);
222
+ const target = path.join(destDir, ...parts);
223
+ const rel = path.relative(destDir, target);
224
+ if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`entry escapes destination: ${rawName}`);
225
+ const isDir = name.endsWith('/');
226
+ if (isDir) {
227
+ fs.mkdirSync(target, { recursive: true });
228
+ continue;
229
+ }
230
+
231
+ if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error(`bad local header: ${rawName}`);
232
+ const lhNameLen = buf.readUInt16LE(localOff + 26);
233
+ const lhExtraLen = buf.readUInt16LE(localOff + 28);
234
+ const dataOff = localOff + 30 + lhNameLen + lhExtraLen;
235
+ const comp = buf.subarray(dataOff, dataOff + compSize);
236
+ if (comp.length !== compSize) throw new Error(`truncated entry data: ${rawName}`);
237
+ const data = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
238
+ if (data.length !== uncompSize) throw new Error(`size mismatch after inflate: ${rawName}`);
239
+ if (crc32(data) !== crc) throw new Error(`crc mismatch: ${rawName}`);
240
+ fs.mkdirSync(path.dirname(target), { recursive: true });
241
+ fs.writeFileSync(target, data);
242
+ extracted.push(parts.join('/'));
243
+ }
244
+ return extracted;
245
+ }
246
+
247
+ // --- provisioning ---------------------------------------------------------
248
+
249
+ function sha256File(filePath) {
250
+ const h = crypto.createHash('sha256');
251
+ h.update(fs.readFileSync(filePath));
252
+ return h.digest('hex');
253
+ }
254
+
255
+ function checkFile(filePath, want) {
256
+ // want: {name, sha256, size}. Returns null when good, else a reason.
257
+ let st;
258
+ try {
259
+ st = fs.statSync(filePath);
260
+ } catch {
261
+ return 'missing';
262
+ }
263
+ if (!st.isFile()) return 'not a file';
264
+ if (st.size !== want.size) return `size ${st.size} != manifest ${want.size}`;
265
+ const got = sha256File(filePath);
266
+ if (got !== want.sha256.toLowerCase()) return 'sha256 mismatch';
267
+ return null;
268
+ }
269
+
270
+ // The ONLY fetch call site in this package. The diagnostic path returns
271
+ // before the ensure* functions, so --doctor/--print-manifest provably reach
272
+ // no network: grep `fetch(` — it appears exactly once, here.
273
+ async function downloadToMemory(url) {
274
+ const ctrl = new AbortController();
275
+ const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
276
+ let res;
277
+ try {
278
+ res = await fetch(url, { signal: ctrl.signal });
279
+ } catch (err) {
280
+ throw new Error(`download failed: ${err.message}`);
281
+ } finally {
282
+ clearTimeout(timer);
283
+ }
284
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
285
+ return Buffer.from(await res.arrayBuffer());
286
+ }
287
+
288
+ async function ensureRuntime(manifest, cacheDir) {
289
+ const rt = manifest.runtime;
290
+ if (isPlaceholderSha(rt.archive.sha256) || !(rt.archive.size > 0)) {
291
+ fail(
292
+ 'this package was not published by the release workflow: runtime hashes are placeholders. ' +
293
+ 'See docs/PUBLISHING.md: publish only the workflow-built npm-package artifact.'
294
+ );
295
+ }
296
+ if (isPlaceholderSha(rt.server.sha256) || !(rt.server.size > 0)) {
297
+ fail('engine manifest has no release-provisioned server hash; refusing to run.');
298
+ }
299
+
300
+ const markerPath = path.join(cacheDir, '.ready');
301
+ const reuse = (() => {
302
+ try {
303
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
304
+ if (marker.serverVersion !== manifest.serverVersion) return false;
305
+ if (String(marker.bundleSha256).toLowerCase() !== rt.archive.sha256.toLowerCase()) return false;
306
+ } catch {
307
+ return false;
308
+ }
309
+ const bad = checkFile(path.join(cacheDir, rt.server.name), rt.server);
310
+ if (bad) {
311
+ log(`cached ${rt.server.name} invalid (${bad}); re-provisioning.`);
312
+ return false;
313
+ }
314
+ return true;
315
+ })();
316
+ if (reuse) {
317
+ log(`runtime cache ready (reused): ${cacheDir}`);
318
+ return cacheDir;
319
+ }
320
+
321
+ const url = `${rt.source}/${rt.archive.name}`;
322
+ log(`provisioning runtime cache: ${cacheDir}`);
323
+ log(`downloading ${url} (${rt.archive.size} bytes)`);
324
+ let data;
325
+ try {
326
+ data = await downloadToMemory(url);
327
+ } catch (err) {
328
+ fail(err.message);
329
+ }
330
+ if (data.length !== rt.archive.size) {
331
+ fail(`download size ${data.length} != manifest ${rt.archive.size}; wrote nothing.`);
332
+ }
333
+ const digest = crypto.createHash('sha256').update(data).digest('hex');
334
+ if (digest !== rt.archive.sha256.toLowerCase()) {
335
+ fail('download sha256 mismatch; wrote nothing.');
336
+ }
337
+
338
+ // Extract into a staging dir, verify, then rename: the target dir is
339
+ // never observed half-written.
340
+ const staging = `${cacheDir}.staging-${process.pid}`;
341
+ fs.rmSync(staging, { recursive: true, force: true });
342
+ fs.rmSync(cacheDir, { recursive: true, force: true });
343
+ fs.mkdirSync(staging, { recursive: true });
344
+ try {
345
+ const names = extractZip(data, staging);
346
+ log(`extracted ${names.length} entries`);
347
+ const bad = checkFile(path.join(staging, rt.server.name), rt.server);
348
+ if (bad) throw new Error(`extracted ${rt.server.name} invalid (${bad})`);
349
+ fs.renameSync(staging, cacheDir);
350
+ fs.writeFileSync(
351
+ markerPath,
352
+ JSON.stringify({ serverVersion: manifest.serverVersion, bundleSha256: rt.archive.sha256.toLowerCase() }, null, 2)
353
+ );
354
+ } catch (err) {
355
+ fs.rmSync(staging, { recursive: true, force: true });
356
+ fs.rmSync(cacheDir, { recursive: true, force: true });
357
+ fail(`provisioning failed (${err.message}); wrote nothing.`);
358
+ }
359
+ log(`runtime cache ready (provisioned): ${cacheDir}`);
360
+ return cacheDir;
361
+ }
362
+
363
+ function runServer(cacheDir, manifest) {
364
+ const exe = path.join(cacheDir, manifest.runtime.server.name);
365
+ if (!fs.existsSync(exe)) {
366
+ fail(`server binary missing after provisioning: ${exe}`);
367
+ }
368
+ // Bare puppet with no args serves MCP over stdio (src/main.rs: the server
369
+ // path below the --version early-return). stdio is inherited so the child
370
+ // IS the MCP channel; the launcher writes nothing to stdout on this path.
371
+ // Ctrl+C reaches both processes on Windows (shared console), so no
372
+ // signal relay is needed; the exit code is forwarded below.
373
+ const child = spawn(exe, [], { stdio: 'inherit', env: { ...process.env } });
374
+ child.on('error', (err) => fail(`cannot start ${exe}: ${err.message}`));
375
+ child.on('exit', (code, signal) => {
376
+ if (signal) {
377
+ log(`server terminated by signal ${signal}`);
378
+ process.exit(1);
379
+ }
380
+ process.exit(code === null ? 1 : code);
381
+ });
382
+ }
383
+
384
+ async function main() {
385
+ const args = process.argv.slice(2);
386
+ const manifest = loadManifest();
387
+ if (manifest.package !== 'puppet-mcp') fail('engine manifest names the wrong package.');
388
+ if (args.includes('--help') || args.includes('-h')) usage(0, process.stdout);
389
+ if (args.includes('--doctor') || args.includes('--print-manifest')) {
390
+ const known = new Set(['--doctor', '--print-manifest']);
391
+ const unknown = args.filter((a) => !known.has(a));
392
+ if (unknown.length > 0) usage(2, process.stderr);
393
+ printDoctor(manifest); // exits 0; no network on this path by construction
394
+ }
395
+ // Every known flag returns above; anything left is unknown.
396
+ if (args.length > 0) usage(2, process.stderr);
397
+
398
+ if (process.platform !== 'win32' && process.env[PLATFORM_OVERRIDE_ENV] !== '1') {
399
+ fail(
400
+ `unsupported platform '${process.platform}': puppet-mcp ships the windows-x86_64 server only. ` +
401
+ `Set ${PLATFORM_OVERRIDE_ENV}=1 to bypass this gate for diagnostics (the server still will not run elsewhere).`
402
+ );
403
+ }
404
+
405
+ const runtime = resolveRuntimeDir(manifest);
406
+ if (runtime.dir.startsWith('<unresolvable>')) {
407
+ fail(`cannot resolve runtime cache dir: ${runtime.notes.join('; ')}. Set ${RUNTIME_CACHE_OVERRIDE_ENV} to an absolute directory.`);
408
+ }
409
+ const readyRuntime = await ensureRuntime(manifest, runtime.dir);
410
+ runServer(readyRuntime, manifest);
411
+ }
412
+
413
+ main().catch((err) => fail(err && err.message ? err.message : String(err)));
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "_comment": "Pinned server manifest for the puppet-mcp launcher. The published package holds ONLY this launcher plus this manifest (no server bytes). On first run the launcher downloads ONLY runtime.archive from the tagged GitHub Release, verifying its sha256 before and after extraction. The release workflow fills the runtime.* hashes it computes from the real build artifacts; the committed manifest carries placeholders the launcher refuses to run with.",
3
3
  "package": "puppet-mcp",
4
- "serverVersion": "4.3.0",
4
+ "serverVersion": "4.4.1",
5
5
  "runtime": {
6
- "source": "https://github.com/nathwn12/puppet/releases/download/v4.3.0",
6
+ "source": "https://github.com/nathwn12/puppet/releases/download/v4.4.1",
7
7
  "archive": {
8
- "name": "puppet-v4.3.0-windows-x86_64.zip",
9
- "sha256": "fb64844f561e46adfc2727ed4c8d598073c69c3d7046944711521b8d85dbf904",
10
- "size": 560128
8
+ "name": "puppet-v4.4.1-windows-x86_64.zip",
9
+ "sha256": "75118f351735b9d73c98a116b487c5c2c6db090cd34dd5d9ce85defc00bbd583",
10
+ "size": 565857
11
11
  },
12
12
  "server": {
13
13
  "name": "puppet.exe",
14
- "sha256": "f30ab22951efa73257bd108c36b51cde16308841e25cdbc6142086552b45b105",
15
- "size": 1451520
14
+ "sha256": "3fd3132647b273193386695889d66a2e6d1e0768772eb9efd824f3f465a2fbd6",
15
+ "size": 1481216
16
16
  }
17
17
  }
18
18
  }
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
- {
2
- "name": "puppet-mcp",
3
- "version": "4.3.0",
4
- "description": "Windows desktop-control MCP server (npx launcher): provisions the pinned server binary from the GitHub Release on first run, then serves MCP over stdio",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "https://github.com/nathwn12/puppet"
9
- },
10
- "homepage": "https://github.com/nathwn12/puppet",
11
- "engines": {
12
- "node": ">=22.14.0"
13
- },
14
- "bin": {
15
- "puppet-mcp": "./bin/puppet-mcp.js"
16
- },
17
- "files": [
18
- "bin/",
19
- "engine-manifest.json",
20
- "LICENSE",
21
- "README.md"
22
- ]
23
- }
1
+ {
2
+ "name": "puppet-mcp",
3
+ "version": "4.4.1",
4
+ "description": "Windows desktop-control MCP server (npx launcher): provisions the pinned server binary from the GitHub Release on first run, then serves MCP over stdio",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nathwn12/puppet"
9
+ },
10
+ "homepage": "https://github.com/nathwn12/puppet",
11
+ "engines": {
12
+ "node": ">=22.14.0"
13
+ },
14
+ "bin": {
15
+ "puppet-mcp": "./bin/puppet-mcp.js"
16
+ },
17
+ "files": [
18
+ "bin/",
19
+ "engine-manifest.json",
20
+ "LICENSE",
21
+ "README.md"
22
+ ]
23
+ }