@cloudgrid-io/mcp 0.21.6 → 0.21.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -2
- package/src/tools/constants.js +31 -4
- package/src/tools/deploy.js +196 -23
- package/src/tools/register.js +59 -11
- package/src/tools.js +2 -0
- package/src/widgets/grid-login/mcp-app.css +57 -0
- package/src/widgets/grid-login/mcp-app.html +26 -0
- package/src/widgets/grid-login/mcp-app.js +231 -0
- package/src/widgets/grid-login/poller.js +110 -0
- package/src/widgets/grid-login.html +150 -0
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
import { createPoller } from "./poller.js";
|
|
26
|
+
|
|
27
|
+
const els = {
|
|
28
|
+
root: document.getElementById("card"),
|
|
29
|
+
status: document.getElementById("status"),
|
|
30
|
+
signin: document.getElementById("signin"),
|
|
31
|
+
check: document.getElementById("check"),
|
|
32
|
+
fallback: document.getElementById("fallback"),
|
|
33
|
+
fallbackUrl: document.getElementById("fallback-url"),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The sign-in URL, lifted from the tool result. Never a token — just the URL
|
|
37
|
+
// the browser opens. Held only to wire the button.
|
|
38
|
+
let loginUrl = null;
|
|
39
|
+
|
|
40
|
+
// Pull the sign-in URL out of a grid_login tool result. structuredContent is
|
|
41
|
+
// the contract (grid_login sets { login_url }); the text content is the
|
|
42
|
+
// text-first fallback that ALSO carries the URL, so we read it if structured is
|
|
43
|
+
// missing. This mirrors the server: the URL is never hidden behind the card.
|
|
44
|
+
function readLoginUrl(result) {
|
|
45
|
+
const fromStructured = result?.structuredContent?.login_url;
|
|
46
|
+
if (typeof fromStructured === "string" && fromStructured) return fromStructured;
|
|
47
|
+
const text = Array.isArray(result?.content)
|
|
48
|
+
? result.content.filter((c) => c?.type === "text").map((c) => c.text).join("\n")
|
|
49
|
+
: "";
|
|
50
|
+
const match = text.match(/https?:\/\/\S+/);
|
|
51
|
+
return match ? match[0] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function showSignInReady() {
|
|
55
|
+
if (loginUrl) {
|
|
56
|
+
els.fallbackUrl.textContent = loginUrl;
|
|
57
|
+
els.fallbackUrl.setAttribute("href", loginUrl);
|
|
58
|
+
els.fallback.hidden = false;
|
|
59
|
+
}
|
|
60
|
+
els.signin.disabled = !loginUrl;
|
|
61
|
+
els.status.textContent = loginUrl
|
|
62
|
+
? "Sign in to publish to your grid."
|
|
63
|
+
: "Preparing the sign-in link.";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function showAwaitingReturn() {
|
|
67
|
+
els.status.textContent =
|
|
68
|
+
"Finish signing in on the page that opened — I'll notice automatically, or select Check sign-in.";
|
|
69
|
+
els.check.hidden = false;
|
|
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
|
+
|
|
170
|
+
const app = new App({ name: "CloudGrid sign-in", version: "1.0.0" });
|
|
171
|
+
|
|
172
|
+
app.onerror = (err) => console.error("[grid-login card]", err);
|
|
173
|
+
|
|
174
|
+
app.onhostcontextchanged = (ctx) => {
|
|
175
|
+
if (ctx?.theme) applyDocumentTheme(ctx.theme);
|
|
176
|
+
if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
|
|
177
|
+
if (ctx?.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// The grid_login result arrives here after connect. It carries the URL, never
|
|
181
|
+
// a token.
|
|
182
|
+
app.ontoolresult = (result) => {
|
|
183
|
+
loginUrl = readLoginUrl(result);
|
|
184
|
+
showSignInReady();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
els.signin.addEventListener("click", async () => {
|
|
188
|
+
if (!loginUrl) return;
|
|
189
|
+
try {
|
|
190
|
+
await app.openLink({ url: loginUrl });
|
|
191
|
+
showAwaitingReturn();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error("[grid-login card] openLink failed", err);
|
|
194
|
+
// The link is still on-screen (fallback block); the user can open it
|
|
195
|
+
// manually. Never dead-end.
|
|
196
|
+
showAwaitingReturn();
|
|
197
|
+
}
|
|
198
|
+
// Only NOW — after Open sign-in — do we begin polling. An untouched card is
|
|
199
|
+
// silent.
|
|
200
|
+
poller.start();
|
|
201
|
+
});
|
|
202
|
+
|
|
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.
|
|
206
|
+
els.check.addEventListener("click", async () => {
|
|
207
|
+
if (settled) return;
|
|
208
|
+
els.check.disabled = true;
|
|
209
|
+
els.status.textContent = "Checking sign-in.";
|
|
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 {
|
|
219
|
+
els.status.textContent =
|
|
220
|
+
"Still waiting for sign-in. Finish on the page that opened, then check again.";
|
|
221
|
+
els.check.disabled = false;
|
|
222
|
+
// Keep automatic polling going if it stopped (e.g. after the ceiling).
|
|
223
|
+
poller.start();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
showSignInReady();
|
|
228
|
+
app.connect().then(() => {
|
|
229
|
+
const ctx = app.getHostContext();
|
|
230
|
+
if (ctx) app.onhostcontextchanged(ctx);
|
|
231
|
+
});
|
|
@@ -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
|
+
}
|