@cloudgrid-io/mcp 0.7.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "smoke:oauth": "node test/smoke-oauth.mjs",
27
27
  "test:auth": "node test/auth.test.mjs",
28
28
  "test:win-cli": "node test/windows-cli-resolution.test.mjs",
29
+ "test:electron-env": "node test/electron-spawn-env.test.mjs",
29
30
  "smoke:docs": "node test/smoke-docs.mjs",
30
31
  "start:docs": "node src/docs.js",
31
32
  "snapshot:corpus": "node scripts/snapshot-corpus.mjs",
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // cli-shim.mjs — runs the bundled CloudGrid CLI as a child of the MCP server.
3
+ //
4
+ // <runtime> cli-shim.mjs <cli-entry> [cli args...]
5
+ //
6
+ // Why the MCP server spawns this shim instead of the CLI entry directly:
7
+ //
8
+ // Under Electron MCP hosts (Claude Desktop runs extensions inside an Electron
9
+ // utility process) `process.execPath` is the host's Electron binary, not Node.
10
+ // The server spawns the CLI with ELECTRON_RUN_AS_NODE=1 so an Electron runtime
11
+ // boots as Node — but that mode still exposes `process.versions.electron`, and
12
+ // commander (the CLI's argument parser) detects it: with `process.defaultApp`
13
+ // undefined it reads user arguments from `argv.slice(1)`, so the CLI entry path
14
+ // itself would be parsed as the command ("error: unknown command '/…/cli/dist/
15
+ // index.js'"). Under plain Node commander reads `argv.slice(2)` instead. This
16
+ // shim rewrites process.argv so BOTH slicings see exactly the user arguments,
17
+ // then imports the real CLI entry in-process.
18
+ //
19
+ // It also strips ELECTRON_RUN_AS_NODE from the environment: the variable has
20
+ // done its job once this file is executing, and letting it leak further would
21
+ // break anything Electron-based the CLI spawns later (e.g. a browser opened
22
+ // for `cloudgrid login` would silently boot as a headless Node process).
23
+
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ delete process.env.ELECTRON_RUN_AS_NODE;
27
+
28
+ const [, , entry, ...args] = process.argv;
29
+ if (!entry) {
30
+ console.error("cli-shim: missing CLI entry path");
31
+ process.exit(2);
32
+ }
33
+
34
+ if (process.versions?.electron && !process.defaultApp) {
35
+ // Electron-as-Node: commander takes user args from argv.slice(1) — the entry
36
+ // path must NOT appear there.
37
+ process.argv = [process.argv[0], ...args];
38
+ } else {
39
+ // Plain Node: commander takes the script path from argv[1] and user args
40
+ // from argv.slice(2) — the standard `node script.js args...` shape.
41
+ process.argv = [process.argv[0], entry, ...args];
42
+ }
43
+
44
+ try {
45
+ await import(pathToFileURL(entry).href);
46
+ } catch (err) {
47
+ console.error(err?.stack || String(err));
48
+ process.exit(1);
49
+ }
package/src/tools.js CHANGED
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
15
15
  import { basename, dirname, resolve, join } from "node:path";
16
+ import { homedir } from "node:os";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { z } from "zod";
18
19
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
@@ -293,16 +294,202 @@ function winQuoteArg(arg) {
293
294
  }
294
295
 
295
296
  // execFile a command that may be a Windows `.cmd` shim, safely on every OS.
296
- function execMaybeCmd(command, args, options) {
297
- if (!IS_WIN) return execFileAsync(command, args, options);
297
+ function execMaybeCmd(command, args, options, exec = execFileAsync) {
298
+ if (!IS_WIN) return exec(command, args, options);
298
299
  const line = [command, ...args].map(winQuoteArg).join(" ");
299
- return execFileAsync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
300
+ return exec(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
300
301
  ...options,
301
302
  windowsVerbatimArguments: true,
302
303
  });
303
304
  }
304
305
 
305
- async function runCloudgrid(args, opts = {}) {
306
+ // ── Electron-safe Node runtime resolution ─────────────────────────────────────
307
+ //
308
+ // Claude Desktop (and other Electron MCP hosts) run this server inside an
309
+ // Electron utility process, so `process.execPath` is the host's Electron helper
310
+ // binary — NOT a Node interpreter. Spawning it as if it were Node either dies
311
+ // with FATAL "Unable to find helper app" (Claude Desktop's helper binary) or
312
+ // boots a GUI Electron app that hangs until the exec timeout and then reports a
313
+ // fake success. ELECTRON_RUN_AS_NODE=1 only helps when the host's Electron
314
+ // build keeps the runAsNode fuse enabled — Claude Desktop's does NOT (verified:
315
+ // the helper FATALs identically with the variable set). So the bundled-CLI rung
316
+ // must (a) never blindly spawn process.execPath under Electron, (b) prefer a
317
+ // real Node binary probed from common install locations (the utility process
318
+ // PATH is a bare /usr/bin:/bin:/usr/sbin:/sbin on macOS), and (c) use
319
+ // execPath-as-node only after a quick verified run-as-node probe.
320
+
321
+ const CLI_SHIM = fileURLToPath(new URL("./cli-shim.mjs", import.meta.url));
322
+
323
+ const NO_NODE_HINT =
324
+ "No usable Node.js runtime found for spawning the CloudGrid CLI (this MCP " +
325
+ "host runs on Electron, whose binary cannot execute Node scripts). Install " +
326
+ "Node.js (https://nodejs.org) or the CloudGrid CLI (npm install -g " +
327
+ "@cloudgrid-io/cli) and try again.";
328
+
329
+ // True when `execPath` is a real Node interpreter we can hand a script to.
330
+ function execPathIsRealNode({ execPath, versions, platform }) {
331
+ if (versions?.electron) return false;
332
+ const name = basename(execPath).toLowerCase();
333
+ return platform === "win32" ? name === "node.exe" || name === "node" : name === "node";
334
+ }
335
+
336
+ // Pick the highest semver-looking entry of a directory (nvm/fnm version dirs).
337
+ function highestVersionEntry(dir, fsReaddir) {
338
+ try {
339
+ const versions = fsReaddir(dir)
340
+ .filter((d) => /^v?\d+\.\d+\.\d+$/.test(d))
341
+ .sort((a, b) => {
342
+ const pa = a.replace(/^v/, "").split(".").map(Number);
343
+ const pb = b.replace(/^v/, "").split(".").map(Number);
344
+ return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2];
345
+ });
346
+ return versions.pop() || null;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ // Candidate real-Node binaries, most likely first. GUI-launched Electron hosts
353
+ // get a bare PATH, so fixed install locations and version-manager dirs are
354
+ // probed explicitly.
355
+ function listNodeCandidates({ platform, env, home, fsReaddir }) {
356
+ const out = [];
357
+ const binName = platform === "win32" ? "node.exe" : "node";
358
+ for (const dir of (env.PATH || "").split(platform === "win32" ? ";" : ":")) {
359
+ if (dir) out.push(join(dir, binName));
360
+ }
361
+ if (platform === "win32") {
362
+ for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
363
+ if (base) out.push(join(base, "nodejs", "node.exe"));
364
+ }
365
+ if (env.NVM_SYMLINK) out.push(join(env.NVM_SYMLINK, "node.exe"));
366
+ if (env.LOCALAPPDATA) out.push(join(env.LOCALAPPDATA, "Volta", "bin", "node.exe"));
367
+ return out;
368
+ }
369
+ out.push("/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node", "/opt/local/bin/node");
370
+ if (home) {
371
+ out.push(join(home, ".volta", "bin", "node"));
372
+ const nvmVersions = join(env.NVM_DIR || join(home, ".nvm"), "versions", "node");
373
+ const nvmBest = highestVersionEntry(nvmVersions, fsReaddir);
374
+ if (nvmBest) out.push(join(nvmVersions, nvmBest, "bin", "node"));
375
+ for (const base of [
376
+ env.FNM_DIR,
377
+ join(home, ".local", "share", "fnm"),
378
+ join(home, "Library", "Application Support", "fnm"),
379
+ join(home, ".fnm"),
380
+ ]) {
381
+ if (!base) continue;
382
+ out.push(join(base, "aliases", "default", "bin", "node"));
383
+ const fnmVersions = join(base, "node-versions");
384
+ const fnmBest = highestVersionEntry(fnmVersions, fsReaddir);
385
+ if (fnmBest) out.push(join(fnmVersions, fnmBest, "installation", "bin", "node"));
386
+ }
387
+ }
388
+ return out;
389
+ }
390
+
391
+ // Verify a candidate actually behaves like Node (guards against broken shims
392
+ // and against Electron's fake-success mode where a timed-out GUI boot exits 0).
393
+ async function verifyNodeRuns(exec, command, env) {
394
+ try {
395
+ const { stdout } = await exec(command, ["-p", "process.version"], {
396
+ timeout: 5_000,
397
+ env,
398
+ stdio: ["ignore", "pipe", "pipe"],
399
+ });
400
+ return /^v\d+\./.test(String(stdout).trim());
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ // Resolve the Node runtime used to spawn the bundled CLI. Returns
407
+ // { command, kind } or null when no usable runtime exists.
408
+ //
409
+ // The `deps` seam exists ONLY so the Electron regression tests can drive the
410
+ // Electron code paths deterministically on a plain-Node CI box. Production
411
+ // (runCloudgrid) only ever injects the real spawner, so the result is memoized.
412
+ let runtimeCache; // undefined = not yet resolved; null = resolved to "none"
413
+ export async function resolveNodeRuntime(deps = {}) {
414
+ // Cache unless the environment itself is simulated (`exec` is just the
415
+ // spawner) — probing spawns children, and the answer cannot change within
416
+ // one server process.
417
+ const cacheable = Object.keys(deps).every((k) => k === "exec");
418
+ if (cacheable && runtimeCache !== undefined) return runtimeCache;
419
+ const {
420
+ exec = execFileAsync,
421
+ execPath = process.execPath,
422
+ versions = process.versions,
423
+ platform = process.platform,
424
+ env = process.env,
425
+ home = homedir(),
426
+ fsExists = existsSync,
427
+ fsReaddir = readdirSync,
428
+ } = deps;
429
+
430
+ let result = null;
431
+ if (execPathIsRealNode({ execPath, versions, platform })) {
432
+ result = { command: execPath, kind: "exec-path" };
433
+ } else {
434
+ // 1. A real Node binary somewhere on this machine.
435
+ for (const candidate of listNodeCandidates({ platform, env, home, fsReaddir })) {
436
+ if (!fsExists(candidate)) continue;
437
+ if (await verifyNodeRuns(exec, candidate, env)) {
438
+ result = { command: candidate, kind: "system-node" };
439
+ break;
440
+ }
441
+ }
442
+ // 2. Last resort: execPath with ELECTRON_RUN_AS_NODE — only if a quick
443
+ // probe proves the host's runAsNode fuse is enabled (Claude Desktop's
444
+ // is not; vanilla Electron's is).
445
+ if (!result) {
446
+ const probeEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" };
447
+ if (await verifyNodeRuns(exec, execPath, probeEnv)) {
448
+ result = { command: execPath, kind: "electron-run-as-node" };
449
+ }
450
+ }
451
+ }
452
+ if (cacheable) runtimeCache = result;
453
+ return result;
454
+ }
455
+
456
+ // A step-1 error that means "the runtime never ran the CLI" (fall through to
457
+ // the global-CLI/npx rungs) as opposed to "the CLI ran and reported an error"
458
+ // (surface it).
459
+ function isRuntimeBootFailure(err) {
460
+ if (!err) return false;
461
+ if (err.code === "ENOENT" || err.code === "EACCES") return true;
462
+ const text = `${err.stderr || ""}\n${err.message || ""}`;
463
+ return text.includes("Unable to find helper app") || /FATAL:.*electron/i.test(text);
464
+ }
465
+
466
+ // Environment for the global-CLI and npx fallback rungs. GUI-launched Electron
467
+ // hosts hand utility processes a bare PATH (macOS: /usr/bin:/bin:/usr/sbin:/sbin)
468
+ // that misses Homebrew/npm bin dirs, so `cloudgrid`/`npx` would be ENOENT even
469
+ // when installed — append the usual install locations. ELECTRON_RUN_AS_NODE is
470
+ // stripped so it cannot leak through a real-node CLI into anything it spawns.
471
+ function pathAugmentedEnv(env = process.env, platform = process.platform, home = homedir()) {
472
+ const out = { ...env };
473
+ delete out.ELECTRON_RUN_AS_NODE;
474
+ if (platform === "win32") return out;
475
+ const extras = ["/usr/local/bin", "/opt/homebrew/bin", join(home, ".volta", "bin"), join(home, ".local", "bin")];
476
+ const parts = (out.PATH || "").split(":").filter(Boolean);
477
+ for (const dir of extras) {
478
+ if (!parts.includes(dir)) parts.push(dir);
479
+ }
480
+ out.PATH = parts.join(":");
481
+ return out;
482
+ }
483
+
484
+ // The `deps` seam mirrors resolveBundledCli's: it exists ONLY so regression
485
+ // tests can spy the exec calls and simulate Electron hosts deterministically.
486
+ // Production always calls this with no deps.
487
+ export async function runCloudgrid(args, opts = {}, deps = {}) {
488
+ const {
489
+ exec = execFileAsync,
490
+ resolveCli = resolveBundledCli,
491
+ resolveRuntime = resolveNodeRuntime,
492
+ } = deps;
306
493
  const cwd = resolveCwd(opts.cwd);
307
494
  const execOpts = {
308
495
  maxBuffer: 16 * 1024 * 1024,
@@ -310,6 +497,11 @@ async function runCloudgrid(args, opts = {}) {
310
497
  stdio: ["ignore", "pipe", "pipe"],
311
498
  ...(cwd ? { cwd } : {}),
312
499
  };
500
+ // PATH-augmented, ELECTRON_RUN_AS_NODE-free env for the fallback rungs.
501
+ const fallbackEnv = pathAugmentedEnv();
502
+ // Set when the bundled CLI had to be skipped for lack of a Node runtime, so
503
+ // the final error names the real cause instead of a raw npx/Electron error.
504
+ let runtimeHint = null;
313
505
 
314
506
  const extract = (err) => {
315
507
  const detail = [err && err.stdout, err && err.stderr, err && err.message]
@@ -319,18 +511,35 @@ async function runCloudgrid(args, opts = {}) {
319
511
  return new Error(detail || "cloudgrid command failed");
320
512
  };
321
513
 
322
- // 1. Bundled CLI — own node_modules only, version-gated
323
- const bundled = resolveBundledCli();
514
+ // 1. Bundled CLI — own node_modules only, version-gated. Runs via
515
+ // cli-shim.mjs under a verified Node runtime (see resolveNodeRuntime):
516
+ // process.execPath is NOT assumed to be Node, because under Electron MCP
517
+ // hosts (Claude Desktop) it is the host's Electron helper binary.
518
+ // ELECTRON_RUN_AS_NODE=1 makes an Electron runtime boot as Node and is
519
+ // ignored by plain Node; the shim strips it again before the CLI runs and
520
+ // fixes commander's Electron argv slicing.
521
+ const bundled = resolveCli();
324
522
  if (bundled && meetsMinVersion(bundled.version)) {
325
- try {
326
- const { stdout, stderr } = await execFileAsync(
327
- process.execPath,
328
- [bundled.entry, ...args],
329
- execOpts,
330
- );
331
- return (stdout || stderr || "").trim() || "Done.";
332
- } catch (err) {
333
- throw extract(err);
523
+ const runtime = await resolveRuntime({ exec });
524
+ if (runtime) {
525
+ try {
526
+ const { stdout, stderr } = await exec(
527
+ runtime.command,
528
+ [CLI_SHIM, bundled.entry, ...args],
529
+ { ...execOpts, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
530
+ );
531
+ return (stdout || stderr || "").trim() || "Done.";
532
+ } catch (err) {
533
+ // A CLI-reported error surfaces; a runtime that failed to boot falls
534
+ // through to the global-CLI/npx rungs instead of killing the chain.
535
+ if (!isRuntimeBootFailure(err)) throw extract(err);
536
+ console.error(
537
+ `cloudgrid-mcp: bundled CLI spawn failed (${err?.code || "runtime boot failure"}); trying global CLI, then npx`,
538
+ );
539
+ }
540
+ } else {
541
+ runtimeHint = NO_NODE_HINT;
542
+ console.error(`cloudgrid-mcp: ${NO_NODE_HINT} Trying global CLI, then npx.`);
334
543
  }
335
544
  } else if (bundled) {
336
545
  console.error(
@@ -344,10 +553,16 @@ async function runCloudgrid(args, opts = {}) {
344
553
  const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
345
554
  let useGlobal = false;
346
555
  try {
347
- const { stdout: vOut } = await execMaybeCmd(globalBin, ["--version"], {
348
- timeout: 5_000,
349
- stdio: ["ignore", "pipe", "pipe"],
350
- });
556
+ const { stdout: vOut } = await execMaybeCmd(
557
+ globalBin,
558
+ ["--version"],
559
+ {
560
+ timeout: 5_000,
561
+ stdio: ["ignore", "pipe", "pipe"],
562
+ env: fallbackEnv,
563
+ },
564
+ exec,
565
+ );
351
566
  const ver = vOut.trim().match(/(\d+\.\d+\.\d+)/)?.[1];
352
567
  if (meetsMinVersion(ver)) {
353
568
  useGlobal = true;
@@ -361,7 +576,12 @@ async function runCloudgrid(args, opts = {}) {
361
576
  }
362
577
  if (useGlobal) {
363
578
  try {
364
- const { stdout, stderr } = await execMaybeCmd(globalBin, args, execOpts);
579
+ const { stdout, stderr } = await execMaybeCmd(
580
+ globalBin,
581
+ args,
582
+ { ...execOpts, env: fallbackEnv },
583
+ exec,
584
+ );
365
585
  return (stdout || stderr || "").trim() || "Done.";
366
586
  } catch (err) {
367
587
  throw extract(err);
@@ -379,11 +599,14 @@ async function runCloudgrid(args, opts = {}) {
379
599
  const { stdout, stderr } = await execMaybeCmd(
380
600
  npx,
381
601
  ["-y", CLI_NPX_PKG, ...args],
382
- execOpts,
602
+ { ...execOpts, env: fallbackEnv },
603
+ exec,
383
604
  );
384
605
  return (stdout || stderr || "").trim() || "Done.";
385
606
  } catch (err) {
386
- throw extract(err);
607
+ const failure = extract(err);
608
+ if (runtimeHint) failure.message = `${runtimeHint}\n\n${failure.message}`;
609
+ throw failure;
387
610
  }
388
611
  }
389
612
 
@@ -407,8 +630,13 @@ function tryOpenBrowser(url) {
407
630
  if (process.env.CLOUDGRID_NO_BROWSER === "1") return;
408
631
  const cmd =
409
632
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
633
+ // Strip ELECTRON_RUN_AS_NODE (present when the server itself runs under
634
+ // Electron-as-Node) so an Electron-based browser doesn't boot as a headless
635
+ // Node process instead of opening a window.
636
+ const env = { ...process.env };
637
+ delete env.ELECTRON_RUN_AS_NODE;
410
638
  try {
411
- execFile(cmd, [url], () => {});
639
+ execFile(cmd, [url], { env }, () => {});
412
640
  } catch {
413
641
  // ignore — the URL is returned to the user anyway
414
642
  }