@cloudgrid-io/mcp 0.8.2 → 0.9.1

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/src/tools.js CHANGED
@@ -36,6 +36,12 @@ export const MCP_VERSION = (() => {
36
36
  })();
37
37
 
38
38
  const ANON_HTML_MAX_BYTES = 2_000_000;
39
+ // Signed-in inline drops get a larger cap than the anonymous 2MB. Kept
40
+ // conservative — it must stay ≤ the platform's single-artifact byte limit.
41
+ // TODO(platform-confirm): confirm the server's single-artifact limit (the
42
+ // folder-plug path uses PLUG_MAX_TOTAL_BYTES = 100MB, but the single-artifact
43
+ // inline limit is not yet confirmed) and raise this to match.
44
+ const AUTHED_HTML_MAX_BYTES = 25_000_000;
39
45
  const CONSOLE_URL = "https://console.cloudgrid.io/";
40
46
 
41
47
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
@@ -80,6 +86,9 @@ Operating rules:
80
86
  6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
81
87
  7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
82
88
  8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call gridctl_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
89
+ 9. To modify an existing drop when you don't already have its HTML in context, first call gridctl_source to fetch the current HTML, apply your change, then call gridctl_drop/gridctl_plug with target_entity_id (the drop's entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
90
+ 10. Publishing a heavy or local file: in the local edition use the path parameter so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a drop looks empty, use gridctl_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
91
+ 11. Persistence check: if the user needs to SAVE data, share state across users/sessions, log in, or store submissions, that's a runtime app-with-data (Mongo-backed), NOT a static page — static templates keep state only in memory and lose it on refresh. Use the app-with-data workflow. This requires the LOCAL edition (Claude Desktop/Code or the CLI); on the hosted edition, tell the user persistence isn't available there and offer a static version.
83
92
 
84
93
  Deploy is edition-dependent: on the hosted MCP call the drop tool with the HTML; on local MCP / CLI write the file and run the plug tool. An HTML page deploys synchronously, so you get a URL right away.`;
85
94
 
@@ -116,6 +125,7 @@ function readEntryDir(dirUrl) {
116
125
  });
117
126
  const pick =
118
127
  files.find((n) => n === "index.html") ||
128
+ files.find((n) => n === "index.md") ||
119
129
  files.find((n) => n.endsWith(".html")) ||
120
130
  files.find((n) => n.endsWith(".md")) ||
121
131
  (files.length === 1 ? files[0] : null);
@@ -781,6 +791,50 @@ function looksLikeFullHtml(s) {
781
791
  return head.startsWith("<!doctype html") || head.startsWith("<html");
782
792
  }
783
793
 
794
+ // A real user (heavy persona-deck in Claude Desktop) hit an agent that worried
795
+ // about inline size, base64-encoded the HTML, and passed the base64 blob as
796
+ // `html` — which used to get wrapped in an HTML shell and published as a wall of
797
+ // text (an empty-looking page). Rescue that case: if the candidate text is not
798
+ // already full HTML but is a strict-base64 blob that DECODES to full HTML, use
799
+ // the decoded HTML. Applied to both the inline `html` string and the bytes read
800
+ // via `path` (a base64 `.txt` file). Returns the original text unchanged when it
801
+ // isn't base64-of-HTML, so genuine snippets are untouched.
802
+ function decodeIfBase64Html(text) {
803
+ if (typeof text !== "string" || looksLikeFullHtml(text)) {
804
+ return { html: text, wasBase64: false };
805
+ }
806
+ const stripped = text.replace(/\s+/g, "");
807
+ if (
808
+ stripped.length < 64 ||
809
+ stripped.length % 4 !== 0 ||
810
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(stripped)
811
+ ) {
812
+ return { html: text, wasBase64: false };
813
+ }
814
+ let decoded;
815
+ try {
816
+ decoded = Buffer.from(stripped, "base64").toString("utf8");
817
+ } catch {
818
+ return { html: text, wasBase64: false };
819
+ }
820
+ if (looksLikeFullHtml(decoded)) {
821
+ return { html: decoded, wasBase64: true };
822
+ }
823
+ return { html: text, wasBase64: false };
824
+ }
825
+
826
+ // Heuristic: does the string look like a bare filesystem path (not HTML)? Used
827
+ // to catch a model that passes a file path — or an invented `@/home/...`
828
+ // shorthand — as `html`. Single line, no HTML tag, path-ish shape.
829
+ function looksLikePath(s) {
830
+ if (typeof s !== "string") return false;
831
+ const t = s.trim();
832
+ if (t.length === 0 || t.length > 4096) return false;
833
+ if (/[\n\r]/.test(t)) return false;
834
+ if (/<[a-z!/]/i.test(t)) return false; // contains a tag → not a path
835
+ return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
836
+ }
837
+
784
838
  // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
785
839
  // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
786
840
  const GUEST_ORG_SLUG = "guest";
@@ -826,6 +880,11 @@ export async function runDrop(
826
880
  ) {
827
881
  // `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
828
882
  const targetGrid = org ?? grid;
883
+ // The `deps` seam (readFile/exists) exists ONLY so the content-handling
884
+ // regression tests can drive the `path` / `@`-path routes offline. Production
885
+ // always calls this with no deps.
886
+ const { readFile: readFileImpl = readFile, fsExists = existsSync } = deps;
887
+
829
888
  // Defensive: the web edition schema excludes `path`, but if a model still
830
889
  // passes one (e.g. from a cached tool description), reject early with a
831
890
  // clear explanation.
@@ -835,30 +894,106 @@ export async function runDrop(
835
894
  );
836
895
  }
837
896
 
897
+ // An agent may pass an `@`-prefixed path (invented `@/home/claude/...`
898
+ // shorthand) or a bare filesystem path as `html`. Normalise: strip a leading
899
+ // `@`, and in the local edition promote a path-looking `html` to the `path`
900
+ // route (read from disk) when the file exists. On the hosted server there is
901
+ // no disk, so a path-looking `html` is a clear error.
902
+ let effectivePath = filePath;
903
+ let htmlInput = typeof html === "string" ? html : undefined;
904
+ if (!effectivePath && typeof htmlInput === "string") {
905
+ let candidate = htmlInput;
906
+ if (candidate.startsWith("@")) candidate = candidate.slice(1);
907
+ if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
908
+ if (ctx.edition === "web") {
909
+ throw new Error(
910
+ `This looks like a file path (\`${candidate.trim()}\`), but the hosted server cannot read local ` +
911
+ `files — pass the raw HTML inline as \`html\`.`,
912
+ );
913
+ }
914
+ const resolvedPath = candidate.trim();
915
+ if (fsExists(resolvedPath)) {
916
+ effectivePath = resolvedPath;
917
+ htmlInput = undefined;
918
+ } else {
919
+ throw new Error(
920
+ `The path \`${resolvedPath}\` does not exist. Pass a valid \`path\` to the .html file, or pass ` +
921
+ `the raw HTML inline as \`html\` (do not base64-encode it, and there is no artifact_files parameter).`,
922
+ );
923
+ }
924
+ } else if (candidate !== htmlInput) {
925
+ // Leading `@` on genuine inline content — strip it and keep the content.
926
+ htmlInput = candidate;
927
+ }
928
+ }
929
+
838
930
  let bytes;
839
931
  let name;
840
932
  let type;
841
-
842
- if (filePath) {
843
- bytes = await readFile(filePath);
844
- name = filename || basename(filePath);
845
- type = "application/octet-stream";
846
- } else if (typeof html === "string" && html.length > 0) {
847
- let content = html;
933
+ // Anonymous drops are inline-capped hard; signed-in drops get a larger cap.
934
+ // The cap is enforced AFTER auth is resolved (see below), so only inline
935
+ // routes flag themselves here — `path` (local, read from disk) is uncapped.
936
+ let enforceInlineCap = false;
937
+
938
+ if (effectivePath) {
939
+ const raw = await readFileImpl(effectivePath);
940
+ const asText = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
941
+ const { html: decoded, wasBase64 } = decodeIfBase64Html(asText);
942
+ const lowerName = String(effectivePath).toLowerCase();
943
+ const nameIsHtml = lowerName.endsWith(".html") || lowerName.endsWith(".htm");
944
+ if (wasBase64) {
945
+ // A base64 `.txt` (or similar) that decoded to real HTML → publish the
946
+ // decoded HTML as text/html under an index.html name.
947
+ bytes = Buffer.from(decoded, "utf8");
948
+ type = "text/html";
949
+ name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
950
+ } else {
951
+ bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
952
+ // Content-type sniff (fix #4): serve HTML as text/html so it renders as a
953
+ // page instead of downloading as a blob.
954
+ if (looksLikeFullHtml(asText) || nameIsHtml) {
955
+ type = "text/html";
956
+ name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
957
+ } else {
958
+ type = "application/octet-stream";
959
+ name = filename || basename(effectivePath);
960
+ }
961
+ }
962
+ } else if (typeof htmlInput === "string" && htmlInput.length > 0) {
963
+ // Rescue a base64-of-HTML blob passed as `html` (real user repro).
964
+ const { html: resolved } = decodeIfBase64Html(htmlInput);
965
+ let content = resolved;
848
966
  if (!looksLikeFullHtml(content)) {
849
- content =
850
- `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
851
- `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
967
+ const stripped = content.replace(/\s+/g, "");
968
+ const looksBase64 =
969
+ stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
970
+ const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
971
+ const hasTag = /<[a-z][\s\S]*>/i.test(content);
972
+ if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
973
+ // Legit "share this snippet" — wrap a small text/markup fragment into a
974
+ // full document (preserve existing friendly behavior).
975
+ content =
976
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
977
+ `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
978
+ } else {
979
+ // Large, or base64 that failed to decode to HTML, or a bare file path:
980
+ // refuse instead of silently publishing garbage.
981
+ const kind = looksBase64
982
+ ? "base64"
983
+ : looksLikePath(content)
984
+ ? "a file path"
985
+ : "raw data";
986
+ throw new Error(
987
+ `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
988
+ `or in the local edition pass \`path\` to the .html file — do NOT base64-encode it. ` +
989
+ `There is no artifact_files parameter.`,
990
+ );
991
+ }
852
992
  }
853
993
  bytes = Buffer.from(content, "utf8");
854
994
  name = filename || "index.html";
855
995
  type = "text/html";
856
- if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
857
- throw new Error(
858
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
859
- `Trim it, or sign in to publish larger.`,
860
- );
861
- }
996
+ enforceInlineCap = true;
862
997
  } else {
863
998
  throw new Error("Provide either `html` (inline content) or `path` (a local file).");
864
999
  }
@@ -920,6 +1055,26 @@ export async function runDrop(
920
1055
  }
921
1056
  const isAnonymousWire = mode === "anon-edit" || !authToken;
922
1057
 
1058
+ // Auth-aware inline size cap (relocated here — auth is only known now). Anon
1059
+ // inline drops stay capped at 2MB; signed-in inline drops get the larger
1060
+ // AUTHED cap. `path` (read from disk) is uncapped and does not set the flag.
1061
+ if (enforceInlineCap) {
1062
+ if (isAnonymousWire) {
1063
+ if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
1064
+ throw new Error(
1065
+ `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1066
+ `Trim it, or sign in to publish larger.`,
1067
+ );
1068
+ }
1069
+ } else if (bytes.byteLength > AUTHED_HTML_MAX_BYTES) {
1070
+ throw new Error(
1071
+ `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1072
+ `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1073
+ `.html file (read from disk — no inline size limit) instead of pasting it inline.`,
1074
+ );
1075
+ }
1076
+ }
1077
+
923
1078
  const headers = {};
924
1079
  if (!isAnonymousWire) {
925
1080
  headers["Authorization"] = `Bearer ${authToken}`;
@@ -2089,6 +2244,114 @@ async function runVisibility(ctx, { target, visibility, org }) {
2089
2244
  };
2090
2245
  }
2091
2246
 
2247
+ // Max HTML we'll return inline from runSource. Past this, we return the first
2248
+ // slice with truncated:true — a re-plug needs the complete document, so a
2249
+ // truncated body is a signal the drop is likely multi-file rather than a single
2250
+ // editable HTML document.
2251
+ const SOURCE_MAX_BYTES = 1_500_000;
2252
+
2253
+ // Reject anything whose host is not `*.cloudgrid.io` (apex `cloudgrid.io`
2254
+ // included). SSRF guard for runSource: we only ever fetch live CloudGrid drops
2255
+ // server-side, never arbitrary hosts. Returns true when the host is allowed.
2256
+ function isCloudgridHost(hostname) {
2257
+ const h = String(hostname || "").toLowerCase();
2258
+ return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
2259
+ }
2260
+
2261
+ // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
2262
+ // that lost the content can edit it and re-plug in place. Resolves the fetch URL
2263
+ // (explicit url → session lastDrop → composed grid+slug), enforces the
2264
+ // `*.cloudgrid.io` SSRF guard, caps the body at SOURCE_MAX_BYTES, and fails
2265
+ // gracefully on a non-200. Uses the global `fetch` seam so tests can mock it.
2266
+ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2267
+ const last = ctx.state.lastDrop;
2268
+ // Resolution order: explicit url → session lastDrop.url (if entity_id matches
2269
+ // or no entity_id was given) → composePlugUrl(grid, slug) → fail.
2270
+ let target = null;
2271
+ if (typeof url === "string" && url.length > 0) {
2272
+ target = url;
2273
+ } else if (last?.url && (!entity_id || last.entity_id === entity_id)) {
2274
+ target = last.url;
2275
+ } else if (grid && slug) {
2276
+ // Inspirations/HTML drops are path-based at the org apex.
2277
+ target = composePlugUrl({ slug, grid });
2278
+ }
2279
+ if (!target) {
2280
+ throw new Error(
2281
+ "I don't have this drop's URL — pass the url (e.g. https://<grid>.cloudgrid.io/<slug>) or grid+slug.",
2282
+ );
2283
+ }
2284
+
2285
+ let parsed;
2286
+ try {
2287
+ parsed = new URL(target);
2288
+ } catch {
2289
+ throw new Error(`Not a valid URL: ${target}`);
2290
+ }
2291
+ if (parsed.protocol !== "https:" || !isCloudgridHost(parsed.hostname)) {
2292
+ throw new Error(
2293
+ `Refusing to fetch ${target}: source retrieval is limited to https://*.cloudgrid.io drops.`,
2294
+ );
2295
+ }
2296
+
2297
+ const resolvedUrl = parsed.toString();
2298
+ let res;
2299
+ try {
2300
+ res = await fetch(resolvedUrl, {
2301
+ method: "GET",
2302
+ redirect: "follow",
2303
+ signal: AbortSignal.timeout(15_000),
2304
+ });
2305
+ } catch (err) {
2306
+ throw new Error(`Could not reach the live drop at ${resolvedUrl}: ${err.message}`);
2307
+ }
2308
+
2309
+ // A redirect must not escape the allow-list (fetch follows automatically; the
2310
+ // final response URL is the one we actually read).
2311
+ if (res.url && res.url !== resolvedUrl) {
2312
+ let finalHost;
2313
+ try { finalHost = new URL(res.url).hostname; } catch { finalHost = ""; }
2314
+ if (!isCloudgridHost(finalHost)) {
2315
+ throw new Error(
2316
+ `Refusing to follow a redirect off CloudGrid (${res.url}): source retrieval is limited to https://*.cloudgrid.io.`,
2317
+ );
2318
+ }
2319
+ }
2320
+
2321
+ if (!res.ok) {
2322
+ // Graceful fail — never throw a raw fetch error at the model.
2323
+ throw new Error(
2324
+ `Couldn't read the live drop (HTTP ${res.status}). It may be expired, private, or claimed.`,
2325
+ );
2326
+ }
2327
+
2328
+ const buf = Buffer.from(await res.arrayBuffer());
2329
+ const totalBytes = buf.length;
2330
+ const truncated = totalBytes > SOURCE_MAX_BYTES;
2331
+ const html = (truncated ? buf.subarray(0, SOURCE_MAX_BYTES) : buf).toString("utf-8");
2332
+
2333
+ const lines = [
2334
+ `Current source for ${resolvedUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
2335
+ ];
2336
+ if (truncated) {
2337
+ lines.push(
2338
+ "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
2339
+ );
2340
+ }
2341
+ lines.push("", html);
2342
+
2343
+ return {
2344
+ text: lines.join("\n"),
2345
+ structured: {
2346
+ url: resolvedUrl,
2347
+ entity_id: entity_id ?? last?.entity_id ?? null,
2348
+ bytes: totalBytes,
2349
+ truncated,
2350
+ html,
2351
+ },
2352
+ };
2353
+ }
2354
+
2092
2355
  // ── Registration ───────────────────────────────────────────────────────────────
2093
2356
  // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
2094
2357
  // tools are included (they need a local machine).
@@ -2174,8 +2437,8 @@ export function registerTools(server, ctx) {
2174
2437
  ...dropReplugParams,
2175
2438
  }
2176
2439
  : {
2177
- html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
2178
- path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
2440
+ html: z.string().optional().describe("Inline HTML to publish (a small fragment is wrapped into a full document). Pass the RAW HTML — do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a file path here. For a large or image-heavy document, use `path` instead."),
2441
+ path: z.string().optional().describe("Path to a local .html file to upload instead of inline HTML — read from disk with no inline size limit. Preferred for large or image-heavy documents. There is no `artifact_files` parameter."),
2179
2442
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2180
2443
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2181
2444
  grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
@@ -2186,7 +2449,7 @@ export function registerTools(server, ctx) {
2186
2449
  reg(
2187
2450
  "gridctl_drop",
2188
2451
  {
2189
- description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. Calls POST /api/v2/plug directly.",
2452
+ description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. If you want to edit an existing drop but no longer have its HTML, call gridctl_source first to retrieve it, then re-plug with target_entity_id. For a large or image-heavy document in the local edition, pass `path` to the .html file (read from disk — no inline size limit); do NOT base64-encode the content and do NOT pass an `@`-prefixed path or a file path as `html`. There is no `artifact_files` parameter. Calls POST /api/v2/plug directly.",
2190
2453
  inputSchema: dropInputSchema,
2191
2454
  outputSchema: {
2192
2455
  url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
@@ -2378,7 +2641,9 @@ export function registerTools(server, ctx) {
2378
2641
  ? "Pass the source inline via artifact_files."
2379
2642
  : "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
2380
2643
  " Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
2381
- "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder.",
2644
+ "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
2645
+ " If you want to edit an existing drop but no longer have its HTML, call gridctl_source first to " +
2646
+ "retrieve it, then re-plug with target_entity_id.",
2382
2647
  inputSchema: plugInputSchema,
2383
2648
  outputSchema: {
2384
2649
  entity_id: z.string().optional().describe("Globally unique — pass back as target_entity_id to re-plug."),
@@ -2502,6 +2767,42 @@ export function registerTools(server, ctx) {
2502
2767
  },
2503
2768
  );
2504
2769
 
2770
+ // Source — both editions. Fetches a drop's current deployed HTML inline so an
2771
+ // agent that lost the content can edit it and re-plug in place.
2772
+ reg(
2773
+ "gridctl_source",
2774
+ {
2775
+ description:
2776
+ "Retrieve the CURRENT deployed HTML of an inspiration/drop inline as text, so you can edit it and " +
2777
+ "re-plug the SAME URL when you no longer have its source in context (e.g. the user asks to 'change the " +
2778
+ "color' of a drop you made earlier). Defaults to this session's last drop; otherwise pass the public " +
2779
+ "url (or grid+slug). Works for single-document HTML drops/inspirations (the common case). For " +
2780
+ "multi-file or runtime (app/agent) deploys, use gridctl_download (signed tarball URLs) instead. " +
2781
+ "Fetches the public *.cloudgrid.io URL server-side; read-only, creates nothing.",
2782
+ inputSchema: {
2783
+ entity_id: z.string().optional().describe("The drop's durable id. Defaults to this session's last drop."),
2784
+ url: z.string().optional().describe("The public URL of the drop (e.g. https://<grid>.cloudgrid.io/<slug>). Defaults to this session's last drop URL."),
2785
+ grid: z.string().optional().describe("Grid slug — used only to construct the URL when neither url nor session state is available."),
2786
+ slug: z.string().optional().describe("Entity slug — used with grid to construct the URL when neither url nor session state is available."),
2787
+ },
2788
+ outputSchema: {
2789
+ url: z.string().describe("The URL that was fetched."),
2790
+ entity_id: z.string().nullable().describe("The drop's entity id (echoed from input/session, if known)."),
2791
+ bytes: z.number().describe("Total size of the live document in bytes."),
2792
+ truncated: z.boolean().describe("True if the body exceeded 1.5MB and was cut — the drop may be multi-file."),
2793
+ html: z.string().describe("The current deployed HTML (truncated to 1.5MB when oversized)."),
2794
+ },
2795
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2796
+ },
2797
+ async (input) => {
2798
+ try {
2799
+ return okResult(await runSource(ctx, input || {}));
2800
+ } catch (err) {
2801
+ return fail(err.message);
2802
+ }
2803
+ },
2804
+ );
2805
+
2505
2806
  // Login — both editions. Local opens a browser and saves to the credentials
2506
2807
  // file; web returns the URL and saves to the session.
2507
2808
  reg(