@cloudgrid-io/mcp 0.20.7 → 0.20.9
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/README.md +40 -31
- package/package.json +4 -1
- package/src/corpus/examples/docs-app/index.md +113 -0
- package/src/playbook.js +135 -0
- package/src/tools/cli.js +446 -0
- package/src/tools/constants.js +106 -0
- package/src/tools/deploy.js +1891 -0
- package/src/tools/register.js +1169 -0
- package/src/tools/util.js +17 -0
- package/src/tools.js +30 -3470
package/src/tools/cli.js
ADDED
|
@@ -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
|
+
};
|