@cloudgrid-io/mcp 0.8.2 → 0.10.0
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/README.md +33 -33
- package/package.json +4 -1
- package/src/corpus/cookbook.md +1 -1
- package/src/corpus/examples/app-with-data/index.md +251 -0
- package/src/corpus/install.md +1 -1
- package/src/corpus/skill-claim.md +1 -1
- package/src/corpus/skill-drop.md +2 -2
- package/src/corpus/skill-login.md +2 -2
- 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/usage.md +2 -2
- package/src/corpus/workflows/app-with-data.md +115 -0
- package/src/tools.js +350 -64
- package/src/widgets/live-result.html +1 -1
- package/src/widgets/org-picker.html +1 -1
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);
|
|
@@ -216,30 +226,30 @@ const MIN_CLI_VERSION = "0.12.0";
|
|
|
216
226
|
// Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
|
|
217
227
|
// The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
|
|
218
228
|
export const CLI_TOOL_VERBS = {
|
|
219
|
-
|
|
220
|
-
//
|
|
229
|
+
gridctl_init: ["init"],
|
|
230
|
+
// gridctl_plug is NOT here: gridctl_plug is now a direct-API tool
|
|
221
231
|
// (POST /api/v2/plug, spec v2 §3), not a CLI wrapper.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
232
|
+
gridctl_logs: ["logs"],
|
|
233
|
+
gridctl_share: ["visibility"],
|
|
234
|
+
gridctl_feedback: ["feedback"],
|
|
235
|
+
gridctl_whoami: ["whoami"],
|
|
236
|
+
gridctl_use: ["use"],
|
|
237
|
+
gridctl_logout: ["logout"],
|
|
238
|
+
gridctl_status: ["status"],
|
|
239
|
+
gridctl_info: ["info"],
|
|
240
|
+
gridctl_get: ["get"],
|
|
241
|
+
gridctl_describe_grid: ["describe"],
|
|
242
|
+
gridctl_pickup: ["pickup"],
|
|
243
|
+
gridctl_rename: ["rename"],
|
|
244
|
+
gridctl_unplug: ["unplug"],
|
|
245
|
+
gridctl_delete: ["delete"],
|
|
246
|
+
gridctl_rollback: ["rollback"],
|
|
247
|
+
gridctl_versions: ["versions"],
|
|
248
|
+
gridctl_env: ["env"],
|
|
249
|
+
gridctl_secrets: ["secrets"],
|
|
250
|
+
gridctl_scaffold: ["scaffold"],
|
|
251
|
+
gridctl_doctor: ["doctor"],
|
|
252
|
+
gridctl_open: ["open"],
|
|
243
253
|
};
|
|
244
254
|
|
|
245
255
|
// Simple semver comparison: true when `version` >= MIN_CLI_VERSION.
|
|
@@ -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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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,38 +2244,131 @@ 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).
|
|
2095
2358
|
export function registerTools(server, ctx) {
|
|
2096
|
-
// ── Tool naming: gridctl_*
|
|
2097
|
-
// Every tool is registered under its
|
|
2098
|
-
// `cloudgrid_*`
|
|
2099
|
-
//
|
|
2100
|
-
//
|
|
2359
|
+
// ── Tool naming: gridctl_* only ───────────────────────────────────────────
|
|
2360
|
+
// Every tool is registered under its `gridctl_*` name only. The legacy
|
|
2361
|
+
// deprecated `cloudgrid_*` aliases were removed in 0.10.0 — they doubled the
|
|
2362
|
+
// connector tool list and de-duplicated poorly in permission UIs. Clients
|
|
2363
|
+
// enumerate tools dynamically, so discovery is unaffected. `reg` wraps the
|
|
2101
2364
|
// object-config `server.registerTool`; `regTool` wraps the positional
|
|
2102
|
-
// `server.tool` shorthand.
|
|
2103
|
-
// alias by swapping the prefix.
|
|
2104
|
-
const aliasOf = (name) => name.replace(/^gridctl_/, "cloudgrid_");
|
|
2105
|
-
|
|
2365
|
+
// `server.tool` shorthand.
|
|
2106
2366
|
const reg = (name, config, handler) => {
|
|
2107
2367
|
server.registerTool(name, config, handler);
|
|
2108
|
-
const alias = aliasOf(name);
|
|
2109
|
-
if (alias !== name) {
|
|
2110
|
-
server.registerTool(
|
|
2111
|
-
alias,
|
|
2112
|
-
{ ...config, description: `(deprecated: use ${name}) ${config.description}` },
|
|
2113
|
-
handler,
|
|
2114
|
-
);
|
|
2115
|
-
}
|
|
2116
2368
|
};
|
|
2117
2369
|
|
|
2118
2370
|
const regTool = (name, description, schema, annotations, handler) => {
|
|
2119
2371
|
server.tool(name, description, schema, annotations, handler);
|
|
2120
|
-
const alias = aliasOf(name);
|
|
2121
|
-
if (alias !== name) {
|
|
2122
|
-
server.tool(alias, `(deprecated: use ${name}) ${description}`, schema, annotations, handler);
|
|
2123
|
-
}
|
|
2124
2372
|
};
|
|
2125
2373
|
|
|
2126
2374
|
// ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
|
|
@@ -2174,8 +2422,8 @@ export function registerTools(server, ctx) {
|
|
|
2174
2422
|
...dropReplugParams,
|
|
2175
2423
|
}
|
|
2176
2424
|
: {
|
|
2177
|
-
html: z.string().optional().describe("Inline HTML to publish
|
|
2178
|
-
path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
|
|
2425
|
+
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."),
|
|
2426
|
+
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
2427
|
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
2180
2428
|
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
2181
2429
|
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 +2434,7 @@ export function registerTools(server, ctx) {
|
|
|
2186
2434
|
reg(
|
|
2187
2435
|
"gridctl_drop",
|
|
2188
2436
|
{
|
|
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.",
|
|
2437
|
+
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
2438
|
inputSchema: dropInputSchema,
|
|
2191
2439
|
outputSchema: {
|
|
2192
2440
|
url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
|
|
@@ -2378,7 +2626,9 @@ export function registerTools(server, ctx) {
|
|
|
2378
2626
|
? "Pass the source inline via artifact_files."
|
|
2379
2627
|
: "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
|
|
2380
2628
|
" 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."
|
|
2629
|
+
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
|
|
2630
|
+
" If you want to edit an existing drop but no longer have its HTML, call gridctl_source first to " +
|
|
2631
|
+
"retrieve it, then re-plug with target_entity_id.",
|
|
2382
2632
|
inputSchema: plugInputSchema,
|
|
2383
2633
|
outputSchema: {
|
|
2384
2634
|
entity_id: z.string().optional().describe("Globally unique — pass back as target_entity_id to re-plug."),
|
|
@@ -2502,6 +2752,42 @@ export function registerTools(server, ctx) {
|
|
|
2502
2752
|
},
|
|
2503
2753
|
);
|
|
2504
2754
|
|
|
2755
|
+
// Source — both editions. Fetches a drop's current deployed HTML inline so an
|
|
2756
|
+
// agent that lost the content can edit it and re-plug in place.
|
|
2757
|
+
reg(
|
|
2758
|
+
"gridctl_source",
|
|
2759
|
+
{
|
|
2760
|
+
description:
|
|
2761
|
+
"Retrieve the CURRENT deployed HTML of an inspiration/drop inline as text, so you can edit it and " +
|
|
2762
|
+
"re-plug the SAME URL when you no longer have its source in context (e.g. the user asks to 'change the " +
|
|
2763
|
+
"color' of a drop you made earlier). Defaults to this session's last drop; otherwise pass the public " +
|
|
2764
|
+
"url (or grid+slug). Works for single-document HTML drops/inspirations (the common case). For " +
|
|
2765
|
+
"multi-file or runtime (app/agent) deploys, use gridctl_download (signed tarball URLs) instead. " +
|
|
2766
|
+
"Fetches the public *.cloudgrid.io URL server-side; read-only, creates nothing.",
|
|
2767
|
+
inputSchema: {
|
|
2768
|
+
entity_id: z.string().optional().describe("The drop's durable id. Defaults to this session's last drop."),
|
|
2769
|
+
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."),
|
|
2770
|
+
grid: z.string().optional().describe("Grid slug — used only to construct the URL when neither url nor session state is available."),
|
|
2771
|
+
slug: z.string().optional().describe("Entity slug — used with grid to construct the URL when neither url nor session state is available."),
|
|
2772
|
+
},
|
|
2773
|
+
outputSchema: {
|
|
2774
|
+
url: z.string().describe("The URL that was fetched."),
|
|
2775
|
+
entity_id: z.string().nullable().describe("The drop's entity id (echoed from input/session, if known)."),
|
|
2776
|
+
bytes: z.number().describe("Total size of the live document in bytes."),
|
|
2777
|
+
truncated: z.boolean().describe("True if the body exceeded 1.5MB and was cut — the drop may be multi-file."),
|
|
2778
|
+
html: z.string().describe("The current deployed HTML (truncated to 1.5MB when oversized)."),
|
|
2779
|
+
},
|
|
2780
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
2781
|
+
},
|
|
2782
|
+
async (input) => {
|
|
2783
|
+
try {
|
|
2784
|
+
return okResult(await runSource(ctx, input || {}));
|
|
2785
|
+
} catch (err) {
|
|
2786
|
+
return fail(err.message);
|
|
2787
|
+
}
|
|
2788
|
+
},
|
|
2789
|
+
);
|
|
2790
|
+
|
|
2505
2791
|
// Login — both editions. Local opens a browser and saves to the credentials
|
|
2506
2792
|
// file; web returns the URL and saves to the session.
|
|
2507
2793
|
reg(
|
|
@@ -124,7 +124,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
|
|
124
124
|
var statusEl = document.getElementById("vis-status");
|
|
125
125
|
if (statusEl) statusEl.textContent = "Updating\u2026";
|
|
126
126
|
if (!window.openai || !window.openai.callTool) return;
|
|
127
|
-
window.openai.callTool("
|
|
127
|
+
window.openai.callTool("gridctl_visibility", { visibility: vis }).then(function (res) {
|
|
128
128
|
var newVis = res && res.structuredContent && res.structuredContent.visibility;
|
|
129
129
|
if (newVis) {
|
|
130
130
|
data.current_visibility = newVis;
|
|
@@ -75,7 +75,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
|
|
75
75
|
if (input.fresh !== undefined) args.fresh = input.fresh;
|
|
76
76
|
args.grid = slug;
|
|
77
77
|
args.org = slug; // alias kept for compatibility
|
|
78
|
-
window.openai.callTool("
|
|
78
|
+
window.openai.callTool("gridctl_drop", args).then(function () {
|
|
79
79
|
// The host will render the new result with the live-result card.
|
|
80
80
|
}).catch(function () {
|
|
81
81
|
for (var m = 0; m < all.length; m++) all[m].classList.remove("loading");
|