@cloudgrid-io/mcp 0.21.7 → 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 +3 -1
- package/src/tools/constants.js +19 -4
- package/src/tools/deploy.js +113 -0
- package/src/tools/register.js +24 -11
- package/src/tools.js +2 -0
- package/src/widgets/grid-login/mcp-app.js +119 -19
- package/src/widgets/grid-login/poller.js +110 -0
- package/src/widgets/grid-login.html +11 -11
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",
|
|
@@ -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": {
|
package/src/tools/constants.js
CHANGED
|
@@ -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
|
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
|
|
@@ -1705,6 +1750,40 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1705
1750
|
|
|
1706
1751
|
const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
|
|
1707
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
|
+
|
|
1708
1787
|
// An inspiration edit content-versions the FIRST uploaded artifact — when a
|
|
1709
1788
|
// multi-file folder rides a re-plug, put the primary entry first so the edit
|
|
1710
1789
|
// swaps the right file (index.html > any .html > everything else).
|
|
@@ -2048,6 +2127,21 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2048
2127
|
data.status = "live";
|
|
2049
2128
|
data.poll_url = undefined;
|
|
2050
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
|
+
}
|
|
2051
2145
|
const msg = verdict.error || "The build failed.";
|
|
2052
2146
|
throw new Error(
|
|
2053
2147
|
`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 +2769,25 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2675
2769
|
// the source zip (grid_get_app_source → source_download_url) or the
|
|
2676
2770
|
// pull command that downloads + links the folder so the SAME entity
|
|
2677
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
|
+
}
|
|
2678
2791
|
const handoff = ctx.edition === "web" && entityId
|
|
2679
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.`
|
|
2680
2793
|
: "";
|
package/src/tools/register.js
CHANGED
|
@@ -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:
|
|
135
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
130
136
|
}, async () => ({
|
|
131
137
|
contents: [{
|
|
132
138
|
uri: LIVE_RESULT_URI,
|
|
133
|
-
mimeType:
|
|
139
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
134
140
|
text: LIVE_RESULT_HTML,
|
|
135
|
-
_meta: {
|
|
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:
|
|
147
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
142
148
|
}, async () => ({
|
|
143
149
|
contents: [{
|
|
144
150
|
uri: GRID_PICKER_URI,
|
|
145
|
-
mimeType:
|
|
151
|
+
mimeType: CHATGPT_WIDGET_MIME,
|
|
146
152
|
text: GRID_PICKER_HTML,
|
|
147
|
-
_meta: {
|
|
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 {
|
package/src/tools.js
CHANGED
|
@@ -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
|
|
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:
|
|
102
|
-
//
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
"
|
|
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
|
+
}
|