@cloudgrid-io/mcp 0.21.7 → 0.21.9

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.7",
3
+ "version": "0.21.9",
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",
@@ -71,6 +72,7 @@
71
72
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
72
73
  "test:internal-refs": "node --test test/no-internal-refs.test.mjs",
73
74
  "test:login-app": "node test/login-app.test.mjs",
75
+ "test:login-poller": "node test/login-poller.test.mjs",
74
76
  "build:login-widget": "node scripts/build-login-widget.mjs"
75
77
  },
76
78
  "dependencies": {
@@ -53,7 +53,26 @@ 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.
@@ -72,10 +91,6 @@ export const GRID_PICKER_HTML = readFileSync(new URL("../widgets/org-picker.html
72
91
  // scripts/build-login-widget.mjs (App class + card inlined; deny-by-default CSP).
73
92
  export const GRID_LOGIN_APP_URI = "ui://grid-login/mcp-app.html";
74
93
  export const GRID_LOGIN_APP_HTML = readFileSync(new URL("../widgets/grid-login.html", import.meta.url), "utf-8");
75
- export const WIDGET_CSP = {
76
- connectDomains: ["https://*.cloudgrid.io"],
77
- resourceDomains: ["https://*.cloudgrid.io"],
78
- };
79
94
 
80
95
  // The lazy npx fallback always fetches the LATEST published CLI, so the MCP is
81
96
  // never left behind the platform's required CLI version (a pinned range went
@@ -1480,6 +1480,45 @@ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
1480
1480
  return guidance ? `${base} — ${guidance}` : base;
1481
1481
  }
1482
1482
 
1483
+ // ── Corrupt-archive FETCHSOURCE handoff (defense in depth) ───────────────────
1484
+ // Historically the hosted (web) server materialized uploaded multipart parts
1485
+ // into a source tarball SERVER-SIDE and, for a multi-file runtime app re-plugged
1486
+ // inline, could emit a non-gzip stream that died at the build container's
1487
+ // FETCHSOURCE with "gzip: stdin: not in gzip format / tar: Child returned status
1488
+ // 1" — raw, unactionable output. The server-side defect (cloudgrid#2977) is now
1489
+ // fixed and verified live, and the pre-upload gate that refused that shape has
1490
+ // been lifted (#315). This handoff stays as defense in depth: if a corrupt-archive
1491
+ // build failure ever recurs, rewrite the raw tar/gzip log to an actionable CLI
1492
+ // handoff (via archiveCorruptionSignature) instead of surfacing it verbatim.
1493
+
1494
+ // The "continue this exact entity from a terminal" CLI commands, with the real
1495
+ // handle substituted. Prefers grid/slug (what `pull` takes, human-readable);
1496
+ // falls back to the entity id. Same entity, same URL.
1497
+ export function cliContinueHandoff({ entityId, grid, slug } = {}) {
1498
+ const target = grid && slug ? `${grid}/${slug}` : slug || entityId || "<grid>/<app>";
1499
+ const dir = slug || entityId || "<app>";
1500
+ return (
1501
+ "Continue from a terminal with the CLI — it uploads the files from disk and updates the SAME entity (same URL):\n" +
1502
+ " npx -y @cloudgrid-io/cli@latest login\n" +
1503
+ ` npx -y @cloudgrid-io/cli@latest pull ${target}\n` +
1504
+ ` cd ${dir}\n` +
1505
+ " npx -y @cloudgrid-io/cli@latest plug"
1506
+ );
1507
+ }
1508
+
1509
+ // True when a build failure is the corrupt-archive signature (a FETCHSOURCE
1510
+ // gunzip/tar failure), as opposed to a genuine app build error the developer can
1511
+ // act on (a missing dependency, a syntax error) — those keep their real log tail.
1512
+ export function archiveCorruptionSignature(text) {
1513
+ const hay = String(text || "").toLowerCase();
1514
+ return (
1515
+ /not in gzip format/.test(hay) ||
1516
+ /gzip:\s*stdin/.test(hay) ||
1517
+ /tar:\s*child returned status/.test(hay) ||
1518
+ /invalid gzip/.test(hay)
1519
+ );
1520
+ }
1521
+
1483
1522
  // Parse the live URL the CLI prints on a successful `plug`. The CLI prints the
1484
1523
  // canonical https://…cloudgrid.io URL somewhere in stdout (labelled "Outlet"
1485
1524
  // / "Live" / bare); take the last cloudgrid.io URL it emits — the final line is
@@ -2048,6 +2087,20 @@ export async function runPlug(ctx, input, deps = {}) {
2048
2087
  data.status = "live";
2049
2088
  data.poll_url = undefined;
2050
2089
  } else if (verdict.status === "failed") {
2090
+ // Defense in depth: a corrupt-archive FETCHSOURCE failure is unactionable
2091
+ // as raw tar output — never surface it verbatim. The server-side defect
2092
+ // that produced it (cloudgrid#2977) is fixed and the pre-upload gate has
2093
+ // been lifted (#315), so no corrupt-archive verdict is expected here; if the
2094
+ // class ever recurs, name the CLI path with the real handle instead of the
2095
+ // raw log. Genuine app build errors keep their real log tail — those the
2096
+ // developer CAN act on.
2097
+ if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
2098
+ throw new Error(
2099
+ "The uploaded source could not be read by the build (its archive was corrupt) — the URL is NOT " +
2100
+ "live. " +
2101
+ cliContinueHandoff({ entityId: data.entity_id, grid: data.grid, slug: data.slug }),
2102
+ );
2103
+ }
2051
2104
  const msg = verdict.error || "The build failed.";
2052
2105
  throw new Error(
2053
2106
  `Deploy failed (trace ${data.trace_id ?? "n/a"}): ${msg} The URL is NOT live — do not give it to the user as working.` +
@@ -2675,6 +2728,24 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
2675
2728
  // the source zip (grid_get_app_source → source_download_url) or the
2676
2729
  // pull command that downloads + links the folder so the SAME entity
2677
2730
  // continues locally.
2731
+ // Defense in depth: a corrupt-archive FETCHSOURCE failure is unactionable raw
2732
+ // tar output — do NOT echo it. The server-side defect that produced it
2733
+ // (cloudgrid#2977) is fixed and the pre-upload gate has been lifted (#315);
2734
+ // if the class ever recurs, name the CLI continue-path with the real handle
2735
+ // instead of the raw log.
2736
+ if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
2737
+ return {
2738
+ text: "The uploaded source could not be read by the build (its archive was corrupt) — the URL is " +
2739
+ "not live. The project files are NOT lost — they are saved on the entity. " +
2740
+ cliContinueHandoff({ entityId, grid: gridSlug, slug: ctx.state.lastDrop?.slug }),
2741
+ structured: {
2742
+ status: "failed",
2743
+ live: false,
2744
+ reason: "archive_corrupt",
2745
+ ...(ctx.edition === "web" && entityId ? { source_recovery: { entity_id: entityId, via: ["grid_get_app_source", "grid pull"] } } : {}),
2746
+ },
2747
+ };
2748
+ }
2678
2749
  const handoff = ctx.edition === "web" && entityId
2679
2750
  ? `\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.`
2680
2751
  : "";
@@ -8,11 +8,11 @@ import { newLoginCode, buildLoginUrl, pollStatusOnce, checkApiConnectivity, deco
8
8
  import { PLAYBOOK, fetchCorpus, listWorkflows } from "../playbook.js";
9
9
  import {
10
10
  APPS_WIDGETS_ENABLED,
11
+ CHATGPT_WIDGET_MIME,
11
12
  LIVE_RESULT_URI,
12
13
  GRID_PICKER_URI,
13
14
  LIVE_RESULT_HTML,
14
15
  GRID_PICKER_HTML,
15
- WIDGET_CSP,
16
16
  GRID_LOGIN_APP_URI,
17
17
  GRID_LOGIN_APP_HTML,
18
18
  } from "./constants.js";
@@ -124,27 +124,33 @@ export function registerTools(server, ctx) {
124
124
 
125
125
  // ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
126
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.
127
133
  server.registerResource("cloudgrid-live-result", LIVE_RESULT_URI, {
128
134
  description: "Live result card after a CloudGrid drop — shows URL, grid link, and visibility controls.",
129
- mimeType: "text/html;profile=mcp-app",
135
+ mimeType: CHATGPT_WIDGET_MIME,
130
136
  }, async () => ({
131
137
  contents: [{
132
138
  uri: LIVE_RESULT_URI,
133
- mimeType: "text/html;profile=mcp-app",
139
+ mimeType: CHATGPT_WIDGET_MIME,
134
140
  text: LIVE_RESULT_HTML,
135
- _meta: { ui: { csp: WIDGET_CSP } },
141
+ _meta: { "openai/widgetAccessible": true },
136
142
  }],
137
143
  }));
138
144
 
139
145
  server.registerResource("cloudgrid-org-picker", GRID_PICKER_URI, {
140
146
  description: "Grid picker card — lets the user choose which grid to plug into.",
141
- mimeType: "text/html;profile=mcp-app",
147
+ mimeType: CHATGPT_WIDGET_MIME,
142
148
  }, async () => ({
143
149
  contents: [{
144
150
  uri: GRID_PICKER_URI,
145
- mimeType: "text/html;profile=mcp-app",
151
+ mimeType: CHATGPT_WIDGET_MIME,
146
152
  text: GRID_PICKER_HTML,
147
- _meta: { ui: { csp: WIDGET_CSP } },
153
+ _meta: { "openai/widgetAccessible": true },
148
154
  }],
149
155
  }));
150
156
 
@@ -512,11 +518,13 @@ export function registerTools(server, ctx) {
512
518
  // live at a public URL in place. Versions + grid rollback exist, but the
513
519
  // honest MCP annotation for overwrite-live-state is destructive.
514
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).
515
526
  ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
516
- _meta: {
517
- ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
518
- "openai/outputTemplate": LIVE_RESULT_URI,
519
- },
527
+ _meta: { "openai/outputTemplate": LIVE_RESULT_URI },
520
528
  } : {}),
521
529
  };
522
530
  const plugHandler = async (input) => {
@@ -799,6 +807,11 @@ export function registerTools(server, ctx) {
799
807
  url: z.string().optional().describe("URL of the entity, if returned."),
800
808
  },
801
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 } } : {}),
802
815
  },
803
816
  async (input) => {
804
817
  try {
@@ -930,7 +943,7 @@ export function registerTools(server, ctx) {
930
943
  .object({
931
944
  active_grid: z.string().nullable().describe("The user's active grid/org slug, or null."),
932
945
  signed_in: z.boolean().describe("Whether the current session is signed in."),
933
- email: z.string().optional().describe("The signed-in user's email, when available."),
946
+ email: z.string().optional().describe("The signed-in user's email — the CloudGrid account this session is authenticated as. Present whenever signed_in is true and the session token carries an email claim (the normal case). Absent only if signed_in is false, or on the rare token that lacks the claim; the identity line in this tool's text content states which case applies. Use this to answer 'which account/user am I?'."),
934
947
  session_expired: z.boolean().optional().describe("True when credentials exist but the JWT has expired. Run grid_login to sign in again."),
935
948
  identity_changed: z.boolean().optional().describe("True when the session's identity changed via a different transport Bearer. Session state was reset."),
936
949
  update_available: z
@@ -994,8 +1007,22 @@ export function registerTools(server, ctx) {
994
1007
  const wfLines = workflows.length
995
1008
  ? workflows.map((w) => ` - ${w.name}: ${w.when || w.summary}`).join("\n")
996
1009
  : " (none available)";
1010
+ // Identity banner — first line of the text channel, because the account
1011
+ // this session is signed in as is only otherwise carried in
1012
+ // structuredContent.context, which many clients/models never surface. A
1013
+ // hosted assistant asked "which account am I?" was concluding no tool
1014
+ // exposes it while the answer sat in structured output it had already
1015
+ // received (#317). Stating it in the text the model actually reads makes
1016
+ // "who am I" answerable without the CLI (whose separate session can point
1017
+ // at a different account entirely).
1018
+ const gridSuffix = activeGrid ? ` (active grid: ${activeGrid})` : "";
1019
+ const identityLine = signedIn
1020
+ ? signedInEmail
1021
+ ? `You are signed in to CloudGrid as ${signedInEmail}${gridSuffix}. If the user asks which CloudGrid account, user, or email they are connected to, that is the answer.`
1022
+ : `You are signed in to CloudGrid${gridSuffix}, but this session's token carries no email claim, so the account address is not available here. Do not guess it from grid memberships or the CLI (its session may be a different account).`
1023
+ : `You are not signed in to CloudGrid. Run grid_login to sign in.`;
997
1024
  let text =
998
- `${PLAYBOOK}\n\nAvailable workflows:\n${wfLines}\n\n` +
1025
+ `${identityLine}\n\n${PLAYBOOK}\n\nAvailable workflows:\n${wfLines}\n\n` +
999
1026
  `Next: match the intent to a workflow and call grid_get_template({kind:"workflow", name}).` +
1000
1027
  (stale ? `\n\n${stale}` : "");
1001
1028
  if (sessionExpired) {
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,
@@ -22,6 +22,7 @@ import {
22
22
  applyHostFonts,
23
23
  applyHostStyleVariables,
24
24
  } from "@modelcontextprotocol/ext-apps";
25
+ import { createPoller } from "./poller.js";
25
26
 
26
27
  const els = {
27
28
  root: document.getElementById("card"),
@@ -64,10 +65,108 @@ function showSignInReady() {
64
65
 
65
66
  function showAwaitingReturn() {
66
67
  els.status.textContent =
67
- "Finish signing in on the page that opened, then come back and select Check sign-in.";
68
+ "Finish signing in on the page that opened — I'll notice automatically, or select Check sign-in.";
68
69
  els.check.hidden = false;
69
70
  }
70
71
 
72
+ // ── Bounded polling ──────────────────────────────────────────────────────────
73
+ // Once "Open sign-in" is clicked, the card polls grid_login_status until it
74
+ // resolves. The loop and all its bounds live in poller.js (interval, wall-clock
75
+ // ceiling, consecutive-error cap, hidden-tab skip) so they can be unit-tested
76
+ // deterministically. Values chosen: a 3s interval keeps it to one round-trip
77
+ // every few seconds; a 150s ceiling covers a normal sign-in with margin and
78
+ // caps a backgrounded card at ≤50 requests total; 3 consecutive errors stops a
79
+ // broken/blocked endpoint fast. The manual "Check sign-in" button stays the
80
+ // escape hatch throughout.
81
+ const POLL_INTERVAL_MS = 3000;
82
+ const POLL_CEILING_MS = 150000;
83
+ const MAX_CONSECUTIVE_ERRORS = 3;
84
+
85
+ let settled = false; // true once we've reached signed-in (never act again)
86
+
87
+ const poller = createPoller({
88
+ intervalMs: POLL_INTERVAL_MS,
89
+ ceilingMs: POLL_CEILING_MS,
90
+ maxConsecutiveErrors: MAX_CONSECUTIVE_ERRORS,
91
+ check: () => queryStatus(),
92
+ onSuccess: (email) => onSignedIn(email),
93
+ onCeiling: () => {
94
+ els.status.textContent =
95
+ "Still waiting for sign-in. Finish on the page that opened, then select Check sign-in.";
96
+ },
97
+ onGiveUp: () => {
98
+ els.status.textContent =
99
+ "Could not check sign-in automatically. Complete it in the browser, then select Check sign-in.";
100
+ },
101
+ });
102
+
103
+ // One grid_login_status round-trip. Returns a plain outcome; NEVER a token —
104
+ // grid_login_status returns { status, email } and email is not a credential.
105
+ async function queryStatus() {
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
+ return { authenticated: status === "authenticated", email, errored: false };
111
+ } catch (err) {
112
+ console.error("[grid-login card] status check failed", err);
113
+ return { authenticated: false, email: null, errored: true };
114
+ }
115
+ }
116
+
117
+ // Reached signed-in state. Update the card (email only, never a token) and tell
118
+ // the model so the conversation continues WITHOUT the user typing anything.
119
+ async function onSignedIn(email) {
120
+ settled = true;
121
+ poller.stop();
122
+ els.status.textContent = email ? `Signed in as ${email}.` : "Signed in.";
123
+ els.signin.hidden = true;
124
+ els.check.hidden = true;
125
+ // Once signed in there is nothing left to open — drop the manual sign-in link
126
+ // so the card reads as a clean terminal state, not a still-actionable prompt.
127
+ els.fallback.hidden = true;
128
+ await tellModel(email);
129
+ }
130
+
131
+ // Push the outcome to the host so the model continues on its own. Two calls,
132
+ // because they do different things (verified against the installed
133
+ // @modelcontextprotocol/ext-apps App class, not from memory):
134
+ // • updateModelContext — pushes structured signed-in state into the model's
135
+ // context, but by design does NOT trigger a turn (it waits for the next user
136
+ // message). On its own it would NOT fix the complaint.
137
+ // • sendMessage — adds a message to the conversation, which does trigger the
138
+ // model. This is the half that lets the conversation move on with no "done".
139
+ // Both are wrapped so a host that lacks either still leaves the card signed-in
140
+ // and the manual path intact.
141
+ async function tellModel(email) {
142
+ const who = email ? ` as ${email}` : "";
143
+ try {
144
+ await app.updateModelContext({
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text: `The user has finished signing in to CloudGrid${who}. Sign-in is complete — continue with what they were doing; no need to ask them to confirm.`,
149
+ },
150
+ ],
151
+ structuredContent: email
152
+ ? { status: "authenticated", email }
153
+ : { status: "authenticated" },
154
+ });
155
+ } catch (err) {
156
+ console.error("[grid-login card] updateModelContext failed", err);
157
+ }
158
+ try {
159
+ await app.sendMessage({
160
+ role: "user",
161
+ content: [
162
+ { type: "text", text: `I've signed in to CloudGrid${who}. Please continue.` },
163
+ ],
164
+ });
165
+ } catch (err) {
166
+ console.error("[grid-login card] sendMessage failed", err);
167
+ }
168
+ }
169
+
71
170
  const app = new App({ name: "CloudGrid sign-in", version: "1.0.0" });
72
171
 
73
172
  app.onerror = (err) => console.error("[grid-login card]", err);
@@ -96,31 +195,32 @@ els.signin.addEventListener("click", async () => {
96
195
  // manually. Never dead-end.
97
196
  showAwaitingReturn();
98
197
  }
198
+ // Only NOW — after Open sign-in — do we begin polling. An untouched card is
199
+ // silent.
200
+ poller.start();
99
201
  });
100
202
 
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.
203
+ // Check sign-in: the manual escape hatch. It shares queryStatus/onSignedIn with
204
+ // the poller, so it stays available even if polling is throttled, the tab was
205
+ // backgrounded past the ceiling, or the model-context push is unavailable.
103
206
  els.check.addEventListener("click", async () => {
207
+ if (settled) return;
104
208
  els.check.disabled = true;
105
209
  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);
210
+ const outcome = await queryStatus();
211
+ if (settled) return;
212
+ if (outcome.authenticated) {
213
+ await onSignedIn(outcome.email);
214
+ } else if (outcome.errored) {
215
+ els.status.textContent =
216
+ "Could not check sign-in from here. Complete it in the browser and select Check sign-in.";
217
+ els.check.disabled = false;
218
+ } else {
121
219
  els.status.textContent =
122
- "Could not check sign-in from here. Complete it in the browser and I will continue.";
220
+ "Still waiting for sign-in. Finish on the page that opened, then check again.";
123
221
  els.check.disabled = false;
222
+ // Keep automatic polling going if it stopped (e.g. after the ceiling).
223
+ poller.start();
124
224
  }
125
225
  });
126
226
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @file Bounded sign-in poller for the grid_login MCP App (issue #306).
3
+ *
4
+ * After the user clicks "Open sign-in", the browser (not the card) performs the
5
+ * auth, and the card has no way to be told when it finishes — so it polls
6
+ * grid_login_status. Every poll is a real round-trip to our API, so this loop is
7
+ * bounded three independent ways and NEVER runs before it is started:
8
+ *
9
+ * • interval — at most one check every `intervalMs` (never a tight loop)
10
+ * • ceiling — gives up after `ceilingMs` of wall-clock, whatever happens
11
+ * • errors — stops after `maxConsecutiveErrors` failed round-trips in a row
12
+ *
13
+ * While the tab is hidden it skips the round-trip (the wall-clock ceiling still
14
+ * applies, so it always terminates). A card left open in a background tab can
15
+ * therefore issue at most ceiling/interval requests and then stops for good.
16
+ *
17
+ * Every timing/environment dependency is injected so the loop is testable with
18
+ * fake timers and a fake clock, with no DOM and no ext-apps import. mcp-app.js
19
+ * wires the real `setTimeout`, `performance.now`, `document.hidden`, and the
20
+ * grid_login_status round-trip into it. This whole module is bundled inline into
21
+ * src/widgets/grid-login.html by scripts/build-login-widget.mjs.
22
+ */
23
+
24
+ /**
25
+ * @param {object} deps
26
+ * @param {number} deps.intervalMs delay between polls
27
+ * @param {number} deps.ceilingMs wall-clock budget before giving up
28
+ * @param {number} deps.maxConsecutiveErrors errors in a row before giving up
29
+ * @param {() => Promise<{authenticated: boolean, email?: string|null, errored: boolean}>} deps.check
30
+ * one grid_login_status round-trip, reduced to a plain outcome
31
+ * @param {(email: string|null|undefined) => (void|Promise<void>)} deps.onSuccess
32
+ * called once when authenticated; the poller stops permanently first
33
+ * @param {() => void} deps.onCeiling called when the wall-clock ceiling is hit
34
+ * @param {() => void} deps.onGiveUp called when consecutive errors hit the cap
35
+ * @param {() => number} [deps.now] monotonic clock (defaults to performance.now)
36
+ * @param {(fn: Function, ms: number) => any} [deps.schedule] defaults to setTimeout
37
+ * @param {(handle: any) => void} [deps.cancel] defaults to clearTimeout
38
+ * @param {() => boolean} [deps.isHidden] whether to skip a round-trip this tick
39
+ */
40
+ export function createPoller({
41
+ intervalMs,
42
+ ceilingMs,
43
+ maxConsecutiveErrors,
44
+ check,
45
+ onSuccess,
46
+ onCeiling,
47
+ onGiveUp,
48
+ now = () => performance.now(),
49
+ schedule = (fn, ms) => setTimeout(fn, ms),
50
+ cancel = (handle) => clearTimeout(handle),
51
+ isHidden = () => typeof document !== "undefined" && document.hidden,
52
+ }) {
53
+ let timer = null;
54
+ let deadline = 0;
55
+ let consecutiveErrors = 0;
56
+ let stopped = false; // permanent once we succeed; blocks any further work
57
+
58
+ function armNext() {
59
+ timer = schedule(tick, intervalMs);
60
+ }
61
+
62
+ function start() {
63
+ // Idempotent: a manual re-check that resumes polling won't stack loops, and
64
+ // it cannot restart once we've succeeded.
65
+ if (stopped || timer !== null) return;
66
+ deadline = now() + ceilingMs;
67
+ consecutiveErrors = 0;
68
+ armNext();
69
+ }
70
+
71
+ function stop() {
72
+ stopped = true;
73
+ if (timer !== null) {
74
+ cancel(timer);
75
+ timer = null;
76
+ }
77
+ }
78
+
79
+ async function tick() {
80
+ timer = null;
81
+ if (stopped) return;
82
+ if (now() >= deadline) {
83
+ onCeiling(); // ceiling: stop; caller keeps the manual fallback on screen
84
+ return;
85
+ }
86
+ if (isHidden()) {
87
+ armNext(); // don't spend a round-trip while hidden; ceiling still applies
88
+ return;
89
+ }
90
+ const outcome = await check();
91
+ if (stopped) return;
92
+ if (outcome.authenticated) {
93
+ stopped = true;
94
+ await onSuccess(outcome.email);
95
+ return;
96
+ }
97
+ if (outcome.errored) {
98
+ consecutiveErrors += 1;
99
+ if (consecutiveErrors >= maxConsecutiveErrors) {
100
+ onGiveUp(); // terminal failure: stop, keep the manual path
101
+ return;
102
+ }
103
+ } else {
104
+ consecutiveErrors = 0;
105
+ }
106
+ armNext();
107
+ }
108
+
109
+ return { start, stop, isStopped: () => stopped };
110
+ }