@cloudgrid-io/mcp 0.21.6 → 0.21.8
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 +11 -2
- package/src/tools/constants.js +31 -4
- package/src/tools/deploy.js +196 -23
- package/src/tools/register.js +59 -11
- package/src/tools.js +2 -0
- package/src/widgets/grid-login/mcp-app.css +57 -0
- package/src/widgets/grid-login/mcp-app.html +26 -0
- package/src/widgets/grid-login/mcp-app.js +231 -0
- package/src/widgets/grid-login/poller.js +110 -0
- package/src/widgets/grid-login.html +150 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.8",
|
|
4
4
|
"description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"test:mcpb-boot": "node test/mcpb-boot.test.mjs",
|
|
37
37
|
"test:plug-timeout": "node test/plug-timeout.test.mjs",
|
|
38
38
|
"test:manifest-confirm": "node test/manifest-confirm.test.mjs",
|
|
39
|
+
"test:inline-multifile-guard": "node test/inline-multifile-guard.test.mjs",
|
|
39
40
|
"test:qa-courier": "node test/qa-courier.test.mjs",
|
|
40
41
|
"test:drift-guard": "node test/drift-guard.mjs",
|
|
41
42
|
"smoke:drop": "node test/smoke-drop.mjs",
|
|
@@ -68,15 +69,23 @@
|
|
|
68
69
|
"test:web-identity": "node --test test/web-identity.test.mjs",
|
|
69
70
|
"test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
|
|
70
71
|
"test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
|
|
71
|
-
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs"
|
|
72
|
+
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
|
|
73
|
+
"test:internal-refs": "node --test test/no-internal-refs.test.mjs",
|
|
74
|
+
"test:login-app": "node test/login-app.test.mjs",
|
|
75
|
+
"test:login-poller": "node test/login-poller.test.mjs",
|
|
76
|
+
"build:login-widget": "node scripts/build-login-widget.mjs"
|
|
72
77
|
},
|
|
73
78
|
"dependencies": {
|
|
79
|
+
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
74
80
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
75
81
|
"express": "^4.22.2",
|
|
76
82
|
"fflate": "^0.8.3",
|
|
77
83
|
"undici": "^8.10.0",
|
|
78
84
|
"zod": "^3.23.0"
|
|
79
85
|
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"esbuild": "^0.25.0"
|
|
88
|
+
},
|
|
80
89
|
"license": "Apache-2.0",
|
|
81
90
|
"homepage": "https://github.com/cloudgrid-io/mcp",
|
|
82
91
|
"repository": {
|
package/src/tools/constants.js
CHANGED
|
@@ -53,17 +53,44 @@ export const VISIBILITY_LABELS = {
|
|
|
53
53
|
// optional. Flip MCP_APPS_WIDGETS=1 in the platform manifest to re-enable once
|
|
54
54
|
// the widget HTML is verified to render. The resources stay registered either
|
|
55
55
|
// way (harmless when no tool references them via outputTemplate).
|
|
56
|
+
//
|
|
57
|
+
// 2026-08-25 (#308): the black frame was diagnosed as a MIME mismatch. ChatGPT
|
|
58
|
+
// discovers an Apps-SDK widget by a tool carrying openai/outputTemplate PLUS a
|
|
59
|
+
// ui:// resource whose mimeType is `text/html+skybridge` (verified against
|
|
60
|
+
// OpenAI's shipped example servers — openai/openai-apps-sdk-examples
|
|
61
|
+
// solar-system/shopping_cart/kitchen_sink main.py). Our two ChatGPT widgets
|
|
62
|
+
// declared `text/html;profile=mcp-app` — the SEP-1865/MCP-Apps MIME that CLAUDE
|
|
63
|
+
// reads (RESOURCE_MIME_TYPE in @modelcontextprotocol/ext-apps). ChatGPT saw the
|
|
64
|
+
// outputTemplate pointer, opened the skybridge iframe (dark chrome), but the
|
|
65
|
+
// resource was not a skybridge document, so the HTML/bridge never populated —
|
|
66
|
+
// the empty dark frame covering the text. CHATGPT_WIDGET_MIME below is the
|
|
67
|
+
// correct type. The flag STAYS OFF until a card is seen rendering in real
|
|
68
|
+
// ChatGPT (Developer Mode custom connector against staging); this only makes the
|
|
69
|
+
// machinery correct for that verification.
|
|
56
70
|
export const APPS_WIDGETS_ENABLED = process.env.MCP_APPS_WIDGETS === "1";
|
|
71
|
+
// The mimeType ChatGPT's Apps-SDK renderer requires for a ui:// widget resource.
|
|
72
|
+
// Distinct from RESOURCE_MIME_TYPE (`text/html;profile=mcp-app`, the SEP-1865
|
|
73
|
+
// key Claude reads) — the two hosts key off different MIME types, so the ChatGPT
|
|
74
|
+
// widgets and the Claude sign-in card must NOT share one.
|
|
75
|
+
export const CHATGPT_WIDGET_MIME = "text/html+skybridge";
|
|
57
76
|
export const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
|
|
58
77
|
// URI/resource-name/filename stay `org-picker` — that's the stable contract the
|
|
59
78
|
// web card is registered under; only the JS identifier moves toward grid.
|
|
60
79
|
export const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
|
|
61
80
|
export const LIVE_RESULT_HTML = readFileSync(new URL("../widgets/live-result.html", import.meta.url), "utf-8");
|
|
62
81
|
export const GRID_PICKER_HTML = readFileSync(new URL("../widgets/org-picker.html", import.meta.url), "utf-8");
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
|
|
83
|
+
// ── grid_login MCP App (SEP-1865, Claude web) ────────────────────────────────
|
|
84
|
+
// A SECOND, independent UI mechanism from the ChatGPT Apps-SDK widgets above.
|
|
85
|
+
// It is NOT gated behind MCP_APPS_WIDGETS: that flag controls only the
|
|
86
|
+
// openai/outputTemplate key ChatGPT reads, and it must stay exactly as it is.
|
|
87
|
+
// The login card binds via _meta.ui.resourceUri (the SEP-1865 key Claude reads),
|
|
88
|
+
// which ChatGPT ignores — so shipping it does not touch the ChatGPT path. A
|
|
89
|
+
// client without the UI extension ignores _meta.ui and still gets the text-first
|
|
90
|
+
// sign-in URL. The HTML is the self-contained bundle from
|
|
91
|
+
// scripts/build-login-widget.mjs (App class + card inlined; deny-by-default CSP).
|
|
92
|
+
export const GRID_LOGIN_APP_URI = "ui://grid-login/mcp-app.html";
|
|
93
|
+
export const GRID_LOGIN_APP_HTML = readFileSync(new URL("../widgets/grid-login.html", import.meta.url), "utf-8");
|
|
67
94
|
|
|
68
95
|
// The lazy npx fallback always fetches the LATEST published CLI, so the MCP is
|
|
69
96
|
// never left behind the platform's required CLI version (a pinned range went
|
package/src/tools/deploy.js
CHANGED
|
@@ -1480,6 +1480,51 @@ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
|
|
|
1480
1480
|
return guidance ? `${base} — ${guidance}` : base;
|
|
1481
1481
|
}
|
|
1482
1482
|
|
|
1483
|
+
// ── WORKAROUND(#311): inline multi-file re-plug guard + FETCHSOURCE handoff ───
|
|
1484
|
+
// WHAT IS BROKEN: the hosted (web) server materializes the uploaded multipart
|
|
1485
|
+
// parts into a source tarball SERVER-SIDE; for a MULTI-FILE runtime app
|
|
1486
|
+
// re-plugged INLINE it emits a non-gzip stream that dies at the build container's
|
|
1487
|
+
// FETCHSOURCE with "gzip: stdin: not in gzip format / tar: Child returned status
|
|
1488
|
+
// 1" — raw, unactionable output that stranded a real user (natalys-grid). This
|
|
1489
|
+
// client never gzips/tars anything (it uploads each file as an octet-stream part
|
|
1490
|
+
// — see buildForm; byte-exactness proven in test), so the corruption is a
|
|
1491
|
+
// SERVER-SIDE defect downstream of this process; the disk-based CLI uploads the
|
|
1492
|
+
// same files and works.
|
|
1493
|
+
// WHAT WOULD LET THIS BE REMOVED: the API-side fix to tarball materialization for
|
|
1494
|
+
// this shape (tracked on #311). Until then, two defenses live here:
|
|
1495
|
+
// 1. detect the shape BEFORE upload (the gate in runPlug) and hand over the
|
|
1496
|
+
// CLI commands with the real entity handle — never fail at the build.
|
|
1497
|
+
// 2. if a corrupt-archive build failure still slips through, rewrite the raw
|
|
1498
|
+
// tar/gzip log to the same actionable handoff (archiveCorruptionSignature).
|
|
1499
|
+
|
|
1500
|
+
// The "continue this exact entity from a terminal" CLI commands, with the real
|
|
1501
|
+
// handle substituted. Prefers grid/slug (what `pull` takes, human-readable);
|
|
1502
|
+
// falls back to the entity id. Same entity, same URL.
|
|
1503
|
+
export function cliContinueHandoff({ entityId, grid, slug } = {}) {
|
|
1504
|
+
const target = grid && slug ? `${grid}/${slug}` : slug || entityId || "<grid>/<app>";
|
|
1505
|
+
const dir = slug || entityId || "<app>";
|
|
1506
|
+
return (
|
|
1507
|
+
"Continue from a terminal with the CLI — it uploads the files from disk and updates the SAME entity (same URL):\n" +
|
|
1508
|
+
" npx -y @cloudgrid-io/cli@latest login\n" +
|
|
1509
|
+
` npx -y @cloudgrid-io/cli@latest pull ${target}\n` +
|
|
1510
|
+
` cd ${dir}\n` +
|
|
1511
|
+
" npx -y @cloudgrid-io/cli@latest plug"
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// True when a build failure is the #311 corrupt-archive signature (a FETCHSOURCE
|
|
1516
|
+
// gunzip/tar failure), as opposed to a genuine app build error the developer can
|
|
1517
|
+
// act on (a missing dependency, a syntax error) — those keep their real log tail.
|
|
1518
|
+
export function archiveCorruptionSignature(text) {
|
|
1519
|
+
const hay = String(text || "").toLowerCase();
|
|
1520
|
+
return (
|
|
1521
|
+
/not in gzip format/.test(hay) ||
|
|
1522
|
+
/gzip:\s*stdin/.test(hay) ||
|
|
1523
|
+
/tar:\s*child returned status/.test(hay) ||
|
|
1524
|
+
/invalid gzip/.test(hay)
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1483
1528
|
// Parse the live URL the CLI prints on a successful `plug`. The CLI prints the
|
|
1484
1529
|
// canonical https://…cloudgrid.io URL somewhere in stdout (labelled "Outlet"
|
|
1485
1530
|
// / "Live" / bare); take the last cloudgrid.io URL it emits — the final line is
|
|
@@ -1557,6 +1602,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1557
1602
|
target_entity_id,
|
|
1558
1603
|
grid,
|
|
1559
1604
|
slug,
|
|
1605
|
+
url: targetUrl,
|
|
1560
1606
|
hints,
|
|
1561
1607
|
anon,
|
|
1562
1608
|
owner_token,
|
|
@@ -1676,20 +1722,68 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1676
1722
|
);
|
|
1677
1723
|
}
|
|
1678
1724
|
|
|
1679
|
-
//
|
|
1680
|
-
// explicit target_entity_id was given
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
//
|
|
1725
|
+
// Re-plug handle resolution (the pickup contract's `replug_handle`): when no
|
|
1726
|
+
// explicit target_entity_id was given, resolve one from an alternative target
|
|
1727
|
+
// the caller DID name — a grid+slug pair, or a CloudGrid `url` — so an edge
|
|
1728
|
+
// identified only by its public URL re-plugs the SAME entity in place instead
|
|
1729
|
+
// of minting a new one. A target that does NOT resolve to an existing entity →
|
|
1730
|
+
// targetEntityId stays empty → this is a CREATE (no false-positive re-plug).
|
|
1731
|
+
// target_entity_id remains the primary/documented handle. Best-effort resolve
|
|
1732
|
+
// (pickup contract); never fetches the public URL.
|
|
1733
|
+
//
|
|
1734
|
+
// #296: a `url` is only trusted as a re-plug target when its host is a real
|
|
1735
|
+
// CloudGrid grid apex (gridFromCloudgridUrl != null) — a foreign or malformed
|
|
1736
|
+
// host is NOT sent to the resolver, so an untrusted URL can never redirect a
|
|
1737
|
+
// write to another entity. `pickupData` is reused below to header the write
|
|
1738
|
+
// without a second round-trip.
|
|
1685
1739
|
let targetEntityId = target_entity_id;
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1740
|
+
const urlGrid = gridFromCloudgridUrl(targetUrl);
|
|
1741
|
+
let pickupData = null;
|
|
1742
|
+
if (typeof targetEntityId !== "string" || targetEntityId.length === 0) {
|
|
1743
|
+
if (grid && slug) {
|
|
1744
|
+
pickupData = await resolveEntityViaPickup(ctx, { target: slug, grid });
|
|
1745
|
+
} else if (urlGrid) {
|
|
1746
|
+
pickupData = await resolveEntityViaPickup(ctx, { url: targetUrl, grid: urlGrid });
|
|
1747
|
+
}
|
|
1748
|
+
if (pickupData?.entity_id) targetEntityId = pickupData.entity_id;
|
|
1689
1749
|
}
|
|
1690
1750
|
|
|
1691
1751
|
const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
|
|
1692
1752
|
|
|
1753
|
+
// ── WORKAROUND(#311): pre-upload gate for inline multi-file runtime RE-PLUG ──
|
|
1754
|
+
// WHAT IS BROKEN: the hosted server's tarball materialization corrupts the
|
|
1755
|
+
// source archive for a multi-file runtime app re-plugged INLINE — it emits a
|
|
1756
|
+
// non-gzip stream that dies at the build container's FETCHSOURCE with
|
|
1757
|
+
// "gzip: stdin: not in gzip format / tar: Child returned status 1". This client
|
|
1758
|
+
// uploads byte-exact octet-stream parts (proven in test), so the defect is
|
|
1759
|
+
// SERVER-SIDE, not here. It stranded a real user (natalys-grid) twice.
|
|
1760
|
+
// WHAT WOULD LET THIS BE REMOVED: the API-side fix to tarball materialization
|
|
1761
|
+
// for this shape (tracked on #311). Once hosted rebuilds a multi-file inline
|
|
1762
|
+
// re-plug reliably, delete this gate — the inline path would just work.
|
|
1763
|
+
// Until then, detect the shape BEFORE uploading and hand over the CLI, which
|
|
1764
|
+
// uploads from disk and updates the SAME entity. NARROW by design so it never
|
|
1765
|
+
// touches the cases that work today: hosted (web) only, RE-PLUG only (a real
|
|
1766
|
+
// entity id in hand to substitute), MULTIPLE files, and a ROOT cloudgrid.yaml
|
|
1767
|
+
// that DECLARES services (i.e. a runtime app, not a static bundle). A single
|
|
1768
|
+
// HTML page, or a few small text files with no service manifest, are untouched
|
|
1769
|
+
// and still plug inline. Multi-file runtime CREATES are also untouched (issue
|
|
1770
|
+
// #48 aligned that bundle) — only the re-plug shape that failed twice is gated.
|
|
1771
|
+
if (ctx.edition === "web" && isEdit && hasArtifacts && artifacts.length > 1) {
|
|
1772
|
+
const manifest = detectSourceManifest(input);
|
|
1773
|
+
if (manifest && manifest.services.length > 0) {
|
|
1774
|
+
const slugForMsg = pickupData?.slug || (typeof slug === "string" && slug ? slug : null);
|
|
1775
|
+
const gridForMsg = pickupData?.grid || (typeof grid === "string" && grid ? grid : null);
|
|
1776
|
+
throw new Error(
|
|
1777
|
+
`This is a multi-file runtime app (${artifacts.length} files + a cloudgrid.yaml declaring ` +
|
|
1778
|
+
`services: ${manifest.services.join(", ")}) being re-plugged inline on the hosted server. ` +
|
|
1779
|
+
"The hosted server can't reliably rebuild a multi-file app from an inline upload — the source " +
|
|
1780
|
+
"archive arrives corrupt at build (#311), which is why the identical source plugs fine from the " +
|
|
1781
|
+
"CLI. " +
|
|
1782
|
+
cliContinueHandoff({ entityId: targetEntityId, grid: gridForMsg, slug: slugForMsg }),
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1693
1787
|
// An inspiration edit content-versions the FIRST uploaded artifact — when a
|
|
1694
1788
|
// multi-file folder rides a re-plug, put the primary entry first so the edit
|
|
1695
1789
|
// swaps the right file (index.html > any .html > everything else).
|
|
@@ -1771,17 +1865,26 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1771
1865
|
// the caller's membership from this header and requires it to MATCH the
|
|
1772
1866
|
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1773
1867
|
// active grid.
|
|
1774
|
-
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1775
1868
|
// #296: an EDIT's destination is NOT ambiguous — the entity already lives in
|
|
1776
|
-
// exactly one grid
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1869
|
+
// exactly one grid, and every way of naming that entity carries the grid.
|
|
1870
|
+
// Resolve it in a deliberate order, cheapest reliable source first, so the
|
|
1871
|
+
// write never depends on session state the API would otherwise reject for
|
|
1872
|
+
// (GRID_HEADER_REQUIRED):
|
|
1873
|
+
// 1. explicit `grid` param — the caller said so.
|
|
1874
|
+
// 2. active grid — session state, no network.
|
|
1875
|
+
// 3. the URL host — FREE: the grid is the subdomain of a
|
|
1876
|
+
// CloudGrid `url` the user pasted; read it before any round-trip. A
|
|
1877
|
+
// foreign/malformed host yields null (gridFromCloudgridUrl) and we fall
|
|
1878
|
+
// through — never a silent redirect.
|
|
1879
|
+
// 4. the target entity itself — a pickup round-trip, only if 1–3 all
|
|
1880
|
+
// came up empty. Reuse a pickup already done above to resolve the id.
|
|
1881
|
+
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1882
|
+
if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
|
|
1883
|
+
if (!orgSlug && isEdit) {
|
|
1884
|
+
if (!pickupData && targetEntityId) {
|
|
1885
|
+
pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
|
|
1886
|
+
}
|
|
1887
|
+
if (pickupData?.grid) orgSlug = pickupData.grid;
|
|
1785
1888
|
}
|
|
1786
1889
|
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1787
1890
|
if (orgSlug) {
|
|
@@ -2024,6 +2127,21 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2024
2127
|
data.status = "live";
|
|
2025
2128
|
data.poll_url = undefined;
|
|
2026
2129
|
} else if (verdict.status === "failed") {
|
|
2130
|
+
// WORKAROUND(#311): a corrupt-archive FETCHSOURCE failure is unactionable
|
|
2131
|
+
// as raw tar output — never surface it verbatim. WHAT IS BROKEN: the
|
|
2132
|
+
// server-side tarball materialization corrupts the archive for a multi-file
|
|
2133
|
+
// runtime re-plug; WHAT WOULD LET THIS BE REMOVED: the API-side fix (#311),
|
|
2134
|
+
// after which no corrupt-archive verdict reaches here. Name the CLI path
|
|
2135
|
+
// with the real handle instead (defense-in-depth behind the pre-upload gate
|
|
2136
|
+
// above; this catches any shape the gate didn't). Genuine app build errors
|
|
2137
|
+
// keep their real log tail — those the developer CAN act on.
|
|
2138
|
+
if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
|
|
2139
|
+
throw new Error(
|
|
2140
|
+
"The uploaded source could not be read by the build (its archive was corrupt) — the URL is NOT " +
|
|
2141
|
+
"live. This is a known limitation of inline multi-file uploads on the hosted server (#311). " +
|
|
2142
|
+
cliContinueHandoff({ entityId: data.entity_id, grid: data.grid, slug: data.slug }),
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2027
2145
|
const msg = verdict.error || "The build failed.";
|
|
2028
2146
|
throw new Error(
|
|
2029
2147
|
`Deploy failed (trace ${data.trace_id ?? "n/a"}): ${msg} The URL is NOT live — do not give it to the user as working.` +
|
|
@@ -2651,6 +2769,25 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2651
2769
|
// the source zip (grid_get_app_source → source_download_url) or the
|
|
2652
2770
|
// pull command that downloads + links the folder so the SAME entity
|
|
2653
2771
|
// continues locally.
|
|
2772
|
+
// WORKAROUND(#311): a corrupt-archive FETCHSOURCE failure is unactionable raw
|
|
2773
|
+
// tar output — do NOT echo it. WHAT IS BROKEN: server-side tarball
|
|
2774
|
+
// materialization corrupts the archive for a multi-file runtime re-plug;
|
|
2775
|
+
// WHAT WOULD LET THIS BE REMOVED: the API-side fix (#311). Name the CLI
|
|
2776
|
+
// continue-path with the real handle instead.
|
|
2777
|
+
if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
|
|
2778
|
+
return {
|
|
2779
|
+
text: "The uploaded source could not be read by the build (its archive was corrupt) — the URL is " +
|
|
2780
|
+
"not live. This is a known limitation of inline multi-file uploads on the hosted server (#311); the " +
|
|
2781
|
+
"project files are NOT lost — they are saved on the entity. " +
|
|
2782
|
+
cliContinueHandoff({ entityId, grid: gridSlug, slug: ctx.state.lastDrop?.slug }),
|
|
2783
|
+
structured: {
|
|
2784
|
+
status: "failed",
|
|
2785
|
+
live: false,
|
|
2786
|
+
reason: "archive_corrupt",
|
|
2787
|
+
...(ctx.edition === "web" && entityId ? { source_recovery: { entity_id: entityId, via: ["grid_get_app_source", "grid pull"] } } : {}),
|
|
2788
|
+
},
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2654
2791
|
const handoff = ctx.edition === "web" && entityId
|
|
2655
2792
|
? `\nThe project files are NOT lost — they are saved on the entity. If it can't be fixed from this chat, hand the user their work: call grid_get_app_source for the source zip (source_download_url), or give them the local continue command: npx -y @cloudgrid-io/cli@latest pull ${gridSlug ? `${gridSlug}/` : ""}${ctx.state.lastDrop?.slug || entityId} — it downloads the project and links the folder so their next plug updates this same entity.`
|
|
2656
2793
|
: "";
|
|
@@ -2688,6 +2825,42 @@ function isCloudgridHost(hostname) {
|
|
|
2688
2825
|
return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
|
|
2689
2826
|
}
|
|
2690
2827
|
|
|
2828
|
+
// Derive the home grid slug from a CloudGrid URL (or a bare host). The grid is
|
|
2829
|
+
// the subdomain: `<grid>.cloudgrid.io/<slug>` (inspiration/path form) and
|
|
2830
|
+
// `<slug>--<grid>.cloudgrid.io` (runtime/flat form — grid is the label after
|
|
2831
|
+
// `--`), matching composePlugUrl's two serving shapes.
|
|
2832
|
+
//
|
|
2833
|
+
// SECURITY (#296): this derives a WRITE DESTINATION from user-supplied input, so
|
|
2834
|
+
// it is deliberately strict — it reuses `isCloudgridHost` (the single host check,
|
|
2835
|
+
// SSRF-shaped) rather than string-matching, and returns null for anything that is
|
|
2836
|
+
// not a trusted grid apex: a foreign or lookalike host, a malformed URL, the bare
|
|
2837
|
+
// apex `cloudgrid.io`, a deeper host (`a.b.cloudgrid.io`), or the anonymous
|
|
2838
|
+
// `guest` apex (never an authed write destination). A null result means "I could
|
|
2839
|
+
// not name the grid" → the caller falls through to today's behaviour and NEVER
|
|
2840
|
+
// silently redirects the write to a grid the user did not mean.
|
|
2841
|
+
export function gridFromCloudgridUrl(input) {
|
|
2842
|
+
const raw = String(input ?? "").trim();
|
|
2843
|
+
if (!raw) return null;
|
|
2844
|
+
let parsed;
|
|
2845
|
+
try {
|
|
2846
|
+
// Accept a bare host (`shoobi.cloudgrid.io/x`) by supplying a scheme; a
|
|
2847
|
+
// string that is already a URL keeps its own scheme.
|
|
2848
|
+
parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
2849
|
+
} catch {
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
if (!isCloudgridHost(parsed.hostname)) return null;
|
|
2853
|
+
const host = parsed.hostname.toLowerCase();
|
|
2854
|
+
const labels = host.split(".");
|
|
2855
|
+
// Exactly `<sub>.cloudgrid.io`. The apex (`cloudgrid.io`, 2 labels) and any
|
|
2856
|
+
// deeper host (`a.b.cloudgrid.io`, 4+) name no single grid apex we trust.
|
|
2857
|
+
if (labels.length !== 3) return null;
|
|
2858
|
+
const sub = labels[0];
|
|
2859
|
+
const grid = sub.includes("--") ? sub.split("--").pop() : sub;
|
|
2860
|
+
if (!grid || grid === GUEST_ORG_SLUG) return null;
|
|
2861
|
+
return grid;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2691
2864
|
// Shape an HTML string into the grid_get_app_source result (shared by the API-read
|
|
2692
2865
|
// and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
|
|
2693
2866
|
// optional edition metadata resolved from the pickup contract (kind, single_html,
|
|
@@ -2837,10 +3010,10 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
|
|
|
2837
3010
|
const resolvedUrl = parsed.toString();
|
|
2838
3011
|
let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
|
|
2839
3012
|
|
|
2840
|
-
// Derive the grid/slug hint from the
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
3013
|
+
// Derive the grid/slug hint from the URL. gridFromCloudgridUrl is the single
|
|
3014
|
+
// host→grid parser (it also handles the flat `<slug>--<grid>` form the old
|
|
3015
|
+
// inline check missed); the slug is the first path segment.
|
|
3016
|
+
const gridHint = grid ?? gridFromCloudgridUrl(resolvedUrl);
|
|
2844
3017
|
const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
|
|
2845
3018
|
|
|
2846
3019
|
// ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
|
package/src/tools/register.js
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
// Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
6
7
|
import { newLoginCode, buildLoginUrl, pollStatusOnce, checkApiConnectivity, decodeJwt as decodeJwtAuth } from "../auth.js";
|
|
7
8
|
import { PLAYBOOK, fetchCorpus, listWorkflows } from "../playbook.js";
|
|
8
9
|
import {
|
|
9
10
|
APPS_WIDGETS_ENABLED,
|
|
11
|
+
CHATGPT_WIDGET_MIME,
|
|
10
12
|
LIVE_RESULT_URI,
|
|
11
13
|
GRID_PICKER_URI,
|
|
12
14
|
LIVE_RESULT_HTML,
|
|
13
15
|
GRID_PICKER_HTML,
|
|
14
|
-
|
|
16
|
+
GRID_LOGIN_APP_URI,
|
|
17
|
+
GRID_LOGIN_APP_HTML,
|
|
15
18
|
} from "./constants.js";
|
|
16
19
|
import { fail, okResult } from "./util.js";
|
|
17
20
|
import { cliTool, tryOpenBrowser } from "./cli.js";
|
|
@@ -121,27 +124,50 @@ export function registerTools(server, ctx) {
|
|
|
121
124
|
|
|
122
125
|
// ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
|
|
123
126
|
if (ctx.edition === "web") {
|
|
127
|
+
// ChatGPT Apps-SDK widgets. mimeType MUST be text/html+skybridge
|
|
128
|
+
// (CHATGPT_WIDGET_MIME) — NOT the MCP-Apps `text/html;profile=mcp-app` the
|
|
129
|
+
// Claude card uses; that mismatch was the black frame (#308). widgetAccessible
|
|
130
|
+
// lets the widget call tools back over window.openai.callTool (the visibility
|
|
131
|
+
// pills call grid_visibility). These make no direct network requests, so no
|
|
132
|
+
// openai/widgetCSP is declared — the default deny is correct.
|
|
124
133
|
server.registerResource("cloudgrid-live-result", LIVE_RESULT_URI, {
|
|
125
134
|
description: "Live result card after a CloudGrid drop — shows URL, grid link, and visibility controls.",
|
|
126
|
-
mimeType:
|
|
135
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
127
136
|
}, async () => ({
|
|
128
137
|
contents: [{
|
|
129
138
|
uri: LIVE_RESULT_URI,
|
|
130
|
-
mimeType:
|
|
139
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
131
140
|
text: LIVE_RESULT_HTML,
|
|
132
|
-
_meta: {
|
|
141
|
+
_meta: { "openai/widgetAccessible": true },
|
|
133
142
|
}],
|
|
134
143
|
}));
|
|
135
144
|
|
|
136
145
|
server.registerResource("cloudgrid-org-picker", GRID_PICKER_URI, {
|
|
137
146
|
description: "Grid picker card — lets the user choose which grid to plug into.",
|
|
138
|
-
mimeType:
|
|
147
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
139
148
|
}, async () => ({
|
|
140
149
|
contents: [{
|
|
141
150
|
uri: GRID_PICKER_URI,
|
|
142
|
-
mimeType:
|
|
151
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
143
152
|
text: GRID_PICKER_HTML,
|
|
144
|
-
_meta: {
|
|
153
|
+
_meta: { "openai/widgetAccessible": true },
|
|
154
|
+
}],
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
// grid_login MCP App (SEP-1865). Registered via the official ext-apps
|
|
158
|
+
// helper, which defaults the MIME type to RESOURCE_MIME_TYPE
|
|
159
|
+
// (text/html;profile=mcp-app) — the type Claude web looks for. NO csp
|
|
160
|
+
// domains: the card makes no network requests of its own (it only asks the
|
|
161
|
+
// host to open the sign-in URL and calls grid_login_status back over the
|
|
162
|
+
// bridge), so deny-by-default is exactly right. Independent of
|
|
163
|
+
// APPS_WIDGETS_ENABLED by design (see constants.js).
|
|
164
|
+
registerAppResource(server, "cloudgrid-grid-login", GRID_LOGIN_APP_URI, {
|
|
165
|
+
description: "CloudGrid sign-in card — opens the sign-in URL and confirms when you return.",
|
|
166
|
+
}, async () => ({
|
|
167
|
+
contents: [{
|
|
168
|
+
uri: GRID_LOGIN_APP_URI,
|
|
169
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
170
|
+
text: GRID_LOGIN_APP_HTML,
|
|
145
171
|
}],
|
|
146
172
|
}));
|
|
147
173
|
}
|
|
@@ -380,6 +406,13 @@ export function registerTools(server, ctx) {
|
|
|
380
406
|
"raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
|
|
381
407
|
"entity is treated as a CREATE (never a false-positive re-plug).",
|
|
382
408
|
),
|
|
409
|
+
url: z.string().optional().describe(
|
|
410
|
+
"Alternative RE-PLUG handle: the entity's public CloudGrid URL (e.g. https://<grid>.cloudgrid.io/<slug>) " +
|
|
411
|
+
"— use it to edit in place when you have the URL but no target_entity_id (the common \"change the color of " +
|
|
412
|
+
"this page\" flow after grid_get_app_source). The grid is read from the host and the entity is resolved via " +
|
|
413
|
+
"the pickup contract, so no `grid` param is needed. target_entity_id and grid+slug take precedence. A " +
|
|
414
|
+
"non-CloudGrid host is ignored (never redirects the write); a URL that does not resolve is treated as a CREATE.",
|
|
415
|
+
),
|
|
383
416
|
hints: z.object({
|
|
384
417
|
kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
|
|
385
418
|
yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
|
|
@@ -485,11 +518,13 @@ export function registerTools(server, ctx) {
|
|
|
485
518
|
// live at a public URL in place. Versions + grid rollback exist, but the
|
|
486
519
|
// honest MCP annotation for overwrite-live-state is destructive.
|
|
487
520
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
521
|
+
// ChatGPT-only binding: openai/outputTemplate points at the skybridge
|
|
522
|
+
// widget. Deliberately NOT the MCP-Apps `ui.resourceUri` key — that is
|
|
523
|
+
// Claude's, and the live-result resource is now text/html+skybridge, which
|
|
524
|
+
// Claude cannot render; carrying it here would regress the Claude path when
|
|
525
|
+
// this flag is flipped. The two mechanisms stay independent (#308/#303).
|
|
488
526
|
...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
|
|
489
|
-
_meta: {
|
|
490
|
-
ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
|
|
491
|
-
"openai/outputTemplate": LIVE_RESULT_URI,
|
|
492
|
-
},
|
|
527
|
+
_meta: { "openai/outputTemplate": LIVE_RESULT_URI },
|
|
493
528
|
} : {}),
|
|
494
529
|
};
|
|
495
530
|
const plugHandler = async (input) => {
|
|
@@ -662,6 +697,14 @@ export function registerTools(server, ctx) {
|
|
|
662
697
|
login_url: z.string().describe("URL to open in a browser to complete sign-in."),
|
|
663
698
|
},
|
|
664
699
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
700
|
+
// Bind the SEP-1865 sign-in card on the web edition (Claude web). This is
|
|
701
|
+
// the SEP-1865 key (_meta.ui.resourceUri); ChatGPT reads openai/outputTemplate
|
|
702
|
+
// and ignores it, and a client without the UI extension ignores it too and
|
|
703
|
+
// gets the text-first result below. NOT gated behind MCP_APPS_WIDGETS — a
|
|
704
|
+
// second, independent mechanism (see constants.js). The result shape is
|
|
705
|
+
// unchanged: content[0] stays the sign-in URL text so the link is never
|
|
706
|
+
// unreachable if the card does not render.
|
|
707
|
+
...(ctx.edition === "web" ? { _meta: { ui: { resourceUri: GRID_LOGIN_APP_URI } } } : {}),
|
|
665
708
|
},
|
|
666
709
|
async () => {
|
|
667
710
|
try {
|
|
@@ -764,6 +807,11 @@ export function registerTools(server, ctx) {
|
|
|
764
807
|
url: z.string().optional().describe("URL of the entity, if returned."),
|
|
765
808
|
},
|
|
766
809
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
810
|
+
// The live-result widget's visibility pills call this back over
|
|
811
|
+
// window.openai.callTool; ChatGPT only permits that when the target tool
|
|
812
|
+
// is marked openai/widgetAccessible. ChatGPT-only key, inert on Claude and
|
|
813
|
+
// when the widget is disabled. (#308)
|
|
814
|
+
...(ctx.edition === "web" ? { _meta: { "openai/widgetAccessible": true } } : {}),
|
|
767
815
|
},
|
|
768
816
|
async (input) => {
|
|
769
817
|
try {
|
package/src/tools.js
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
:root { color-scheme: light dark; }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
body {
|
|
4
|
+
margin: 0;
|
|
5
|
+
font-family: var(--font-sans, Inter, system-ui, -apple-system, sans-serif);
|
|
6
|
+
background: transparent;
|
|
7
|
+
color: light-dark(#0d0d0f, #fafafa);
|
|
8
|
+
}
|
|
9
|
+
#card {
|
|
10
|
+
max-width: 420px;
|
|
11
|
+
margin: 0 auto;
|
|
12
|
+
padding: 1.5rem;
|
|
13
|
+
}
|
|
14
|
+
h1 {
|
|
15
|
+
font-size: 1.15rem;
|
|
16
|
+
margin: 0 0 0.5rem;
|
|
17
|
+
}
|
|
18
|
+
.lede {
|
|
19
|
+
margin: 0 0 1rem;
|
|
20
|
+
font-size: 0.9rem;
|
|
21
|
+
line-height: 1.45;
|
|
22
|
+
opacity: 0.85;
|
|
23
|
+
}
|
|
24
|
+
.status {
|
|
25
|
+
margin: 0 0 1rem;
|
|
26
|
+
font-size: 0.85rem;
|
|
27
|
+
opacity: 0.7;
|
|
28
|
+
min-height: 1.2em;
|
|
29
|
+
}
|
|
30
|
+
.actions {
|
|
31
|
+
display: flex;
|
|
32
|
+
gap: 0.5rem;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
}
|
|
35
|
+
button {
|
|
36
|
+
font: inherit;
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
padding: 0.6rem 1.1rem;
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
border: 1px solid transparent;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
background: light-dark(#0d0d0f, #fafafa);
|
|
43
|
+
color: light-dark(#fafafa, #0d0d0f);
|
|
44
|
+
}
|
|
45
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
46
|
+
button.secondary {
|
|
47
|
+
background: transparent;
|
|
48
|
+
color: inherit;
|
|
49
|
+
border-color: light-dark(rgba(13,13,15,0.25), rgba(250,250,250,0.3));
|
|
50
|
+
}
|
|
51
|
+
.fallback {
|
|
52
|
+
margin: 1rem 0 0;
|
|
53
|
+
font-size: 0.8rem;
|
|
54
|
+
opacity: 0.7;
|
|
55
|
+
word-break: break-all;
|
|
56
|
+
}
|
|
57
|
+
.fallback a { color: inherit; }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>Sign in to CloudGrid</title>
|
|
8
|
+
<style>/* __CSS__ */</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main id="card">
|
|
12
|
+
<h1>Sign in to CloudGrid</h1>
|
|
13
|
+
<p class="lede">CloudGrid runs your apps with the infrastructure they need. Sign in to plug to your grid.</p>
|
|
14
|
+
<p class="status" id="status">Preparing the sign-in link.</p>
|
|
15
|
+
<div class="actions">
|
|
16
|
+
<button id="signin" type="button" disabled>Open sign-in</button>
|
|
17
|
+
<button id="check" type="button" class="secondary" hidden>Check sign-in</button>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="fallback" id="fallback" hidden>
|
|
20
|
+
Or open this link yourself:
|
|
21
|
+
<a id="fallback-url" href="#" target="_blank" rel="noopener noreferrer"></a>
|
|
22
|
+
</p>
|
|
23
|
+
</main>
|
|
24
|
+
<script type="module">/* __JS__ */</script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|