@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/package.json +4 -1
- package/src/corpus/examples/app-with-data/index.md +251 -0
- package/src/corpus/templates/app-with-data/README.md +70 -0
- package/src/corpus/templates/app-with-data/cloudgrid.yaml +15 -0
- package/src/corpus/templates/app-with-data/index.md +261 -0
- package/src/corpus/templates/app-with-data/services/web/app/api/todos/route.js +48 -0
- package/src/corpus/templates/app-with-data/services/web/app/layout.js +32 -0
- package/src/corpus/templates/app-with-data/services/web/app/page.js +24 -0
- package/src/corpus/templates/app-with-data/services/web/app/todo-form.js +58 -0
- package/src/corpus/templates/app-with-data/services/web/lib/db.js +31 -0
- package/src/corpus/templates/app-with-data/services/web/package.json +16 -0
- package/src/corpus/troubleshooting/persistent-apps.md +22 -0
- package/src/corpus/workflows/app-with-data.md +115 -0
- package/src/index.js +12 -0
- package/src/tools.js +428 -31
- package/src/web.js +12 -1
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
841
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
|
|
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
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
});
|