@cloudgrid-io/mcp 0.8.1 → 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
@@ -25,7 +25,23 @@ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid
25
25
  "",
26
26
  );
27
27
 
28
+ // This MCP server's version — mirrors the CLI's cli_version in a report's origin.
29
+ // Read once from package.json; never throw (a report must never fail on this).
30
+ export const MCP_VERSION = (() => {
31
+ try {
32
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
33
+ } catch {
34
+ return "unknown";
35
+ }
36
+ })();
37
+
28
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;
29
45
  const CONSOLE_URL = "https://console.cloudgrid.io/";
30
46
 
31
47
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
@@ -70,6 +86,9 @@ Operating rules:
70
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.
71
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.
72
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.
73
92
 
74
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.`;
75
94
 
@@ -106,6 +125,7 @@ function readEntryDir(dirUrl) {
106
125
  });
107
126
  const pick =
108
127
  files.find((n) => n === "index.html") ||
128
+ files.find((n) => n === "index.md") ||
109
129
  files.find((n) => n.endsWith(".html")) ||
110
130
  files.find((n) => n.endsWith(".md")) ||
111
131
  (files.length === 1 ? files[0] : null);
@@ -771,6 +791,50 @@ function looksLikeFullHtml(s) {
771
791
  return head.startsWith("<!doctype html") || head.startsWith("<html");
772
792
  }
773
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
+
774
838
  // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
775
839
  // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
776
840
  const GUEST_ORG_SLUG = "guest";
@@ -816,6 +880,11 @@ export async function runDrop(
816
880
  ) {
817
881
  // `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
818
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
+
819
888
  // Defensive: the web edition schema excludes `path`, but if a model still
820
889
  // passes one (e.g. from a cached tool description), reject early with a
821
890
  // clear explanation.
@@ -825,30 +894,106 @@ export async function runDrop(
825
894
  );
826
895
  }
827
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
+
828
930
  let bytes;
829
931
  let name;
830
932
  let type;
831
-
832
- if (filePath) {
833
- bytes = await readFile(filePath);
834
- name = filename || basename(filePath);
835
- type = "application/octet-stream";
836
- } else if (typeof html === "string" && html.length > 0) {
837
- 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;
838
966
  if (!looksLikeFullHtml(content)) {
839
- content =
840
- `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
841
- `<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
+ }
842
992
  }
843
993
  bytes = Buffer.from(content, "utf8");
844
994
  name = filename || "index.html";
845
995
  type = "text/html";
846
- if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
847
- throw new Error(
848
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
849
- `Trim it, or sign in to publish larger.`,
850
- );
851
- }
996
+ enforceInlineCap = true;
852
997
  } else {
853
998
  throw new Error("Provide either `html` (inline content) or `path` (a local file).");
854
999
  }
@@ -910,6 +1055,26 @@ export async function runDrop(
910
1055
  }
911
1056
  const isAnonymousWire = mode === "anon-edit" || !authToken;
912
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
+
913
1078
  const headers = {};
914
1079
  if (!isAnonymousWire) {
915
1080
  headers["Authorization"] = `Bearer ${authToken}`;
@@ -1165,11 +1330,27 @@ export function scrubReportContext(obj, depth = 0) {
1165
1330
 
1166
1331
  // Send a consent-gated bug report to the CloudGrid team. The agent calls this
1167
1332
  // ONLY after the user explicitly agrees (see errorGuidance + the PLAYBOOK rule).
1168
- // Posts to POST /api/v2/errors/feedback with {app:"mcp", message, context,
1169
- // node_version, platform}. Authed → Bearer; anon+web → the trusted-server
1170
- // headers (works once Gilad opens the endpoint; until then a 401 degrades to a
1171
- // friendly "sign in to report"). Never throws — always returns friendly text.
1172
- export async function runReport(ctx, { message, context, include_conversation } = {}) {
1333
+ //
1334
+ // Matches the CLI reporter (packages/cli error-reporter.ts → client.reportError):
1335
+ // POST /api/v2/errors with the CLI payload shape
1336
+ // { type:'error', category, app, message, stack?, context, trace_id?,
1337
+ // failed_step?, http_status?, cli_version, node_version, platform }
1338
+ // so CLI + MCP reports land uniformly in the `errors` collection.
1339
+ //
1340
+ // Source attribution (Gilad's ask): every report says WHERE it came from —
1341
+ // source (mcp-stdio | mcp-hosted), client (the calling agent from MCP clientInfo),
1342
+ // platform, mcp_version. Sent BOTH as top-level fields AND mirrored in
1343
+ // `context.origin`. The POST /errors handler only persists known top-level keys
1344
+ // (it drops unknown ones), and `context` is stored + secret-stripped server-side,
1345
+ // so `context.origin` is the durable carrier — belt-and-suspenders.
1346
+ //
1347
+ // Auth: signed-in → Bearer; anon+web → the trusted-server headers (works once the
1348
+ // endpoint accepts the credential; until then a 401 degrades to "sign in to
1349
+ // report"). Honors CLOUDGRID_TELEMETRY=off (matches the CLI). Never throws.
1350
+ export async function runReport(
1351
+ ctx,
1352
+ { message, context, include_conversation, category, trace_id, failed_step, http_status } = {},
1353
+ ) {
1173
1354
  const summary = typeof message === "string" ? message.trim() : "";
1174
1355
  if (!summary) {
1175
1356
  return okResult({
@@ -1178,19 +1359,69 @@ export async function runReport(ctx, { message, context, include_conversation }
1178
1359
  });
1179
1360
  }
1180
1361
 
1181
- // Belt-and-suspenders scrub before the value ever leaves the process.
1182
- const safeContext =
1183
- context && typeof context === "object" ? scrubReportContext(context) : undefined;
1362
+ // Privacy escape hatch — no telemetry when explicitly disabled (matches the CLI
1363
+ // reporter's CLOUDGRID_TELEMETRY=off). Consent still gates the call regardless;
1364
+ // this is the belt-and-suspenders global opt-out. Nothing leaves the process.
1365
+ if (process.env.CLOUDGRID_TELEMETRY === "off") {
1366
+ return okResult({
1367
+ text: "Error reporting is disabled (CLOUDGRID_TELEMETRY=off) — nothing was sent.",
1368
+ structured: { status: "disabled" },
1369
+ });
1370
+ }
1371
+
1372
+ // ── Source attribution ──────────────────────────────────────────────────────
1373
+ // source: mcp-stdio (local edition) | mcp-hosted (web edition).
1374
+ const source = ctx.edition === "web" ? "mcp-hosted" : "mcp-stdio";
1375
+ // client: the calling agent captured from the MCP clientInfo at initialize.
1376
+ // Falls back to "unknown" — a report must never fail on missing client info.
1377
+ const ci = ctx.state?.client;
1378
+ const client =
1379
+ ci && ci.name
1380
+ ? ci.version
1381
+ ? `${ci.name} ${ci.version}`
1382
+ : String(ci.name)
1383
+ : "unknown";
1384
+ const platform = `${process.platform} ${process.arch}`;
1385
+
1386
+ // Belt-and-suspenders scrub before the value ever leaves the process. The
1387
+ // origin block is authored by us (not user/agent input), so it is appended
1388
+ // AFTER the scrub — it carries no secrets and must survive verbatim.
1389
+ const scrubbed =
1390
+ context && typeof context === "object" ? scrubReportContext(context) : {};
1391
+ const safeContext = {
1392
+ ...scrubbed,
1393
+ origin: {
1394
+ source,
1395
+ client,
1396
+ platform,
1397
+ mcp_version: MCP_VERSION,
1398
+ },
1399
+ };
1400
+
1184
1401
  // The full conversation is NEVER included unless the agent explicitly set the
1185
1402
  // flag (which the PLAYBOOK gates on the user's explicit yes). This tool only
1186
1403
  // records the flag alongside the report so intent is auditable — it does not
1187
1404
  // itself have the transcript.
1188
1405
  const body = {
1406
+ type: "error",
1407
+ // category: default "mcp" (or the failing tool name the agent passes).
1408
+ category: typeof category === "string" && category.trim() ? category.trim() : "mcp",
1189
1409
  app: "mcp",
1190
1410
  message: summary.slice(0, 5000),
1191
1411
  context: safeContext,
1412
+ // Diagnostic pivots (match the CLI) — only when the agent forwards them.
1413
+ ...(typeof trace_id === "string" && trace_id ? { trace_id } : {}),
1414
+ ...(typeof failed_step === "string" && failed_step ? { failed_step } : {}),
1415
+ ...(typeof http_status === "number" && Number.isFinite(http_status) ? { http_status } : {}),
1416
+ // Attribution, ALSO top-level (persisted once the handler accepts these keys;
1417
+ // context.origin is the fallback until then).
1418
+ source,
1419
+ client,
1420
+ platform,
1421
+ // cli_version stays null for MCP-originated reports; mcp_version carries our
1422
+ // version in context.origin (the CLI-analog lives there).
1423
+ cli_version: null,
1192
1424
  node_version: process.version,
1193
- platform: process.platform,
1194
1425
  ...(include_conversation === true ? { include_conversation: true } : {}),
1195
1426
  };
1196
1427
 
@@ -1215,7 +1446,7 @@ export async function runReport(ctx, { message, context, include_conversation }
1215
1446
 
1216
1447
  let res;
1217
1448
  try {
1218
- res = await fetch(`${API_BASE}/api/v2/errors/feedback`, {
1449
+ res = await fetch(`${API_BASE}/api/v2/errors`, {
1219
1450
  method: "POST",
1220
1451
  headers,
1221
1452
  body: JSON.stringify(body),
@@ -2013,6 +2244,114 @@ async function runVisibility(ctx, { target, visibility, org }) {
2013
2244
  };
2014
2245
  }
2015
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
+
2016
2355
  // ── Registration ───────────────────────────────────────────────────────────────
2017
2356
  // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
2018
2357
  // tools are included (they need a local machine).
@@ -2098,8 +2437,8 @@ export function registerTools(server, ctx) {
2098
2437
  ...dropReplugParams,
2099
2438
  }
2100
2439
  : {
2101
- html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
2102
- 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."),
2103
2442
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2104
2443
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2105
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."),
@@ -2110,7 +2449,7 @@ export function registerTools(server, ctx) {
2110
2449
  reg(
2111
2450
  "gridctl_drop",
2112
2451
  {
2113
- 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.",
2114
2453
  inputSchema: dropInputSchema,
2115
2454
  outputSchema: {
2116
2455
  url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
@@ -2302,7 +2641,9 @@ export function registerTools(server, ctx) {
2302
2641
  ? "Pass the source inline via artifact_files."
2303
2642
  : "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
2304
2643
  " Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
2305
- "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.",
2306
2647
  inputSchema: plugInputSchema,
2307
2648
  outputSchema: {
2308
2649
  entity_id: z.string().optional().describe("Globally unique — pass back as target_entity_id to re-plug."),
@@ -2426,6 +2767,42 @@ export function registerTools(server, ctx) {
2426
2767
  },
2427
2768
  );
2428
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
+
2429
2806
  // Login — both editions. Local opens a browser and saves to the credentials
2430
2807
  // file; web returns the URL and saves to the session.
2431
2808
  reg(
@@ -2705,10 +3082,26 @@ export function registerTools(server, ctx) {
2705
3082
  .boolean()
2706
3083
  .optional()
2707
3084
  .describe("Default false. Set true ONLY if the user explicitly agreed to include the full conversation."),
3085
+ category: z
3086
+ .string()
3087
+ .optional()
3088
+ .describe("Optional category, e.g. the failing tool name (\"deploy\"). Defaults to \"mcp\"."),
3089
+ trace_id: z
3090
+ .string()
3091
+ .optional()
3092
+ .describe("The server's trace/deploy id from the failed response, if any (helps support pivot to the trace)."),
3093
+ failed_step: z
3094
+ .string()
3095
+ .optional()
3096
+ .describe("The server-side pipeline step that failed, if known."),
3097
+ http_status: z
3098
+ .number()
3099
+ .optional()
3100
+ .describe("The HTTP status of the final failed request, if applicable."),
2708
3101
  },
2709
3102
  outputSchema: {
2710
3103
  status: z
2711
- .enum(["recorded", "rate_limited", "unauthorized", "error", "skipped"])
3104
+ .enum(["recorded", "rate_limited", "unauthorized", "error", "skipped", "disabled"])
2712
3105
  .describe("Outcome of the report."),
2713
3106
  },
2714
3107
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -2719,6 +3112,10 @@ export function registerTools(server, ctx) {
2719
3112
  message: input?.message,
2720
3113
  context: input?.context,
2721
3114
  include_conversation: input?.include_conversation === true,
3115
+ category: input?.category,
3116
+ trace_id: input?.trace_id,
3117
+ failed_step: input?.failed_step,
3118
+ http_status: input?.http_status,
2722
3119
  });
2723
3120
  } catch (err) {
2724
3121
  // Belt-and-suspenders: never throw noisily out of a report attempt.
package/src/web.js CHANGED
@@ -132,7 +132,18 @@ app.post("/mcp", async (req, res) => {
132
132
  }
133
133
  };
134
134
  const server = new McpServer({ name: "cloudgrid-mcp-web", version });
135
- registerTools(server, makeWebContext(newSessionId));
135
+ const webCtx = makeWebContext(newSessionId);
136
+ registerTools(server, webCtx);
137
+ // Capture the calling agent's clientInfo (name+version) for this session once
138
+ // the MCP handshake completes, so gridctl_report can attribute the origin
139
+ // (which agent: Claude/ChatGPT/Cursor/…). Never fatal — missing → "unknown".
140
+ server.server.oninitialized = () => {
141
+ try {
142
+ webCtx.state.client = server.server.getClientVersion() ?? null;
143
+ } catch {
144
+ webCtx.state.client = null;
145
+ }
146
+ };
136
147
  await server.connect(transport);
137
148
  await transport.handleRequest(req, res, req.body);
138
149
  });