@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.21.6",
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": {
@@ -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
- export const WIDGET_CSP = {
64
- connectDomains: ["https://*.cloudgrid.io"],
65
- resourceDomains: ["https://*.cloudgrid.io"],
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
@@ -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
- // grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
1680
- // explicit target_entity_id was given but a grid+slug pair is, resolve it to
1681
- // an existing entity_id and re-plug that in place. A slug that does NOT resolve
1682
- // to an existing entity → targetEntityId stays empty → this is a CREATE (no
1683
- // false-positive re-plug). target_entity_id remains the primary/documented
1684
- // handle. Best-effort resolve (pickup contract); never fetches the public URL.
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
- if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
1687
- const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
1688
- if (resolved?.entity_id) targetEntityId = resolved.entity_id;
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. When neither an explicit `grid` nor active-grid session
1777
- // state resolves one (the common hosted case, where active-grid is often
1778
- // unset), derive the home grid from the target entity itself so the write
1779
- // never depends on session state the API would otherwise reject for
1780
- // (GRID_HEADER_REQUIRED). Best-effort metadata resolve via the pickup
1781
- // contract (no claim); a null result simply falls back to today's behaviour.
1782
- if (!orgSlug && isEdit && targetEntityId) {
1783
- const resolved = await resolveEntityViaPickup(ctx, { target: targetEntityId });
1784
- if (resolved?.grid) orgSlug = resolved.grid;
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 inspiration path URL (`grid.cloudgrid.io/slug`).
2841
- const host = parsed.hostname;
2842
- const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
2843
- ? host.split(".")[0] : null);
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 ──────
@@ -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
- WIDGET_CSP,
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: "text/html;profile=mcp-app",
135
+ mimeType: CHATGPT_WIDGET_MIME,
127
136
  }, async () => ({
128
137
  contents: [{
129
138
  uri: LIVE_RESULT_URI,
130
- mimeType: "text/html;profile=mcp-app",
139
+ mimeType: CHATGPT_WIDGET_MIME,
131
140
  text: LIVE_RESULT_HTML,
132
- _meta: { ui: { csp: WIDGET_CSP } },
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: "text/html;profile=mcp-app",
147
+ mimeType: CHATGPT_WIDGET_MIME,
139
148
  }, async () => ({
140
149
  contents: [{
141
150
  uri: GRID_PICKER_URI,
142
- mimeType: "text/html;profile=mcp-app",
151
+ mimeType: CHATGPT_WIDGET_MIME,
143
152
  text: GRID_PICKER_HTML,
144
- _meta: { ui: { csp: WIDGET_CSP } },
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
@@ -31,6 +31,8 @@ export {
31
31
  errorGuidance,
32
32
  REPORT_OFFER,
33
33
  parseCliPlugUrl,
34
+ cliContinueHandoff,
35
+ archiveCorruptionSignature,
34
36
  plugViaCliFallback,
35
37
  runPlug,
36
38
  runPickup,
@@ -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>