@cloudgrid-io/mcp 0.20.3 → 0.20.4
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 +3 -1
- package/src/tools.js +103 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
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": {
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"test:content": "node test/content-handling.test.mjs",
|
|
28
28
|
"test:self-healing": "node test/self-healing.test.mjs",
|
|
29
29
|
"test:grid-picker": "node test/grid-picker.test.mjs",
|
|
30
|
+
"test:plug-timeout": "node test/plug-timeout.test.mjs",
|
|
31
|
+
"test:manifest-confirm": "node test/manifest-confirm.test.mjs",
|
|
30
32
|
"test:drift-guard": "node test/drift-guard.mjs",
|
|
31
33
|
"smoke:drop": "node test/smoke-drop.mjs",
|
|
32
34
|
"smoke:login": "node test/smoke-login.mjs",
|
package/src/tools.js
CHANGED
|
@@ -105,7 +105,8 @@ Operating rules:
|
|
|
105
105
|
- Multi-file app or agent (kind is app or agent, or single_html: false): do NOT try to edit it as one inline HTML file. Tell the user it is a multi-file <kind>, give them the entity_id and the source (source_download_url), and explain that rebuilding it needs the local edition (Claude Desktop/Code) or the CLI — the hosted server cannot rebuild a multi-file app.
|
|
106
106
|
- Not yours (capabilities.replug: false, reason not_owner): do NOT attempt a re-plug. Offer to fork it into the user's own grid with grid_fork and edit the copy instead.
|
|
107
107
|
|
|
108
|
-
Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away
|
|
108
|
+
Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.
|
|
109
|
+
When you deploy a folder that already has a cloudgrid.yaml, grid_plug returns needs_confirmation on the first create instead of deploying — it's asking whether to create a NEW app. Relay that to the user, and once they say yes re-call grid_plug with confirm_new_app: true. To update an existing app instead, pass its target_entity_id.`;
|
|
109
110
|
|
|
110
111
|
// The corpus subdirectories that grid_fetch serves, keyed by `kind`. Each
|
|
111
112
|
// lives in its own subtree of src/corpus/ (populated by scripts/snapshot-corpus.mjs
|
|
@@ -241,6 +242,12 @@ const CLI_NPX_PKG = "@cloudgrid-io/cli@latest";
|
|
|
241
242
|
// above) the platform's current required floor.
|
|
242
243
|
const MIN_CLI_VERSION = "0.15.0";
|
|
243
244
|
|
|
245
|
+
// Upload/create POST budget. The build itself is async (server returns 202 +
|
|
246
|
+
// poll_url); this bounds only the request→response, so a stalled server errors
|
|
247
|
+
// instead of hanging forever (the "getting stuck" bug). Generous by default;
|
|
248
|
+
// override with CLOUDGRID_PLUG_UPLOAD_TIMEOUT_MS.
|
|
249
|
+
const PLUG_UPLOAD_TIMEOUT_MS = Number(process.env.CLOUDGRID_PLUG_UPLOAD_TIMEOUT_MS) || 120_000;
|
|
250
|
+
|
|
244
251
|
// Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
|
|
245
252
|
// The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
|
|
246
253
|
export const CLI_TOOL_VERBS = {
|
|
@@ -931,6 +938,56 @@ export function parseManifestName(yaml) {
|
|
|
931
938
|
return null;
|
|
932
939
|
}
|
|
933
940
|
|
|
941
|
+
// Detect whether a CREATE's source already carries a cloudgrid.yaml (i.e. it's
|
|
942
|
+
// a pre-configured runtime app). Returns a light summary { name, services, needs, raw }
|
|
943
|
+
// or null. Pure except the injectable disk read (path source).
|
|
944
|
+
export function detectSourceManifest(input, deps = {}) {
|
|
945
|
+
const readManifestFile = deps.readManifestFile || ((p) => {
|
|
946
|
+
try { return existsSync(p) ? readFileSync(p, "utf8") : null; } catch { return null; }
|
|
947
|
+
});
|
|
948
|
+
let yaml = null;
|
|
949
|
+
if (typeof input?.cloudgrid_yaml === "string" && input.cloudgrid_yaml.trim()) {
|
|
950
|
+
yaml = input.cloudgrid_yaml;
|
|
951
|
+
} else if (Array.isArray(input?.artifact_files)) {
|
|
952
|
+
// Only the ROOT cloudgrid.yaml is a runtime manifest — the server builds
|
|
953
|
+
// from the root. A nested one (services/web/cloudgrid.yaml) is not.
|
|
954
|
+
const entry = input.artifact_files.find((f) => f?.path === "cloudgrid.yaml");
|
|
955
|
+
if (entry?.content) yaml = entry.content;
|
|
956
|
+
} else if (typeof input?.path === "string" && input.path) {
|
|
957
|
+
yaml = readManifestFile(join(input.path, "cloudgrid.yaml"));
|
|
958
|
+
}
|
|
959
|
+
if (!yaml) return null;
|
|
960
|
+
const name = parseManifestName(yaml);
|
|
961
|
+
// lightweight surface for the confirm prompt (no full YAML parser needed).
|
|
962
|
+
// Scope each list to its own top-level block: collect only the immediate
|
|
963
|
+
// child keys of `services:`/`needs:` — a shared 2-space regex would grab a
|
|
964
|
+
// `needs:` child (e.g. `database`) as a bogus service.
|
|
965
|
+
const lines = yaml.split(/\r?\n/);
|
|
966
|
+
const blockChildren = (blockKey) => {
|
|
967
|
+
const out = [];
|
|
968
|
+
let inBlock = false;
|
|
969
|
+
let childIndent = null;
|
|
970
|
+
for (const line of lines) {
|
|
971
|
+
if (/^\S/.test(line)) {
|
|
972
|
+
// a top-level key: enters the target block, or ends it
|
|
973
|
+
inBlock = new RegExp(`^${blockKey}:\\s*$`).test(line);
|
|
974
|
+
childIndent = null;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (!inBlock || line.trim() === "") continue;
|
|
978
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
979
|
+
if (childIndent === null) childIndent = indent;
|
|
980
|
+
if (indent !== childIndent) continue; // deeper nesting (a child's props)
|
|
981
|
+
const m = line.trim().match(/^([a-z0-9-]+):/i);
|
|
982
|
+
if (m) out.push(m[1]);
|
|
983
|
+
}
|
|
984
|
+
return out;
|
|
985
|
+
};
|
|
986
|
+
const services = blockChildren("services");
|
|
987
|
+
const needs = blockChildren("needs");
|
|
988
|
+
return { name: name || null, services, needs, raw: yaml };
|
|
989
|
+
}
|
|
990
|
+
|
|
934
991
|
// The public URL of a `/plug` response: the server-composed `url` verbatim
|
|
935
992
|
// (canonical, flat-arch-aware — the unified plug spec), falling back to client-side composition
|
|
936
993
|
// ONLY when the server left it empty (its composition is best-effort).
|
|
@@ -1493,6 +1550,7 @@ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
|
|
|
1493
1550
|
* `artifact_files` (hosted — inline file entries).
|
|
1494
1551
|
*/
|
|
1495
1552
|
export async function runPlug(ctx, input, deps = {}) {
|
|
1553
|
+
const { fetchImpl = fetch, uploadTimeoutMs = PLUG_UPLOAD_TIMEOUT_MS } = deps;
|
|
1496
1554
|
const {
|
|
1497
1555
|
path: srcPath,
|
|
1498
1556
|
artifact_files,
|
|
@@ -1715,8 +1773,20 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1715
1773
|
|
|
1716
1774
|
let res;
|
|
1717
1775
|
try {
|
|
1718
|
-
res = await
|
|
1776
|
+
res = await fetchImpl(`${API_BASE}/api/v2/plug`, {
|
|
1777
|
+
method: "POST",
|
|
1778
|
+
headers,
|
|
1779
|
+
body: form,
|
|
1780
|
+
signal: AbortSignal.timeout(uploadTimeoutMs),
|
|
1781
|
+
});
|
|
1719
1782
|
} catch (err) {
|
|
1783
|
+
if (err?.name === "AbortError" || err?.name === "TimeoutError") {
|
|
1784
|
+
throw new Error(
|
|
1785
|
+
`The deploy request timed out after ${Math.round(uploadTimeoutMs / 1000)}s. ` +
|
|
1786
|
+
`The build may still be running on CloudGrid — check the deploy status ` +
|
|
1787
|
+
`(poll_url / grid_status, or your grid) before deploying again, so you don't create a duplicate.`,
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1720
1790
|
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1721
1791
|
}
|
|
1722
1792
|
const raw = await res.text();
|
|
@@ -2451,6 +2521,11 @@ export function registerTools(server, ctx) {
|
|
|
2451
2521
|
"The owner token of an anonymously-created drop — authorizes an anonymous re-plug (with " +
|
|
2452
2522
|
"target_entity_id). Re-minted on every anonymous edit; always persist the newest one from the result.",
|
|
2453
2523
|
),
|
|
2524
|
+
confirm_new_app: z.boolean().optional().describe(
|
|
2525
|
+
"Set true to confirm deploying a source that already contains a cloudgrid.yaml as a NEW runtime app. " +
|
|
2526
|
+
"On a create, if the source has a cloudgrid.yaml and this is not set, grid_plug returns needs_confirmation " +
|
|
2527
|
+
"so you can ask the user first (or use target_entity_id to re-plug an existing entity).",
|
|
2528
|
+
),
|
|
2454
2529
|
};
|
|
2455
2530
|
|
|
2456
2531
|
reg(
|
|
@@ -2509,6 +2584,32 @@ export function registerTools(server, ctx) {
|
|
|
2509
2584
|
// proceeds. A single grid proceeds (with a warning if it isn't set up yet).
|
|
2510
2585
|
const isEdit =
|
|
2511
2586
|
typeof input?.target_entity_id === "string" && input.target_entity_id.length > 0;
|
|
2587
|
+
// A grid+slug pair is a probable re-plug handle (the replug_handle,
|
|
2588
|
+
// resolved inside runPlug) — treat it like an edit for the confirm gate.
|
|
2589
|
+
const isReplugHandle = Boolean(input?.grid && input?.slug);
|
|
2590
|
+
// Manifest-aware confirm: a CREATE whose source already carries a
|
|
2591
|
+
// cloudgrid.yaml is a pre-configured runtime app. Don't silently
|
|
2592
|
+
// auto-create — ask once. (Skip when re-plugging, or when confirmed.)
|
|
2593
|
+
if (!isEdit && !isReplugHandle && input?.confirm_new_app !== true) {
|
|
2594
|
+
const manifest = detectSourceManifest(input);
|
|
2595
|
+
if (manifest) {
|
|
2596
|
+
const svc = manifest.services?.length
|
|
2597
|
+
? ` (services: ${manifest.services.join(", ")}${manifest.needs?.length ? `; needs: ${manifest.needs.join(", ")}` : ""})`
|
|
2598
|
+
: "";
|
|
2599
|
+
return okResult({
|
|
2600
|
+
text:
|
|
2601
|
+
`This folder is a CloudGrid runtime app — it already has a cloudgrid.yaml` +
|
|
2602
|
+
(manifest.name ? ` for "${manifest.name}"` : "") + `${svc}. ` +
|
|
2603
|
+
`Deploy it as a NEW app on the grid? If yes, re-call grid_plug with confirm_new_app: true. ` +
|
|
2604
|
+
`To update an existing app instead, pass its target_entity_id.`,
|
|
2605
|
+
structured: {
|
|
2606
|
+
needs_confirmation: true,
|
|
2607
|
+
manifest_detected: true,
|
|
2608
|
+
manifest: { name: manifest.name, services: manifest.services, needs: manifest.needs },
|
|
2609
|
+
},
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2512
2613
|
if (input?.anon !== true && !isEdit) {
|
|
2513
2614
|
const token = await ctx.getToken();
|
|
2514
2615
|
if (token) {
|