@cloudgrid-io/mcp 0.20.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.20.8",
3
+ "version": "0.20.9",
4
4
  "description": "Use CloudGrid via MCP to brainstorm, build, deploy, and improve apps, games, websites, dashboards, and tools and get a live URL - templates, recipes, managed databases and AI, instant single-page publish, and full-app deploys, for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server, over MCP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "test:self-healing": "node test/self-healing.test.mjs",
29
29
  "test:grid-picker": "node test/grid-picker.test.mjs",
30
30
  "test:plug-output-schema": "node test/plug-output-schema.test.mjs",
31
+ "test:zip-deploy": "node test/zip-deploy.test.mjs",
31
32
  "test:plug-timeout": "node test/plug-timeout.test.mjs",
32
33
  "test:manifest-confirm": "node test/manifest-confirm.test.mjs",
33
34
  "test:qa-courier": "node test/qa-courier.test.mjs",
@@ -51,6 +52,7 @@
51
52
  "dependencies": {
52
53
  "@modelcontextprotocol/sdk": "^1.0.0",
53
54
  "express": "^4.22.2",
55
+ "fflate": "^0.8.3",
54
56
  "zod": "^3.23.0"
55
57
  },
56
58
  "license": "Apache-2.0",
package/src/playbook.js CHANGED
@@ -30,7 +30,7 @@ Operating rules:
30
30
  - 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_copy_app and edit the copy instead.
31
31
  16. Help CloudGrid QA. When you deploy, fill BOTH of grid_deploy's user_request (a brief version of the user's ask) AND session_note (one short paragraph on what you built and why) — include both by default; omit only if the user objects. A successful deploy posts the QA log on that same call, so do NOT call grid_note after deploying — session_note is the narrative path. Use grid_note only BEFORE a deploy, or in a session that ends without one. Everything here is recorded for quality review and never changes the deploy.
32
32
 
33
- Deploy is via grid_deploy 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.
33
+ Deploy is via grid_deploy 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. A user-supplied ZIP archive (local edition): pass its path directly as the path param — it is extracted and deployed as a static app; if the zip is assets-only (e.g. images for a gallery), generate the page and pass it as html alongside the zip path, and it becomes the index.html over the archive's files.
34
34
  When you deploy a folder that already has a cloudgrid.yaml, grid_deploy 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_deploy with confirm_new_app: true. To update an existing app instead, pass its target_entity_id.`;
35
35
 
36
36
  // The corpus subdirectories that grid_get_template serves, keyed by `kind`. Each
@@ -673,6 +673,191 @@ function loadIgnoreMatchers(rootDir) {
673
673
  patterns.some((p) => (!p.dirOnly || isDir) && p.re.test(relPath));
674
674
  }
675
675
 
676
+ // ── Zip deploys (local edition) ──────────────────────────────────────────────
677
+ // "Build me a gallery site with the attached images (zip)" — the local server
678
+ // CAN open archives even though the model cannot. `path` accepts a .zip: it is
679
+ // extracted to a temp dir and deployed. Because the platform's multi-file
680
+ // INSPIRATION create currently persists only the primary HTML (see the
681
+ // inline-create issue filed 2026-07-17), a multi-file zip is ALWAYS shaped as
682
+ // a static RUNTIME project — a synthesized cloudgrid.yaml (type: static) with
683
+ // the files under services/web/ — which the server builds and serves fully.
684
+ // A zip that ships its own cloudgrid.yaml is deployed as-is.
685
+ //
686
+ // `html` is allowed TOGETHER with a zip path (the one source combo): it becomes
687
+ // services/web/index.html, so an agent on a no-filesystem client can generate
688
+ // the page inline while the archive supplies the assets.
689
+
690
+ function isZipPath(srcPath) {
691
+ const abs = resolve(srcPath);
692
+ if (!existsSync(abs) || !statSync(abs).isFile()) return false;
693
+ if (/\.zip$/i.test(abs)) return true;
694
+ // Magic sniff so "photos.ZIP.download"-style names still work.
695
+ try {
696
+ const fd = readFileSync(abs);
697
+ return fd.length >= 4 && fd[0] === 0x50 && fd[1] === 0x4b && fd[2] === 0x03 && fd[3] === 0x04;
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+
703
+ // Extract a zip safely and return the directory to deploy. Throws on traversal.
704
+ async function expandZipToProject(zipPath, inlineHtml) {
705
+ const { unzipSync } = await import("fflate");
706
+ const raw = readFileSync(resolve(zipPath));
707
+ if (raw.byteLength > PLUG_MAX_TOTAL_BYTES) {
708
+ throw new Error("The zip exceeds the 100MB plug limit. Trim it or split the content.");
709
+ }
710
+ let entries;
711
+ try {
712
+ entries = unzipSync(new Uint8Array(raw));
713
+ } catch (err) {
714
+ throw new Error(`Could not read the zip archive: ${err.message}`);
715
+ }
716
+ // Sanitize: reject traversal, skip macOS metadata + always-skip dirs.
717
+ const files = [];
718
+ for (const [name, data] of Object.entries(entries)) {
719
+ if (name.endsWith("/")) continue; // directory entry
720
+ const norm = name.replace(/\\/g, "/");
721
+ if (norm.startsWith("/") || /^[A-Za-z]:/.test(norm) || norm.split("/").includes("..")) {
722
+ throw new Error(`Refusing zip entry outside the archive root: ${name}`);
723
+ }
724
+ const parts = norm.split("/");
725
+ if (parts.includes("__MACOSX") || parts.some((p) => PLUG_ALWAYS_SKIP.has(p))) continue;
726
+ files.push({ path: norm, data });
727
+ }
728
+ if (files.length === 0) throw new Error("The zip contains no deployable files.");
729
+ if (files.length > PLUG_MAX_FILES) {
730
+ throw new Error(`The zip has more than ${PLUG_MAX_FILES} files — too large to plug.`);
731
+ }
732
+ // Strip a single common root folder (the usual zip-of-a-folder shape) — but
733
+ // ONLY when stripping surfaces an index.html or cloudgrid.yaml at the root.
734
+ // An assets-only archive like img/a.png shares a common root too, and
735
+ // flattening it would break the page's relative img/ references.
736
+ const firstSeg = files[0].path.split("/")[0];
737
+ const singleRoot =
738
+ files.every((f) => f.path.split("/")[0] === firstSeg) && files.every((f) => f.path.includes("/"));
739
+ if (singleRoot) {
740
+ const stripped = files.map((f) => f.path.slice(firstSeg.length + 1));
741
+ if (stripped.some((p) => /^index\.html?$/i.test(p) || p === "cloudgrid.yaml")) {
742
+ files.forEach((f, i) => { f.path = stripped[i]; });
743
+ if (files.some((f) => !f.path)) throw new Error("Unexpected zip layout (empty path after root strip).");
744
+ }
745
+ }
746
+
747
+ const hasManifest = files.some((f) => f.path === "cloudgrid.yaml");
748
+ const hasIndex = files.some((f) => /^index\.html?$/i.test(f.path));
749
+ const hasInlineHtml = typeof inlineHtml === "string" && inlineHtml.length > 0;
750
+ if (hasInlineHtml && hasManifest) {
751
+ throw new Error(
752
+ "The zip already contains a cloudgrid.yaml project — deploy it as-is (drop the `html` param), " +
753
+ "or re-plug the entity and edit its files instead.",
754
+ );
755
+ }
756
+ if (hasInlineHtml && hasIndex) {
757
+ throw new Error(
758
+ "The zip already has an index.html — pass either the zip alone, or `html` with a zip of assets only.",
759
+ );
760
+ }
761
+ if (!hasManifest && !hasIndex && !hasInlineHtml) {
762
+ throw new Error(
763
+ "The zip has no index.html and no cloudgrid.yaml. Generate the page first and pass it as `html` " +
764
+ "alongside the zip (the archive then supplies the assets), or add an index.html to the archive.",
765
+ );
766
+ }
767
+
768
+ // A zip that is JUST one HTML page (no manifest, no assets) rides the
769
+ // instant single-file inspiration path instead of a runtime build.
770
+ if (!hasManifest && !hasInlineHtml && files.length === 1 && /^index\.html?$/i.test(files[0].path)) {
771
+ return { singleHtml: Buffer.from(files[0].data).toString("utf8") };
772
+ }
773
+
774
+ const dir = await mkdtemp(join(tmpdir(), "cloudgrid-zip-"));
775
+ const writeAll = async (base) => {
776
+ for (const f of files) {
777
+ const dest = resolve(join(base, f.path));
778
+ if (dest !== base && !dest.startsWith(base + sep)) {
779
+ throw new Error(`Refusing to write zip entry outside the temp dir: ${f.path}`);
780
+ }
781
+ await mkdir(dirname(dest), { recursive: true });
782
+ await writeFile(dest, f.data);
783
+ }
784
+ };
785
+
786
+ if (hasManifest) {
787
+ // The archive is already a CloudGrid project — deploy verbatim.
788
+ await writeAll(resolve(dir));
789
+ return { projectDir: dir, name: null };
790
+ }
791
+
792
+ // Synthesize a static-runtime wrapper: name from the archive, files under
793
+ // services/web/. (Static RUNTIME, not inspiration, so every file survives.)
794
+ const rawName = basename(resolve(zipPath)).replace(/\.zip$/i, "");
795
+ const slugged = rawName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 42);
796
+ const name = slugged.length >= 2 && /^[a-z0-9]/.test(slugged) ? slugged : "zip-site";
797
+ const webDir = resolve(join(dir, "services", "web"));
798
+ await mkdir(webDir, { recursive: true });
799
+ await writeAll(webDir);
800
+ if (hasInlineHtml) {
801
+ const art = htmlToArtifact(inlineHtml);
802
+ await writeFile(join(webDir, "index.html"), art.buffer);
803
+ }
804
+ await writeFile(
805
+ join(dir, "cloudgrid.yaml"),
806
+ `name: ${name}\ndescription: Deployed from ${basename(zipPath)}.\nservices:\n web:\n type: static\n path: /\n`,
807
+ );
808
+ return { projectDir: dir, name };
809
+ }
810
+
811
+ // Deploy an extracted zip PROJECT through the CLI (`grid init --here` +
812
+ // `grid plug`). The direct-API inline wire cannot be used here: verified live
813
+ // 2026-07-17, it drops secondary files on inspiration creates AND never starts
814
+ // the build for runtime creates from path-mode ("charged, not yet live" —
815
+ // entities drop-df1f / drop-e86d). The CLI plug path builds correctly (every
816
+ // runtime this week shipped through it), so zip projects ride it.
817
+ async function plugZipProjectViaCli(ctx, { projectDir, name }, input, deps = {}) {
818
+ const { cliRun = runCloudgrid } = deps;
819
+ const grid = input?.grid || (await ctx.getActiveGrid?.()) || null;
820
+ const manifestPath = join(projectDir, "cloudgrid.yaml");
821
+ const manifestBody = readFileSync(manifestPath, "utf8");
822
+ const yamlName =
823
+ name ||
824
+ (() => {
825
+ const m = /^name:\s*(.+?)\s*$/m.exec(manifestBody);
826
+ return m ? m[1].replace(/^["']|["']$/g, "") : "zip-site";
827
+ })();
828
+ // `grid init --here` refuses a dir that already has a cloudgrid.yaml, and it
829
+ // is also the step that mints the real slug (name + 4-hex suffix). So: stash
830
+ // the manifest, init bare, then restore it with the assigned slug as name:.
831
+ await rm(manifestPath, { force: true });
832
+ const initArgs = ["init", "app", yamlName, "--here", ...(grid ? ["--grid", grid] : [])];
833
+ const initOut = String((await cliRun(initArgs, { cwd: projectDir })) || "");
834
+ let assignedSlug = /Slug:\s+(\S+)/.exec(initOut)?.[1] ?? null;
835
+ if (!assignedSlug) {
836
+ try {
837
+ assignedSlug = JSON.parse(readFileSync(join(projectDir, ".cloudgrid", "link.json"), "utf8")).entity_slug;
838
+ } catch {
839
+ assignedSlug = yamlName;
840
+ }
841
+ }
842
+ await writeFile(manifestPath, manifestBody.replace(/^name:.*$/m, `name: ${assignedSlug}`));
843
+ const stdout = await cliRun(
844
+ ["plug", "--no-progress", "--no-clipboard", "--no-notify"],
845
+ { cwd: projectDir },
846
+ );
847
+ const url = parseCliPlugUrl(String(stdout || ""));
848
+ if (!url) {
849
+ throw new Error(
850
+ `The zip project deployed via the CLI but no live URL was found in its output.\n${String(stdout || "").slice(0, 500)}`,
851
+ );
852
+ }
853
+ return {
854
+ text:
855
+ `Live: ${url}\n` +
856
+ "(Deployed from the zip archive as a static app via the bundled CloudGrid CLI.)",
857
+ structured: { url, status: "created", via: "zip-cli" },
858
+ };
859
+ }
860
+
676
861
  // Walk a local folder into `[{path, buffer}]` artifacts (repo-relative paths),
677
862
  // honoring .gitignore/.cloudgridignore at the root plus the always-skip set.
678
863
  // A single file becomes one artifact named by its basename.
@@ -893,13 +1078,20 @@ export async function runPlug(ctx, input, deps = {}) {
893
1078
  } = input || {};
894
1079
 
895
1080
  // ── Source: exactly one of html | artifact_files | path ─────────────────────
896
- const hasHtml = typeof html === "string" && html.length > 0;
1081
+ // One allowed combo: `html` + a `path` that is a ZIP archive — the html
1082
+ // becomes index.html and the archive supplies the assets (the Desktop
1083
+ // "gallery from a zip" flow, where the model can generate a page but cannot
1084
+ // write files).
1085
+ let hasHtml = typeof html === "string" && html.length > 0;
897
1086
  const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
898
- const hasPath = Boolean(srcPath);
899
- if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
1087
+ let hasPath = Boolean(srcPath);
1088
+ let effectivePath = srcPath;
1089
+ const zipSource = hasPath && ctx.edition !== "web" && isZipPath(srcPath);
1090
+ if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1 && !(zipSource && hasHtml && !hasArtifacts)) {
900
1091
  throw new Error(
901
1092
  "Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
902
- "(multiple inline files), or `path` (a local file/folder).",
1093
+ "(multiple inline files), or `path` (a local file/folder/zip). Exception: `html` " +
1094
+ "may accompany a .zip `path` — it becomes the index.html over the archive's assets.",
903
1095
  );
904
1096
  }
905
1097
  if (ctx.edition === "web" && hasPath) {
@@ -907,6 +1099,28 @@ export async function runPlug(ctx, input, deps = {}) {
907
1099
  "The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
908
1100
  );
909
1101
  }
1102
+ let zipSingleHtml = null;
1103
+ if (zipSource) {
1104
+ if (target_entity_id || (slug && grid)) {
1105
+ throw new Error(
1106
+ "Re-plugging an existing entity from a zip is not supported yet — pick up the app " +
1107
+ "(grid_edit_existing_app) and re-plug the folder, or deploy the zip as a new entity.",
1108
+ );
1109
+ }
1110
+ if (anon) {
1111
+ throw new Error("A zip deploy creates a static app and needs sign-in — it cannot be anonymous.");
1112
+ }
1113
+ const expanded = await expandZipToProject(srcPath, hasHtml ? html : null);
1114
+ if (expanded.singleHtml) {
1115
+ // One-page archive → instant inspiration via the proven html wire.
1116
+ zipSingleHtml = expanded.singleHtml;
1117
+ hasPath = false;
1118
+ hasHtml = true;
1119
+ } else {
1120
+ // Multi-file / project archive → CLI deploy (see plugZipProjectViaCli).
1121
+ return plugZipProjectViaCli(ctx, expanded, input, deps);
1122
+ }
1123
+ }
910
1124
  let artifacts;
911
1125
  // Set on the single-file `html` path so the auth-aware inline size cap can be
912
1126
  // enforced once the anon-vs-authed wire is known (see below).
@@ -915,11 +1129,11 @@ export async function runPlug(ctx, input, deps = {}) {
915
1129
  // The ergonomic single-file publish path (the old drop verb): one self-
916
1130
  // contained HTML document → one index.html artifact, with the shared
917
1131
  // hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
918
- const art = htmlToArtifact(html, filename);
1132
+ const art = htmlToArtifact(zipSingleHtml ?? html, filename);
919
1133
  inlineHtmlBytes = art.buffer.byteLength;
920
1134
  artifacts = [art];
921
1135
  } else if (hasPath) {
922
- artifacts = collectPathArtifacts(srcPath);
1136
+ artifacts = collectPathArtifacts(effectivePath);
923
1137
  } else if (hasArtifacts) {
924
1138
  let total = 0;
925
1139
  artifacts = artifact_files.map((f) => {
@@ -181,9 +181,13 @@ export function registerTools(server, ctx) {
181
181
  ? {}
182
182
  : {
183
183
  path: z.string().optional().describe(
184
- "Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
185
- "recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
186
- "Mutually exclusive with `html` and `artifact_files`.",
184
+ "Local edition: path to the entity folder, a single file, or a .zip ARCHIVE to upload. " +
185
+ "A folder is read recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules " +
186
+ "always skipped). A .zip is extracted and deployed: with its own cloudgrid.yaml it deploys " +
187
+ "as that project; otherwise it becomes a static app (all files served). Use this for " +
188
+ "\"deploy this zip\" / \"gallery from these zipped images\" requests. Mutually exclusive with " +
189
+ "`html` and `artifact_files` — EXCEPT `html` + a .zip of assets: the html becomes index.html " +
190
+ "over the archive's files (generate the page, let the zip supply the images).",
187
191
  ),
188
192
  }),
189
193
  artifact_files: z.array(z.object({