@cloudgrid-io/mcp 0.20.2 → 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.
Files changed (2) hide show
  1. package/package.json +3 -1
  2. package/src/tools.js +105 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.20.2",
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 fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
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) {
@@ -2765,9 +2866,9 @@ export function registerTools(server, ctx) {
2765
2866
 
2766
2867
  // Org listing — both editions.
2767
2868
  reg(
2768
- "grid_orgs",
2869
+ "grid_list",
2769
2870
  {
2770
- description: "List the signed-in user's organizations. Returns each org's slug, name, role, and render_ready status. Orgs where render_ready is false are still provisioning — pages published there may not load yet. Prefer a render_ready org; if the user insists on a not-ready one, warn them that pages may not load and suggest waiting or choosing a ready org instead. Requires sign-in.",
2871
+ description: "List the signed-in user's grids, each with slug, name, role, and provisioning status. A grid that is still provisioning (render_ready false) may not serve pages yet — prefer a ready grid, and if the user insists on a not-ready one, warn them that pages may not load. Requires sign-in.",
2771
2872
  inputSchema: {},
2772
2873
  outputSchema: {
2773
2874
  orgs: z.array(z.object({