@cloudgrid-io/mcp 0.5.4 → 0.5.5

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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/tools.js +63 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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": {
@@ -24,6 +24,7 @@
24
24
  "smoke:redrop": "node test/smoke-redrop.mjs",
25
25
  "smoke:oauth": "node test/smoke-oauth.mjs",
26
26
  "test:auth": "node test/auth.test.mjs",
27
+ "test:win-cli": "node test/windows-cli-resolution.test.mjs",
27
28
  "smoke:docs": "node test/smoke-docs.mjs",
28
29
  "start:docs": "node src/docs.js",
29
30
  "snapshot:corpus": "node scripts/snapshot-corpus.mjs",
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 } from "node:fs";
15
15
  import { basename, dirname, resolve, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
16
17
  import { z } from "zod";
17
18
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
18
19
 
@@ -125,20 +126,67 @@ function meetsMinVersion(version) {
125
126
  // Resolve the bundled CLI from this package's OWN node_modules only — never
126
127
  // walk up to parent directories (which may contain stale CLI versions).
127
128
  // Returns { entry, version } or null.
128
- function resolveBundledCli() {
129
+ //
130
+ // `fileURLToPath` (not `new URL(url).pathname`) is required for Windows: on a
131
+ // Windows `file://` URL, `.pathname` is `/C:/Users/.../src` — a leading slash
132
+ // before the drive letter, which is NOT a valid fs path, so `existsSync` fails
133
+ // and the bundled CLI is never found even though the `.mcpb` bundles it.
134
+ // `fileURLToPath` yields a proper `C:\Users\...\src`. macOS/Linux are unaffected.
135
+ //
136
+ // The `deps` seam (moduleUrl + path/fs impls) exists ONLY so the Windows-URL
137
+ // regression test can drive the win32 code path deterministically on a POSIX CI
138
+ // box. Production always uses the real `import.meta.url` and host path/fs.
139
+ export function resolveBundledCli(deps = {}) {
140
+ const {
141
+ moduleUrl = import.meta.url,
142
+ urlToPath = fileURLToPath,
143
+ pathImpl = { dirname, join },
144
+ fsExists = existsSync,
145
+ fsRead = readFileSync,
146
+ } = deps;
129
147
  try {
130
- const srcDir = dirname(new URL(import.meta.url).pathname);
131
- const pkgPath = join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
132
- if (!existsSync(pkgPath)) return null;
133
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
148
+ const srcDir = pathImpl.dirname(urlToPath(moduleUrl));
149
+ const pkgPath = pathImpl.join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
150
+ if (!fsExists(pkgPath)) return null;
151
+ const pkg = JSON.parse(fsRead(pkgPath, "utf-8"));
134
152
  const bin = pkg.bin && pkg.bin.cloudgrid;
135
153
  if (!bin) return null;
136
- return { entry: join(dirname(pkgPath), bin), version: pkg.version || null };
154
+ return { entry: pathImpl.join(pathImpl.dirname(pkgPath), bin), version: pkg.version || null };
137
155
  } catch {
138
156
  return null;
139
157
  }
140
158
  }
141
159
 
160
+ // Windows `.cmd`/`.bat` shims (cloudgrid.cmd, npx.cmd) cannot be launched by
161
+ // `execFile` without a shell — modern patched Node (CVE-2024-27980) rejects it
162
+ // with EINVAL, and even where it doesn't, ENOENT results because the bare name
163
+ // resolves to a `.cmd`. On win32 we route those through `cmd.exe /d /s /c` with
164
+ // an explicitly quoted command line so args with spaces/quotes stay intact.
165
+ // macOS/Linux never take this branch — they keep the plain execFile path.
166
+ const IS_WIN = process.platform === "win32";
167
+
168
+ // Quote a single Windows command-line token for cmd.exe. Wrap in double quotes,
169
+ // escape embedded quotes/backslash-before-quote per Windows argv rules, and also
170
+ // caret-escape cmd.exe metacharacters so nothing is interpreted by the shell.
171
+ function winQuoteArg(arg) {
172
+ const s = String(arg);
173
+ // Escape backslashes that precede a quote, then the quotes themselves.
174
+ let inner = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
175
+ inner = `"${inner}"`;
176
+ // Caret-escape cmd.exe special chars (they live outside the quoted-arg parsing).
177
+ return inner.replace(/[()%!^"<>&|]/g, "^$&");
178
+ }
179
+
180
+ // execFile a command that may be a Windows `.cmd` shim, safely on every OS.
181
+ function execMaybeCmd(command, args, options) {
182
+ if (!IS_WIN) return execFileAsync(command, args, options);
183
+ const line = [command, ...args].map(winQuoteArg).join(" ");
184
+ return execFileAsync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
185
+ ...options,
186
+ windowsVerbatimArguments: true,
187
+ });
188
+ }
189
+
142
190
  async function runCloudgrid(args, opts = {}) {
143
191
  const cwd = resolveCwd(opts.cwd);
144
192
  const execOpts = {
@@ -175,11 +223,13 @@ async function runCloudgrid(args, opts = {}) {
175
223
  );
176
224
  }
177
225
 
178
- // 2. Global `cloudgrid` on PATH — version-gated
226
+ // 2. Global `cloudgrid` on PATH — version-gated.
227
+ // On Windows the bin is `cloudgrid.cmd`; run it via cmd.exe (execMaybeCmd).
179
228
  {
229
+ const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
180
230
  let useGlobal = false;
181
231
  try {
182
- const { stdout: vOut } = await execFileAsync("cloudgrid", ["--version"], {
232
+ const { stdout: vOut } = await execMaybeCmd(globalBin, ["--version"], {
183
233
  timeout: 5_000,
184
234
  stdio: ["ignore", "pipe", "pipe"],
185
235
  });
@@ -196,7 +246,7 @@ async function runCloudgrid(args, opts = {}) {
196
246
  }
197
247
  if (useGlobal) {
198
248
  try {
199
- const { stdout, stderr } = await execFileAsync("cloudgrid", args, execOpts);
249
+ const { stdout, stderr } = await execMaybeCmd(globalBin, args, execOpts);
200
250
  return (stdout || stderr || "").trim() || "Done.";
201
251
  } catch (err) {
202
252
  throw extract(err);
@@ -206,10 +256,12 @@ async function runCloudgrid(args, opts = {}) {
206
256
 
207
257
  // 3. Lazy fetch: npx -y @cloudgrid-io/cli@<pinned> <args>
208
258
  // One-time download; npx caches it for subsequent invocations.
259
+ // On Windows this is `npx.cmd`, run via cmd.exe (execMaybeCmd) so patched
260
+ // Node doesn't EINVAL on the `.cmd` shim.
209
261
  console.error("cloudgrid-mcp: fetching the CloudGrid CLI (first use)…");
210
- const npx = process.platform === "win32" ? "npx.cmd" : "npx";
262
+ const npx = IS_WIN ? "npx.cmd" : "npx";
211
263
  try {
212
- const { stdout, stderr } = await execFileAsync(
264
+ const { stdout, stderr } = await execMaybeCmd(
213
265
  npx,
214
266
  ["-y", CLI_NPX_PKG, ...args],
215
267
  execOpts,