@cloudgrid-io/mcp 0.21.6 → 0.21.7

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.7",
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": {
@@ -68,15 +68,22 @@
68
68
  "test:web-identity": "node --test test/web-identity.test.mjs",
69
69
  "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
70
70
  "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"
71
+ "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
72
+ "test:internal-refs": "node --test test/no-internal-refs.test.mjs",
73
+ "test:login-app": "node test/login-app.test.mjs",
74
+ "build:login-widget": "node scripts/build-login-widget.mjs"
72
75
  },
73
76
  "dependencies": {
77
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
74
78
  "@modelcontextprotocol/sdk": "^1.30.0",
75
79
  "express": "^4.22.2",
76
80
  "fflate": "^0.8.3",
77
81
  "undici": "^8.10.0",
78
82
  "zod": "^3.23.0"
79
83
  },
84
+ "devDependencies": {
85
+ "esbuild": "^0.25.0"
86
+ },
80
87
  "license": "Apache-2.0",
81
88
  "homepage": "https://github.com/cloudgrid-io/mcp",
82
89
  "repository": {
@@ -60,6 +60,18 @@ export const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
60
60
  export const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
61
61
  export const LIVE_RESULT_HTML = readFileSync(new URL("../widgets/live-result.html", import.meta.url), "utf-8");
62
62
  export const GRID_PICKER_HTML = readFileSync(new URL("../widgets/org-picker.html", import.meta.url), "utf-8");
63
+
64
+ // ── grid_login MCP App (SEP-1865, Claude web) ────────────────────────────────
65
+ // A SECOND, independent UI mechanism from the ChatGPT Apps-SDK widgets above.
66
+ // It is NOT gated behind MCP_APPS_WIDGETS: that flag controls only the
67
+ // openai/outputTemplate key ChatGPT reads, and it must stay exactly as it is.
68
+ // The login card binds via _meta.ui.resourceUri (the SEP-1865 key Claude reads),
69
+ // which ChatGPT ignores — so shipping it does not touch the ChatGPT path. A
70
+ // client without the UI extension ignores _meta.ui and still gets the text-first
71
+ // sign-in URL. The HTML is the self-contained bundle from
72
+ // scripts/build-login-widget.mjs (App class + card inlined; deny-by-default CSP).
73
+ export const GRID_LOGIN_APP_URI = "ui://grid-login/mcp-app.html";
74
+ export const GRID_LOGIN_APP_HTML = readFileSync(new URL("../widgets/grid-login.html", import.meta.url), "utf-8");
63
75
  export const WIDGET_CSP = {
64
76
  connectDomains: ["https://*.cloudgrid.io"],
65
77
  resourceDomains: ["https://*.cloudgrid.io"],
@@ -1557,6 +1557,7 @@ export async function runPlug(ctx, input, deps = {}) {
1557
1557
  target_entity_id,
1558
1558
  grid,
1559
1559
  slug,
1560
+ url: targetUrl,
1560
1561
  hints,
1561
1562
  anon,
1562
1563
  owner_token,
@@ -1676,16 +1677,30 @@ export async function runPlug(ctx, input, deps = {}) {
1676
1677
  );
1677
1678
  }
1678
1679
 
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.
1680
+ // Re-plug handle resolution (the pickup contract's `replug_handle`): when no
1681
+ // explicit target_entity_id was given, resolve one from an alternative target
1682
+ // the caller DID name — a grid+slug pair, or a CloudGrid `url` — so an edge
1683
+ // identified only by its public URL re-plugs the SAME entity in place instead
1684
+ // of minting a new one. A target that does NOT resolve to an existing entity →
1685
+ // targetEntityId stays empty → this is a CREATE (no false-positive re-plug).
1686
+ // target_entity_id remains the primary/documented handle. Best-effort resolve
1687
+ // (pickup contract); never fetches the public URL.
1688
+ //
1689
+ // #296: a `url` is only trusted as a re-plug target when its host is a real
1690
+ // CloudGrid grid apex (gridFromCloudgridUrl != null) — a foreign or malformed
1691
+ // host is NOT sent to the resolver, so an untrusted URL can never redirect a
1692
+ // write to another entity. `pickupData` is reused below to header the write
1693
+ // without a second round-trip.
1685
1694
  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;
1695
+ const urlGrid = gridFromCloudgridUrl(targetUrl);
1696
+ let pickupData = null;
1697
+ if (typeof targetEntityId !== "string" || targetEntityId.length === 0) {
1698
+ if (grid && slug) {
1699
+ pickupData = await resolveEntityViaPickup(ctx, { target: slug, grid });
1700
+ } else if (urlGrid) {
1701
+ pickupData = await resolveEntityViaPickup(ctx, { url: targetUrl, grid: urlGrid });
1702
+ }
1703
+ if (pickupData?.entity_id) targetEntityId = pickupData.entity_id;
1689
1704
  }
1690
1705
 
1691
1706
  const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
@@ -1771,17 +1786,26 @@ export async function runPlug(ctx, input, deps = {}) {
1771
1786
  // the caller's membership from this header and requires it to MATCH the
1772
1787
  // entity's grid, so pass `grid` here too when the target lives outside the
1773
1788
  // active grid.
1774
- orgSlug = grid || (await ctx.getActiveGrid());
1775
1789
  // #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;
1790
+ // exactly one grid, and every way of naming that entity carries the grid.
1791
+ // Resolve it in a deliberate order, cheapest reliable source first, so the
1792
+ // write never depends on session state the API would otherwise reject for
1793
+ // (GRID_HEADER_REQUIRED):
1794
+ // 1. explicit `grid` param — the caller said so.
1795
+ // 2. active grid — session state, no network.
1796
+ // 3. the URL host — FREE: the grid is the subdomain of a
1797
+ // CloudGrid `url` the user pasted; read it before any round-trip. A
1798
+ // foreign/malformed host yields null (gridFromCloudgridUrl) and we fall
1799
+ // through — never a silent redirect.
1800
+ // 4. the target entity itself — a pickup round-trip, only if 1–3 all
1801
+ // came up empty. Reuse a pickup already done above to resolve the id.
1802
+ orgSlug = grid || (await ctx.getActiveGrid());
1803
+ if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
1804
+ if (!orgSlug && isEdit) {
1805
+ if (!pickupData && targetEntityId) {
1806
+ pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
1807
+ }
1808
+ if (pickupData?.grid) orgSlug = pickupData.grid;
1785
1809
  }
1786
1810
  // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1787
1811
  if (orgSlug) {
@@ -2688,6 +2712,42 @@ function isCloudgridHost(hostname) {
2688
2712
  return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
2689
2713
  }
2690
2714
 
2715
+ // Derive the home grid slug from a CloudGrid URL (or a bare host). The grid is
2716
+ // the subdomain: `<grid>.cloudgrid.io/<slug>` (inspiration/path form) and
2717
+ // `<slug>--<grid>.cloudgrid.io` (runtime/flat form — grid is the label after
2718
+ // `--`), matching composePlugUrl's two serving shapes.
2719
+ //
2720
+ // SECURITY (#296): this derives a WRITE DESTINATION from user-supplied input, so
2721
+ // it is deliberately strict — it reuses `isCloudgridHost` (the single host check,
2722
+ // SSRF-shaped) rather than string-matching, and returns null for anything that is
2723
+ // not a trusted grid apex: a foreign or lookalike host, a malformed URL, the bare
2724
+ // apex `cloudgrid.io`, a deeper host (`a.b.cloudgrid.io`), or the anonymous
2725
+ // `guest` apex (never an authed write destination). A null result means "I could
2726
+ // not name the grid" → the caller falls through to today's behaviour and NEVER
2727
+ // silently redirects the write to a grid the user did not mean.
2728
+ export function gridFromCloudgridUrl(input) {
2729
+ const raw = String(input ?? "").trim();
2730
+ if (!raw) return null;
2731
+ let parsed;
2732
+ try {
2733
+ // Accept a bare host (`shoobi.cloudgrid.io/x`) by supplying a scheme; a
2734
+ // string that is already a URL keeps its own scheme.
2735
+ parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
2736
+ } catch {
2737
+ return null;
2738
+ }
2739
+ if (!isCloudgridHost(parsed.hostname)) return null;
2740
+ const host = parsed.hostname.toLowerCase();
2741
+ const labels = host.split(".");
2742
+ // Exactly `<sub>.cloudgrid.io`. The apex (`cloudgrid.io`, 2 labels) and any
2743
+ // deeper host (`a.b.cloudgrid.io`, 4+) name no single grid apex we trust.
2744
+ if (labels.length !== 3) return null;
2745
+ const sub = labels[0];
2746
+ const grid = sub.includes("--") ? sub.split("--").pop() : sub;
2747
+ if (!grid || grid === GUEST_ORG_SLUG) return null;
2748
+ return grid;
2749
+ }
2750
+
2691
2751
  // Shape an HTML string into the grid_get_app_source result (shared by the API-read
2692
2752
  // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
2693
2753
  // optional edition metadata resolved from the pickup contract (kind, single_html,
@@ -2837,10 +2897,10 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2837
2897
  const resolvedUrl = parsed.toString();
2838
2898
  let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2839
2899
 
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);
2900
+ // Derive the grid/slug hint from the URL. gridFromCloudgridUrl is the single
2901
+ // host→grid parser (it also handles the flat `<slug>--<grid>` form the old
2902
+ // inline check missed); the slug is the first path segment.
2903
+ const gridHint = grid ?? gridFromCloudgridUrl(resolvedUrl);
2844
2904
  const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
2845
2905
 
2846
2906
  // ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
@@ -3,6 +3,7 @@
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 {
@@ -12,6 +13,8 @@ import {
12
13
  LIVE_RESULT_HTML,
13
14
  GRID_PICKER_HTML,
14
15
  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";
@@ -144,6 +147,23 @@ export function registerTools(server, ctx) {
144
147
  _meta: { ui: { csp: WIDGET_CSP } },
145
148
  }],
146
149
  }));
150
+
151
+ // grid_login MCP App (SEP-1865). Registered via the official ext-apps
152
+ // helper, which defaults the MIME type to RESOURCE_MIME_TYPE
153
+ // (text/html;profile=mcp-app) — the type Claude web looks for. NO csp
154
+ // domains: the card makes no network requests of its own (it only asks the
155
+ // host to open the sign-in URL and calls grid_login_status back over the
156
+ // bridge), so deny-by-default is exactly right. Independent of
157
+ // APPS_WIDGETS_ENABLED by design (see constants.js).
158
+ registerAppResource(server, "cloudgrid-grid-login", GRID_LOGIN_APP_URI, {
159
+ description: "CloudGrid sign-in card — opens the sign-in URL and confirms when you return.",
160
+ }, async () => ({
161
+ contents: [{
162
+ uri: GRID_LOGIN_APP_URI,
163
+ mimeType: RESOURCE_MIME_TYPE,
164
+ text: GRID_LOGIN_APP_HTML,
165
+ }],
166
+ }));
147
167
  }
148
168
 
149
169
  // ── Direct-API tools (both editions) ──────────────────────────────────────
@@ -380,6 +400,13 @@ export function registerTools(server, ctx) {
380
400
  "raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
381
401
  "entity is treated as a CREATE (never a false-positive re-plug).",
382
402
  ),
403
+ url: z.string().optional().describe(
404
+ "Alternative RE-PLUG handle: the entity's public CloudGrid URL (e.g. https://<grid>.cloudgrid.io/<slug>) " +
405
+ "— use it to edit in place when you have the URL but no target_entity_id (the common \"change the color of " +
406
+ "this page\" flow after grid_get_app_source). The grid is read from the host and the entity is resolved via " +
407
+ "the pickup contract, so no `grid` param is needed. target_entity_id and grid+slug take precedence. A " +
408
+ "non-CloudGrid host is ignored (never redirects the write); a URL that does not resolve is treated as a CREATE.",
409
+ ),
383
410
  hints: z.object({
384
411
  kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
385
412
  yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
@@ -662,6 +689,14 @@ export function registerTools(server, ctx) {
662
689
  login_url: z.string().describe("URL to open in a browser to complete sign-in."),
663
690
  },
664
691
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
692
+ // Bind the SEP-1865 sign-in card on the web edition (Claude web). This is
693
+ // the SEP-1865 key (_meta.ui.resourceUri); ChatGPT reads openai/outputTemplate
694
+ // and ignores it, and a client without the UI extension ignores it too and
695
+ // gets the text-first result below. NOT gated behind MCP_APPS_WIDGETS — a
696
+ // second, independent mechanism (see constants.js). The result shape is
697
+ // unchanged: content[0] stays the sign-in URL text so the link is never
698
+ // unreachable if the card does not render.
699
+ ...(ctx.edition === "web" ? { _meta: { ui: { resourceUri: GRID_LOGIN_APP_URI } } } : {}),
665
700
  },
666
701
  async () => {
667
702
  try {
@@ -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>
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @file grid_login MCP App — the sign-in card Claude web renders for grid_login.
3
+ *
4
+ * This module is the UI half of the MCP Apps (SEP-1865) mechanism. It talks to
5
+ * the host over postMessage JSON-RPC via the official `App` class — NOT
6
+ * `window.openai`, which does not exist in Claude's host (that global is the
7
+ * ChatGPT Apps-SDK bridge the old org-picker widget targets).
8
+ *
9
+ * It is bundled into a single self-contained src/widgets/grid-login.html by
10
+ * scripts/build-login-widget.mjs (esbuild inlines this file and the whole
11
+ * @modelcontextprotocol/ext-apps App class). The iframe CSP is deny-by-default,
12
+ * so nothing here may reference a remote script, stylesheet, or font.
13
+ *
14
+ * Security invariant (issue #302): the card NEVER receives, displays, or stores
15
+ * a token. Its only job is to open the sign-in URL in the browser; the browser
16
+ * performs the auth. The most we surface after sign-in is the account email,
17
+ * which grid_login_status returns and which is not a credential.
18
+ */
19
+ import {
20
+ App,
21
+ applyDocumentTheme,
22
+ applyHostFonts,
23
+ applyHostStyleVariables,
24
+ } from "@modelcontextprotocol/ext-apps";
25
+
26
+ const els = {
27
+ root: document.getElementById("card"),
28
+ status: document.getElementById("status"),
29
+ signin: document.getElementById("signin"),
30
+ check: document.getElementById("check"),
31
+ fallback: document.getElementById("fallback"),
32
+ fallbackUrl: document.getElementById("fallback-url"),
33
+ };
34
+
35
+ // The sign-in URL, lifted from the tool result. Never a token — just the URL
36
+ // the browser opens. Held only to wire the button.
37
+ let loginUrl = null;
38
+
39
+ // Pull the sign-in URL out of a grid_login tool result. structuredContent is
40
+ // the contract (grid_login sets { login_url }); the text content is the
41
+ // text-first fallback that ALSO carries the URL, so we read it if structured is
42
+ // missing. This mirrors the server: the URL is never hidden behind the card.
43
+ function readLoginUrl(result) {
44
+ const fromStructured = result?.structuredContent?.login_url;
45
+ if (typeof fromStructured === "string" && fromStructured) return fromStructured;
46
+ const text = Array.isArray(result?.content)
47
+ ? result.content.filter((c) => c?.type === "text").map((c) => c.text).join("\n")
48
+ : "";
49
+ const match = text.match(/https?:\/\/\S+/);
50
+ return match ? match[0] : null;
51
+ }
52
+
53
+ function showSignInReady() {
54
+ if (loginUrl) {
55
+ els.fallbackUrl.textContent = loginUrl;
56
+ els.fallbackUrl.setAttribute("href", loginUrl);
57
+ els.fallback.hidden = false;
58
+ }
59
+ els.signin.disabled = !loginUrl;
60
+ els.status.textContent = loginUrl
61
+ ? "Sign in to publish to your grid."
62
+ : "Preparing the sign-in link.";
63
+ }
64
+
65
+ function showAwaitingReturn() {
66
+ els.status.textContent =
67
+ "Finish signing in on the page that opened, then come back and select Check sign-in.";
68
+ els.check.hidden = false;
69
+ }
70
+
71
+ const app = new App({ name: "CloudGrid sign-in", version: "1.0.0" });
72
+
73
+ app.onerror = (err) => console.error("[grid-login card]", err);
74
+
75
+ app.onhostcontextchanged = (ctx) => {
76
+ if (ctx?.theme) applyDocumentTheme(ctx.theme);
77
+ if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
78
+ if (ctx?.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
79
+ };
80
+
81
+ // The grid_login result arrives here after connect. It carries the URL, never
82
+ // a token.
83
+ app.ontoolresult = (result) => {
84
+ loginUrl = readLoginUrl(result);
85
+ showSignInReady();
86
+ };
87
+
88
+ els.signin.addEventListener("click", async () => {
89
+ if (!loginUrl) return;
90
+ try {
91
+ await app.openLink({ url: loginUrl });
92
+ showAwaitingReturn();
93
+ } catch (err) {
94
+ console.error("[grid-login card] openLink failed", err);
95
+ // The link is still on-screen (fallback block); the user can open it
96
+ // manually. Never dead-end.
97
+ showAwaitingReturn();
98
+ }
99
+ });
100
+
101
+ // Check sign-in: ask the server to finish the flow (grid_login_status). It
102
+ // returns { status, email } and NEVER a token, so the card stays token-free.
103
+ els.check.addEventListener("click", async () => {
104
+ els.check.disabled = true;
105
+ els.status.textContent = "Checking sign-in.";
106
+ try {
107
+ const result = await app.callServerTool({ name: "grid_login_status", arguments: {} });
108
+ const status = result?.structuredContent?.status;
109
+ const email = result?.structuredContent?.email;
110
+ if (status === "authenticated") {
111
+ els.status.textContent = email ? `Signed in as ${email}.` : "Signed in.";
112
+ els.signin.hidden = true;
113
+ els.check.hidden = true;
114
+ } else {
115
+ els.status.textContent =
116
+ "Still waiting for sign-in. Finish on the page that opened, then check again.";
117
+ els.check.disabled = false;
118
+ }
119
+ } catch (err) {
120
+ console.error("[grid-login card] status check failed", err);
121
+ els.status.textContent =
122
+ "Could not check sign-in from here. Complete it in the browser and I will continue.";
123
+ els.check.disabled = false;
124
+ }
125
+ });
126
+
127
+ showSignInReady();
128
+ app.connect().then(() => {
129
+ const ctx = app.getHostContext();
130
+ if (ctx) app.onhostcontextchanged(ctx);
131
+ });