@cloudgrid-io/mcp 0.20.7 → 0.20.8

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.
@@ -0,0 +1,446 @@
1
+ // CLI resolution/exec plumbing (bundled/global/npx fallback) for the local edition.
2
+ // Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
3
+ // Note: the cli-shim.mjs URL is adjusted for the new location (./ -> ../).
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
8
+ import { basename, dirname, resolve, join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { fileURLToPath } from "node:url";
11
+ import { CLI_NPX_PKG, MIN_CLI_VERSION } from "./constants.js";
12
+ import { ok, fail } from "./util.js";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // ── CLI wrapping (local edition only) ──────────────────────────────────────────
17
+
18
+ // Resolve and validate a caller-supplied working directory. Returns the resolved
19
+ // absolute path, or process.cwd() when omitted.
20
+ function resolveCwd(cwd) {
21
+ if (cwd === undefined || cwd === null || cwd === "") return undefined; // let execFile default
22
+ const abs = resolve(cwd);
23
+ if (!existsSync(abs)) {
24
+ throw new Error(`Directory does not exist: ${abs}`);
25
+ }
26
+ if (!statSync(abs).isDirectory()) {
27
+ throw new Error(`Not a directory: ${abs}`);
28
+ }
29
+ return abs;
30
+ }
31
+
32
+ // Simple semver comparison: true when `version` >= MIN_CLI_VERSION.
33
+ function meetsMinVersion(version) {
34
+ if (!version) return false;
35
+ const parse = (s) => s.replace(/^v/, "").split(".").map((p) => parseInt(p, 10) || 0);
36
+ const v = parse(version);
37
+ const m = parse(MIN_CLI_VERSION);
38
+ for (let i = 0; i < 3; i++) {
39
+ if ((v[i] || 0) > (m[i] || 0)) return true;
40
+ if ((v[i] || 0) < (m[i] || 0)) return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ // Resolve the bundled CLI from this package's OWN node_modules only — never
46
+ // walk up to parent directories (which may contain stale CLI versions).
47
+ // Returns { entry, version } or null.
48
+ //
49
+ // `fileURLToPath` (not `new URL(url).pathname`) is required for Windows: on a
50
+ // Windows `file://` URL, `.pathname` is `/C:/Users/.../src` — a leading slash
51
+ // before the drive letter, which is NOT a valid fs path, so `existsSync` fails
52
+ // and the bundled CLI is never found even though the `.mcpb` bundles it.
53
+ // `fileURLToPath` yields a proper `C:\Users\...\src`. macOS/Linux are unaffected.
54
+ //
55
+ // The `deps` seam (moduleUrl + path/fs impls) exists ONLY so the Windows-URL
56
+ // regression test can drive the win32 code path deterministically on a POSIX CI
57
+ // box. Production always uses the real `import.meta.url` and host path/fs.
58
+ export function resolveBundledCli(deps = {}) {
59
+ const {
60
+ moduleUrl = import.meta.url,
61
+ urlToPath = fileURLToPath,
62
+ pathImpl = { dirname, join },
63
+ fsExists = existsSync,
64
+ fsRead = readFileSync,
65
+ } = deps;
66
+ try {
67
+ const srcDir = pathImpl.dirname(urlToPath(moduleUrl));
68
+ const pkgPath = pathImpl.join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
69
+ if (!fsExists(pkgPath)) return null;
70
+ const pkg = JSON.parse(fsRead(pkgPath, "utf-8"));
71
+ const bin = pkg.bin && pkg.bin.cloudgrid;
72
+ if (!bin) return null;
73
+ return { entry: pathImpl.join(pathImpl.dirname(pkgPath), bin), version: pkg.version || null };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // Windows `.cmd`/`.bat` shims (cloudgrid.cmd, npx.cmd) cannot be launched by
80
+ // `execFile` without a shell — modern patched Node (CVE-2024-27980) rejects it
81
+ // with EINVAL, and even where it doesn't, ENOENT results because the bare name
82
+ // resolves to a `.cmd`. On win32 we route those through `cmd.exe /d /s /c` with
83
+ // an explicitly quoted command line so args with spaces/quotes stay intact.
84
+ // macOS/Linux never take this branch — they keep the plain execFile path.
85
+ const IS_WIN = process.platform === "win32";
86
+
87
+ // Quote a single Windows command-line token for cmd.exe. Wrap in double quotes,
88
+ // escape embedded quotes/backslash-before-quote per Windows argv rules, and also
89
+ // caret-escape cmd.exe metacharacters so nothing is interpreted by the shell.
90
+ function winQuoteArg(arg) {
91
+ const s = String(arg);
92
+ // Escape backslashes that precede a quote, then the quotes themselves.
93
+ let inner = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
94
+ inner = `"${inner}"`;
95
+ // Caret-escape cmd.exe special chars (they live outside the quoted-arg parsing).
96
+ return inner.replace(/[()%!^"<>&|]/g, "^$&");
97
+ }
98
+
99
+ // execFile a command that may be a Windows `.cmd` shim, safely on every OS.
100
+ function execMaybeCmd(command, args, options, exec = execFileAsync) {
101
+ if (!IS_WIN) return exec(command, args, options);
102
+ const line = [command, ...args].map(winQuoteArg).join(" ");
103
+ return exec(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
104
+ ...options,
105
+ windowsVerbatimArguments: true,
106
+ });
107
+ }
108
+
109
+ // ── Electron-safe Node runtime resolution ─────────────────────────────────────
110
+ //
111
+ // Claude Desktop (and other Electron MCP hosts) run this server inside an
112
+ // Electron utility process, so `process.execPath` is the host's Electron helper
113
+ // binary — NOT a Node interpreter. Spawning it as if it were Node either dies
114
+ // with FATAL "Unable to find helper app" (Claude Desktop's helper binary) or
115
+ // boots a GUI Electron app that hangs until the exec timeout and then reports a
116
+ // fake success. ELECTRON_RUN_AS_NODE=1 only helps when the host's Electron
117
+ // build keeps the runAsNode fuse enabled — Claude Desktop's does NOT (verified:
118
+ // the helper FATALs identically with the variable set). So the bundled-CLI rung
119
+ // must (a) never blindly spawn process.execPath under Electron, (b) prefer a
120
+ // real Node binary probed from common install locations (the utility process
121
+ // PATH is a bare /usr/bin:/bin:/usr/sbin:/sbin on macOS), and (c) use
122
+ // execPath-as-node only after a quick verified run-as-node probe.
123
+
124
+ const CLI_SHIM = fileURLToPath(new URL("../cli-shim.mjs", import.meta.url));
125
+
126
+ const NO_NODE_HINT =
127
+ "No usable Node.js runtime found for spawning the CloudGrid CLI (this MCP " +
128
+ "host runs on Electron, whose binary cannot execute Node scripts). Install " +
129
+ "Node.js (https://nodejs.org) or the CloudGrid CLI (npm install -g " +
130
+ "@cloudgrid-io/cli) and try again.";
131
+
132
+ // True when `execPath` is a real Node interpreter we can hand a script to.
133
+ function execPathIsRealNode({ execPath, versions, platform }) {
134
+ if (versions?.electron) return false;
135
+ const name = basename(execPath).toLowerCase();
136
+ return platform === "win32" ? name === "node.exe" || name === "node" : name === "node";
137
+ }
138
+
139
+ // Pick the highest semver-looking entry of a directory (nvm/fnm version dirs).
140
+ function highestVersionEntry(dir, fsReaddir) {
141
+ try {
142
+ const versions = fsReaddir(dir)
143
+ .filter((d) => /^v?\d+\.\d+\.\d+$/.test(d))
144
+ .sort((a, b) => {
145
+ const pa = a.replace(/^v/, "").split(".").map(Number);
146
+ const pb = b.replace(/^v/, "").split(".").map(Number);
147
+ return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2];
148
+ });
149
+ return versions.pop() || null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // Candidate real-Node binaries, most likely first. GUI-launched Electron hosts
156
+ // get a bare PATH, so fixed install locations and version-manager dirs are
157
+ // probed explicitly.
158
+ function listNodeCandidates({ platform, env, home, fsReaddir }) {
159
+ const out = [];
160
+ const binName = platform === "win32" ? "node.exe" : "node";
161
+ for (const dir of (env.PATH || "").split(platform === "win32" ? ";" : ":")) {
162
+ if (dir) out.push(join(dir, binName));
163
+ }
164
+ if (platform === "win32") {
165
+ for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
166
+ if (base) out.push(join(base, "nodejs", "node.exe"));
167
+ }
168
+ if (env.NVM_SYMLINK) out.push(join(env.NVM_SYMLINK, "node.exe"));
169
+ if (env.LOCALAPPDATA) out.push(join(env.LOCALAPPDATA, "Volta", "bin", "node.exe"));
170
+ return out;
171
+ }
172
+ out.push("/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node", "/opt/local/bin/node");
173
+ if (home) {
174
+ out.push(join(home, ".volta", "bin", "node"));
175
+ const nvmVersions = join(env.NVM_DIR || join(home, ".nvm"), "versions", "node");
176
+ const nvmBest = highestVersionEntry(nvmVersions, fsReaddir);
177
+ if (nvmBest) out.push(join(nvmVersions, nvmBest, "bin", "node"));
178
+ for (const base of [
179
+ env.FNM_DIR,
180
+ join(home, ".local", "share", "fnm"),
181
+ join(home, "Library", "Application Support", "fnm"),
182
+ join(home, ".fnm"),
183
+ ]) {
184
+ if (!base) continue;
185
+ out.push(join(base, "aliases", "default", "bin", "node"));
186
+ const fnmVersions = join(base, "node-versions");
187
+ const fnmBest = highestVersionEntry(fnmVersions, fsReaddir);
188
+ if (fnmBest) out.push(join(fnmVersions, fnmBest, "installation", "bin", "node"));
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ // Verify a candidate actually behaves like Node (guards against broken shims
195
+ // and against Electron's fake-success mode where a timed-out GUI boot exits 0).
196
+ async function verifyNodeRuns(exec, command, env) {
197
+ try {
198
+ const { stdout } = await exec(command, ["-p", "process.version"], {
199
+ timeout: 5_000,
200
+ env,
201
+ stdio: ["ignore", "pipe", "pipe"],
202
+ });
203
+ return /^v\d+\./.test(String(stdout).trim());
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ // Resolve the Node runtime used to spawn the bundled CLI. Returns
210
+ // { command, kind } or null when no usable runtime exists.
211
+ //
212
+ // The `deps` seam exists ONLY so the Electron regression tests can drive the
213
+ // Electron code paths deterministically on a plain-Node CI box. Production
214
+ // (runCloudgrid) only ever injects the real spawner, so the result is memoized.
215
+ let runtimeCache; // undefined = not yet resolved; null = resolved to "none"
216
+ export async function resolveNodeRuntime(deps = {}) {
217
+ // Cache unless the environment itself is simulated (`exec` is just the
218
+ // spawner) — probing spawns children, and the answer cannot change within
219
+ // one server process.
220
+ const cacheable = Object.keys(deps).every((k) => k === "exec");
221
+ if (cacheable && runtimeCache !== undefined) return runtimeCache;
222
+ const {
223
+ exec = execFileAsync,
224
+ execPath = process.execPath,
225
+ versions = process.versions,
226
+ platform = process.platform,
227
+ env = process.env,
228
+ home = homedir(),
229
+ fsExists = existsSync,
230
+ fsReaddir = readdirSync,
231
+ } = deps;
232
+
233
+ let result = null;
234
+ if (execPathIsRealNode({ execPath, versions, platform })) {
235
+ result = { command: execPath, kind: "exec-path" };
236
+ } else {
237
+ // 1. A real Node binary somewhere on this machine.
238
+ for (const candidate of listNodeCandidates({ platform, env, home, fsReaddir })) {
239
+ if (!fsExists(candidate)) continue;
240
+ if (await verifyNodeRuns(exec, candidate, env)) {
241
+ result = { command: candidate, kind: "system-node" };
242
+ break;
243
+ }
244
+ }
245
+ // 2. Last resort: execPath with ELECTRON_RUN_AS_NODE — only if a quick
246
+ // probe proves the host's runAsNode fuse is enabled (Claude Desktop's
247
+ // is not; vanilla Electron's is).
248
+ if (!result) {
249
+ const probeEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" };
250
+ if (await verifyNodeRuns(exec, execPath, probeEnv)) {
251
+ result = { command: execPath, kind: "electron-run-as-node" };
252
+ }
253
+ }
254
+ }
255
+ if (cacheable) runtimeCache = result;
256
+ return result;
257
+ }
258
+
259
+ // A step-1 error that means "the runtime never ran the CLI" (fall through to
260
+ // the global-CLI/npx rungs) as opposed to "the CLI ran and reported an error"
261
+ // (surface it).
262
+ function isRuntimeBootFailure(err) {
263
+ if (!err) return false;
264
+ if (err.code === "ENOENT" || err.code === "EACCES") return true;
265
+ const text = `${err.stderr || ""}\n${err.message || ""}`;
266
+ return text.includes("Unable to find helper app") || /FATAL:.*electron/i.test(text);
267
+ }
268
+
269
+ // Environment for the global-CLI and npx fallback rungs. GUI-launched Electron
270
+ // hosts hand utility processes a bare PATH (macOS: /usr/bin:/bin:/usr/sbin:/sbin)
271
+ // that misses Homebrew/npm bin dirs, so `cloudgrid`/`npx` would be ENOENT even
272
+ // when installed — append the usual install locations. ELECTRON_RUN_AS_NODE is
273
+ // stripped so it cannot leak through a real-node CLI into anything it spawns.
274
+ function pathAugmentedEnv(env = process.env, platform = process.platform, home = homedir()) {
275
+ const out = { ...env };
276
+ delete out.ELECTRON_RUN_AS_NODE;
277
+ if (platform === "win32") return out;
278
+ const extras = ["/usr/local/bin", "/opt/homebrew/bin", join(home, ".volta", "bin"), join(home, ".local", "bin")];
279
+ const parts = (out.PATH || "").split(":").filter(Boolean);
280
+ for (const dir of extras) {
281
+ if (!parts.includes(dir)) parts.push(dir);
282
+ }
283
+ out.PATH = parts.join(":");
284
+ return out;
285
+ }
286
+
287
+ // The `deps` seam mirrors resolveBundledCli's: it exists ONLY so regression
288
+ // tests can spy the exec calls and simulate Electron hosts deterministically.
289
+ // Production always calls this with no deps.
290
+ export async function runCloudgrid(args, opts = {}, deps = {}) {
291
+ const {
292
+ exec = execFileAsync,
293
+ resolveCli = resolveBundledCli,
294
+ resolveRuntime = resolveNodeRuntime,
295
+ } = deps;
296
+ const cwd = resolveCwd(opts.cwd);
297
+ const execOpts = {
298
+ maxBuffer: 16 * 1024 * 1024,
299
+ timeout: 10 * 60 * 1000,
300
+ stdio: ["ignore", "pipe", "pipe"],
301
+ ...(cwd ? { cwd } : {}),
302
+ };
303
+ // PATH-augmented, ELECTRON_RUN_AS_NODE-free env for the fallback rungs.
304
+ const fallbackEnv = pathAugmentedEnv();
305
+ // Set when the bundled CLI had to be skipped for lack of a Node runtime, so
306
+ // the final error names the real cause instead of a raw npx/Electron error.
307
+ let runtimeHint = null;
308
+
309
+ const extract = (err) => {
310
+ const detail = [err && err.stdout, err && err.stderr, err && err.message]
311
+ .filter(Boolean)
312
+ .join("\n")
313
+ .trim();
314
+ return new Error(detail || "cloudgrid command failed");
315
+ };
316
+
317
+ // 1. Bundled CLI — own node_modules only, version-gated. Runs via
318
+ // cli-shim.mjs under a verified Node runtime (see resolveNodeRuntime):
319
+ // process.execPath is NOT assumed to be Node, because under Electron MCP
320
+ // hosts (Claude Desktop) it is the host's Electron helper binary.
321
+ // ELECTRON_RUN_AS_NODE=1 makes an Electron runtime boot as Node and is
322
+ // ignored by plain Node; the shim strips it again before the CLI runs and
323
+ // fixes commander's Electron argv slicing.
324
+ const bundled = resolveCli();
325
+ if (bundled && meetsMinVersion(bundled.version)) {
326
+ const runtime = await resolveRuntime({ exec });
327
+ if (runtime) {
328
+ try {
329
+ const { stdout, stderr } = await exec(
330
+ runtime.command,
331
+ [CLI_SHIM, bundled.entry, ...args],
332
+ { ...execOpts, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
333
+ );
334
+ return (stdout || stderr || "").trim() || "Done.";
335
+ } catch (err) {
336
+ // A CLI-reported error surfaces; a runtime that failed to boot falls
337
+ // through to the global-CLI/npx rungs instead of killing the chain.
338
+ if (!isRuntimeBootFailure(err)) throw extract(err);
339
+ console.error(
340
+ `cloudgrid-mcp: bundled CLI spawn failed (${err?.code || "runtime boot failure"}); trying global CLI, then npx`,
341
+ );
342
+ }
343
+ } else {
344
+ runtimeHint = NO_NODE_HINT;
345
+ console.error(`cloudgrid-mcp: ${NO_NODE_HINT} Trying global CLI, then npx.`);
346
+ }
347
+ } else if (bundled) {
348
+ console.error(
349
+ `cloudgrid-mcp: bundled CLI ${bundled.version} < ${MIN_CLI_VERSION}, skipping`,
350
+ );
351
+ }
352
+
353
+ // 2. Global `cloudgrid` on PATH — version-gated.
354
+ // On Windows the bin is `cloudgrid.cmd`; run it via cmd.exe (execMaybeCmd).
355
+ {
356
+ const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
357
+ let useGlobal = false;
358
+ try {
359
+ const { stdout: vOut } = await execMaybeCmd(
360
+ globalBin,
361
+ ["--version"],
362
+ {
363
+ timeout: 5_000,
364
+ stdio: ["ignore", "pipe", "pipe"],
365
+ env: fallbackEnv,
366
+ },
367
+ exec,
368
+ );
369
+ const ver = vOut.trim().match(/(\d+\.\d+\.\d+)/)?.[1];
370
+ if (meetsMinVersion(ver)) {
371
+ useGlobal = true;
372
+ } else {
373
+ console.error(
374
+ `cloudgrid-mcp: global CLI ${ver || vOut.trim()} < ${MIN_CLI_VERSION}, skipping`,
375
+ );
376
+ }
377
+ } catch {
378
+ // Not on PATH or --version failed — skip
379
+ }
380
+ if (useGlobal) {
381
+ try {
382
+ const { stdout, stderr } = await execMaybeCmd(
383
+ globalBin,
384
+ args,
385
+ { ...execOpts, env: fallbackEnv },
386
+ exec,
387
+ );
388
+ return (stdout || stderr || "").trim() || "Done.";
389
+ } catch (err) {
390
+ throw extract(err);
391
+ }
392
+ }
393
+ }
394
+
395
+ // 3. Lazy fetch: npx -y @cloudgrid-io/cli@<pinned> <args>
396
+ // One-time download; npx caches it for subsequent invocations.
397
+ // On Windows this is `npx.cmd`, run via cmd.exe (execMaybeCmd) so patched
398
+ // Node doesn't EINVAL on the `.cmd` shim.
399
+ console.error("cloudgrid-mcp: fetching the CloudGrid CLI (first use)…");
400
+ const npx = IS_WIN ? "npx.cmd" : "npx";
401
+ try {
402
+ const { stdout, stderr } = await execMaybeCmd(
403
+ npx,
404
+ ["-y", CLI_NPX_PKG, ...args],
405
+ { ...execOpts, env: fallbackEnv },
406
+ exec,
407
+ );
408
+ return (stdout || stderr || "").trim() || "Done.";
409
+ } catch (err) {
410
+ const failure = extract(err);
411
+ if (runtimeHint) failure.message = `${runtimeHint}\n\n${failure.message}`;
412
+ throw failure;
413
+ }
414
+ }
415
+
416
+ export function cliTool(buildArgs, { cwdParam = false } = {}) {
417
+ return async (input) => {
418
+ try {
419
+ const params = input || {};
420
+ const opts = {};
421
+ if (cwdParam) {
422
+ // Accept cwd, directory, or dir as the working-directory override.
423
+ opts.cwd = params.cwd ?? params.directory ?? params.dir;
424
+ }
425
+ return ok(await runCloudgrid(buildArgs(params), opts));
426
+ } catch (err) {
427
+ return fail(err.message);
428
+ }
429
+ };
430
+ }
431
+
432
+ export function tryOpenBrowser(url) {
433
+ if (process.env.CLOUDGRID_NO_BROWSER === "1") return;
434
+ const cmd =
435
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
436
+ // Strip ELECTRON_RUN_AS_NODE (present when the server itself runs under
437
+ // Electron-as-Node) so an Electron-based browser doesn't boot as a headless
438
+ // Node process instead of opening a window.
439
+ const env = { ...process.env };
440
+ delete env.ELECTRON_RUN_AS_NODE;
441
+ try {
442
+ execFile(cmd, [url], { env }, () => {});
443
+ } catch {
444
+ // ignore — the URL is returned to the user anyway
445
+ }
446
+ }
@@ -0,0 +1,106 @@
1
+ // Shared constants for the CloudGrid MCP server tools.
2
+ // Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
3
+ // Note: import.meta.url-relative reads adjusted for the new location
4
+ // (../package.json -> ../../package.json, ./widgets/ -> ../widgets/).
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid.io").replace(
9
+ /\/+$/,
10
+ "",
11
+ );
12
+
13
+ // This MCP server's version — mirrors the CLI's cli_version in a report's origin.
14
+ // Read once from package.json; never throw (a report must never fail on this).
15
+ export const MCP_VERSION = (() => {
16
+ try {
17
+ return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
18
+ } catch {
19
+ return "unknown";
20
+ }
21
+ })();
22
+
23
+ export const ANON_HTML_MAX_BYTES = 2_000_000;
24
+ // Signed-in inline drops get a larger cap than the anonymous 2MB. Kept
25
+ // conservative — it must stay ≤ the platform's single-artifact byte limit.
26
+ // TODO(platform-confirm): confirm the server's single-artifact limit (the
27
+ // folder-plug path uses PLUG_MAX_TOTAL_BYTES = 100MB, but the single-artifact
28
+ // inline limit is not yet confirmed) and raise this to match.
29
+ export const AUTHED_HTML_MAX_BYTES = 25_000_000;
30
+ export const CONSOLE_URL = "https://console.cloudgrid.io/";
31
+
32
+ export const VISIBILITY_LABELS = {
33
+ private: "Only you",
34
+ org: "Your org",
35
+ authenticated: "Anyone signed in",
36
+ space: "A space",
37
+ link: "Anyone with the link",
38
+ };
39
+
40
+ // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
41
+ // The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
42
+ // as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
43
+ // them behind an env flag, DEFAULT OFF, so the drop/plug result is text-first
44
+ // (the live URL is already the first line of the text content) and the widget is
45
+ // optional. Flip MCP_APPS_WIDGETS=1 in the platform manifest to re-enable once
46
+ // the widget HTML is verified to render. The resources stay registered either
47
+ // way (harmless when no tool references them via outputTemplate).
48
+ export const APPS_WIDGETS_ENABLED = process.env.MCP_APPS_WIDGETS === "1";
49
+ export const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
50
+ // URI/resource-name/filename stay `org-picker` — that's the stable contract the
51
+ // web card is registered under; only the JS identifier moves toward grid.
52
+ export const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
53
+ export const LIVE_RESULT_HTML = readFileSync(new URL("../widgets/live-result.html", import.meta.url), "utf-8");
54
+ export const GRID_PICKER_HTML = readFileSync(new URL("../widgets/org-picker.html", import.meta.url), "utf-8");
55
+ export const WIDGET_CSP = {
56
+ connectDomains: ["https://*.cloudgrid.io"],
57
+ resourceDomains: ["https://*.cloudgrid.io"],
58
+ };
59
+
60
+ // The lazy npx fallback always fetches the LATEST published CLI, so the MCP is
61
+ // never left behind the platform's required CLI version (a pinned range went
62
+ // stale and the API then rejected it with "install the latest CLI").
63
+ export const CLI_NPX_PKG = "@cloudgrid-io/cli@latest";
64
+
65
+ // Minimum CLI version the MCP will USE if it finds one already installed. Below
66
+ // this, skip the local/global CLI and fall back to `npx @latest`. MUST stay at
67
+ // (or above) the platform's live floor (platform_settings.cli_compat, enforced
68
+ // server-side with HTTP 426) — a gate below the floor green-lights CLIs the API
69
+ // rejects on every call (issue #59). Bump this in lockstep with every floor
70
+ // raise (CLI release protocol checklist).
71
+ export const MIN_CLI_VERSION = "0.15.2";
72
+
73
+ // Upload/create POST budget. The build itself is async (server returns 202 +
74
+ // poll_url); this bounds only the request→response, so a stalled server errors
75
+ // instead of hanging forever (the "getting stuck" bug). Generous by default;
76
+ // override with CLOUDGRID_PLUG_UPLOAD_TIMEOUT_MS.
77
+ export const PLUG_UPLOAD_TIMEOUT_MS = Number(process.env.CLOUDGRID_PLUG_UPLOAD_TIMEOUT_MS) || 120_000;
78
+
79
+ // Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
80
+ // The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
81
+ export const CLI_TOOL_VERBS = {
82
+ grid_create_project: ["init"],
83
+ // grid_deploy is NOT here: grid_deploy is now a direct-API tool
84
+ // (POST /api/v2/plug, spec v2 §3), not a CLI wrapper.
85
+ grid_view_logs: ["logs"],
86
+ grid_share: ["visibility"],
87
+ grid_feedback: ["feedback"],
88
+ grid_whoami: ["whoami"],
89
+ grid_switch_grid: ["use"],
90
+ grid_logout: ["logout"],
91
+ grid_status: ["status"],
92
+ grid_info: ["info"],
93
+ grid_get: ["get"],
94
+ grid_describe_grid: ["describe"],
95
+ grid_edit_existing_app: ["pickup"],
96
+ grid_rename: ["rename"],
97
+ grid_take_offline: ["unplug"],
98
+ grid_delete: ["delete"],
99
+ grid_rollback_deploy: ["rollback"],
100
+ grid_list_versions: ["versions"],
101
+ grid_set_env: ["env"],
102
+ grid_set_secret: ["secrets"],
103
+ grid_scaffold: ["scaffold"],
104
+ grid_diagnose: ["doctor"],
105
+ grid_get_url: ["open"],
106
+ };