@cloudgrid-io/mcp 0.21.10 → 0.21.12

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/README.md CHANGED
@@ -84,7 +84,7 @@ posted). `CLOUDGRID_QA_IDLE_MS` overrides the abandoned-idle window (default
84
84
  <!-- gen:tools -->
85
85
  ## Tools
86
86
 
87
- 37 tools registered (15 shared across both editions, 22 local-only). No deprecated aliases. MIN_CLI_VERSION: 0.15.26.
87
+ 38 tools registered (16 shared across both editions, 22 local-only). No deprecated aliases. MIN_CLI_VERSION: 0.15.26.
88
88
 
89
89
  ### Direct-API tools (both editions)
90
90
 
@@ -105,6 +105,7 @@ posted). `CLOUDGRID_QA_IDLE_MS` overrides the abandoned-idle window (default
105
105
  | `grid_start` | API | Orient before building with CloudGrid — the live runtime environment where the user's apps run WITH the infrastructur... |
106
106
  | `grid_get_template` | corpus | Load a specific CloudGrid workflow, template, example, rule, or doc by name — deterministic retrieval from the bundle... |
107
107
  | `grid_report` | API | Report a genuine CloudGrid failure to the CloudGrid team — ONLY with the user's explicit consent. |
108
+ | `grid_hello` | API | Create a minimal hello page and plug it into the user's grid, returning the live URL. |
108
109
 
109
110
  The direct-API tools call the platform without the CLI, so they also work in
110
111
  the web edition. `grid_login` writes the same `~/.cloudgrid/credentials` the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.21.10",
3
+ "version": "0.21.12",
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": {
@@ -31,6 +31,8 @@
31
31
  "test:plug-output-schema": "node test/plug-output-schema.test.mjs",
32
32
  "test:zip-deploy": "node test/zip-deploy.test.mjs",
33
33
  "test:create-gates": "node test/create-gates.test.mjs",
34
+ "test:plug-create-grid-choice": "node test/plug-create-grid-choice.test.mjs",
35
+ "test:hello": "node test/hello.test.mjs",
34
36
  "test:grid-provisioning": "node test/grid-provisioning.test.mjs",
35
37
  "test:create-project-args": "node test/create-project-args.test.mjs",
36
38
  "test:mcpb-boot": "node test/mcpb-boot.test.mjs",
@@ -70,6 +72,7 @@
70
72
  "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
71
73
  "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
72
74
  "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
75
+ "test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
73
76
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
74
77
  "test:internal-refs": "node --test test/no-internal-refs.test.mjs",
75
78
  "test:login-app": "node test/login-app.test.mjs",
package/src/oauth.js CHANGED
@@ -228,11 +228,18 @@ export function mountOAuth(app, publicBase, opts = {}) {
228
228
  res.json({ resource: `${base}/mcp`, authorization_servers: [base], scopes_supported: ["cloudgrid"] });
229
229
  });
230
230
 
231
- // RFC 8414.
232
- app.get("/.well-known/oauth-authorization-server", (_req, res) => {
231
+ // RFC 8414. Both the root form and the path-inserted form: the MCP
232
+ // authorization spec has clients derive the metadata URL from the RESOURCE
233
+ // url (…/mcp), so a client that tries `/.well-known/oauth-authorization-server/mcp`
234
+ // first must not get a 404 HTML page. The protected-resource document above
235
+ // already served both forms; this one served only the root, and the asymmetry
236
+ // was an oversight rather than a decision.
237
+ const serveAsMetadata = (_req, res) => {
233
238
  corsOk(res);
234
239
  res.json(asMetadata);
235
- });
240
+ };
241
+ app.get("/.well-known/oauth-authorization-server", serveAsMetadata);
242
+ app.get("/.well-known/oauth-authorization-server/mcp", serveAsMetadata);
236
243
 
237
244
  // RFC 7591 — dynamic client registration. Public clients, PKCE-only.
238
245
  app.post("/oauth/register", (req, res) => {
@@ -319,6 +326,9 @@ export function mountOAuth(app, publicBase, opts = {}) {
319
326
  created: Date.now(),
320
327
  });
321
328
  authSessions.delete(String(req.query.sid));
329
+ // The sign-in half succeeded. Without this line a connector failure gives
330
+ // no way to tell whether the user ever got through sign-in at all.
331
+ console.error("[oauth] authorization code issued after sign-in; awaiting token exchange");
322
332
  const sep = sess.redirect_uri.includes("?") ? "&" : "?";
323
333
  const redirect = `${sess.redirect_uri}${sep}code=${encodeURIComponent(code)}${sess.state ? `&state=${encodeURIComponent(sess.state)}` : ""}`;
324
334
  res.json({ status: "ready", redirect });
@@ -332,22 +342,74 @@ export function mountOAuth(app, publicBase, opts = {}) {
332
342
  });
333
343
 
334
344
  // Token endpoint — PKCE-verified exchange; the CloudGrid JWT is the access token.
345
+ //
346
+ // WHY THIS BRANCHES INSTEAD OF ONE COMBINED `if`
347
+ //
348
+ // Reported 2026-08-30: ChatGPT web fails with "Something went wrong with
349
+ // setting up the connection" AFTER the user signs in, which puts the failure
350
+ // in this handler. It could not be diagnosed from either side, because five
351
+ // unrelated causes all produced a byte-identical `{"error":"invalid_grant"}`:
352
+ //
353
+ // unknown/expired code · code already exchanged · client_id absent
354
+ // · client_id mismatched · redirect_uri mismatched (a trailing slash does it)
355
+ //
356
+ // All five were reproduced against this handler (test/oauth-token-exchange).
357
+ // Nothing was logged, so the server could not say which happened either — the
358
+ // failure was invisible from both ends at once. That is the actual defect
359
+ // being fixed here: not the rejection, which is correct in every one of those
360
+ // cases, but that a rejection carried no information.
361
+ //
362
+ // The reason is logged server-side and returned as `error_description`. The
363
+ // caller already holds the code, the client_id and the redirect_uri it sent,
364
+ // so naming which of ITS OWN values did not match tells it nothing it did not
365
+ // already know. The token, the verifier and the code are never logged.
366
+ //
367
+ // THE TRADE, NAMED (so nobody extends this pattern by accident): branching
368
+ // these messages does hand a holder of an EXFILTRATED code a small oracle —
369
+ // they can confirm the code is live and probe client_id / redirect_uri
370
+ // without spending a PKCE guess. Accepted here because the code is
371
+ // single-use, dies in 5 minutes, and PKCE still gates the token, so the
372
+ // oracle reveals only values the attacker would have to have already; and
373
+ // RFC 6749 §5.2 explicitly provides error_description for this purpose. That
374
+ // reasoning is specific to this endpoint. Do NOT copy the pattern somewhere
375
+ // a failed attempt is cheap to repeat or the compared value is a secret.
376
+ const denyToken = (res, error, reason, detail = "") => {
377
+ console.error(`[oauth] token exchange refused: ${reason}${detail ? ` ${detail}` : ""}`);
378
+ res.status(400).json({ error, error_description: reason });
379
+ };
380
+
335
381
  app.post("/oauth/token", (req, res) => {
336
382
  corsOk(res);
337
383
  sweep(authCodes);
338
384
  const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body ?? {};
339
385
  if (grant_type !== "authorization_code") {
340
- res.status(400).json({ error: "unsupported_grant_type" });
386
+ denyToken(res, "unsupported_grant_type", "grant_type must be authorization_code", `(got ${JSON.stringify(grant_type ?? null)})`);
341
387
  return;
342
388
  }
343
389
  const rec = authCodes.get(String(code));
344
- if (!rec || rec.client_id !== String(client_id) || rec.redirect_uri !== String(redirect_uri)) {
345
- res.status(400).json({ error: "invalid_grant" });
390
+ if (!rec) {
391
+ // Also the retry case: codes are single-use, so a second exchange of a
392
+ // code that already succeeded lands here.
393
+ denyToken(res, "invalid_grant", "authorization code is unknown, expired or already exchanged");
394
+ return;
395
+ }
396
+ if (rec.client_id !== String(client_id)) {
397
+ denyToken(
398
+ res,
399
+ "invalid_grant",
400
+ client_id === undefined ? "client_id is required at the token endpoint" : "client_id does not match the one the code was issued to",
401
+ );
402
+ return;
403
+ }
404
+ if (rec.redirect_uri !== String(redirect_uri)) {
405
+ // The exact strings, because the difference is usually invisible —
406
+ // a trailing slash, a case change, an added query parameter.
407
+ denyToken(res, "invalid_grant", "redirect_uri does not match the authorize request", `(sent ${JSON.stringify(redirect_uri ?? null)}, expected ${JSON.stringify(rec.redirect_uri)})`);
346
408
  return;
347
409
  }
348
410
  const challenge = b64url(createHash("sha256").update(String(code_verifier ?? "")).digest());
349
411
  if (challenge !== rec.code_challenge) {
350
- res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed." });
412
+ denyToken(res, "invalid_grant", code_verifier === undefined ? "code_verifier is required (PKCE)" : "PKCE verification failed");
351
413
  return;
352
414
  }
353
415
  authCodes.delete(String(code)); // single use
@@ -104,7 +104,15 @@ export async function fetchUserOrgs(token) {
104
104
  // - >1 grid and none supplied → { picker } (a ready-to-return result)
105
105
  // - exactly one grid → { single: annotatedOrg } — the caller
106
106
  // decides how to treat a not-ready single grid (drop blocks; plug warns)
107
- // - no orgs / listing failed → { proceed: true } (fall through)
107
+ // - no orgs / listing failed → { picker } needs_grid_create (asks)
108
+ // Every return shape either ASKS (a picker) or names an explicit grid — none is a
109
+ // silent proceed. That is load-bearing: register.js returns the picker or sets
110
+ // input.grid from `grid`/`single` before runPlug, so the create path never
111
+ // reaches runPlug's headerless fallback (#327). Do NOT reintroduce a
112
+ // `{ proceed: true }` "fall through" shape here — a proceed with NO grid slips
113
+ // past register.js without setting input.grid, into an ungated runPlug where
114
+ // orgSlug is null and the SERVER picks the grid silently: exactly the wrong-grid
115
+ // create this PR removed. A listing failure must ASK, never fall through.
108
116
  // User-facing text says "grid" (Gilad's org→grid rename); the structured payload
109
117
  // carries `needs_grid` AND the `needs_org`/`orgs`/`org`-slug fields the existing
110
118
  // org-picker web widget reads, so the web card keeps working. Stateless — no
@@ -1838,13 +1846,42 @@ export async function runPlug(ctx, input, deps = {}) {
1838
1846
  // through — never a silent redirect.
1839
1847
  // 4. the target entity itself — a pickup round-trip, only if 1–3 all
1840
1848
  // came up empty. Reuse a pickup already done above to resolve the id.
1841
- orgSlug = grid || (await ctx.getActiveGrid());
1842
- if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
1843
- if (!orgSlug && isEdit) {
1844
- if (!pickupData && targetEntityId) {
1845
- pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
1849
+ if (isEdit) {
1850
+ // EDIT: the destination is NOT ambiguous — the entity already lives in
1851
+ // exactly one grid, so derive it (never ask). explicit `grid` → active
1852
+ // grid → URL host → entity lookup (#296/#301/#316). This chain must stay
1853
+ // intact: re-asking on every re-plug is noise and would undo those fixes.
1854
+ orgSlug = grid || (await ctx.getActiveGrid());
1855
+ if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
1856
+ if (!orgSlug) {
1857
+ if (!pickupData && targetEntityId) {
1858
+ pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
1859
+ }
1860
+ if (pickupData?.grid) orgSlug = pickupData.grid;
1846
1861
  }
1847
- if (pickupData?.grid) orgSlug = pickupData.grid;
1862
+ } else {
1863
+ // CREATE (#327): the destination grid is the USER'S CHOICE and must be
1864
+ // EXPLICIT — never silently reuse the persisted active grid. A stale
1865
+ // active grid (weeks old, not surfaced to the user) silently lands a NEW
1866
+ // entity in the wrong grid, and the grid decides the URL, who can open it
1867
+ // (grid-scoped visibility), the datastore tier it inherits, and the
1868
+ // namespace it runs in.
1869
+ //
1870
+ // What makes this safe is the grid-picker gate — resolveGridOrAsk (defined
1871
+ // above; called from register.js): it either ASKS (returns a picker) or
1872
+ // sets input.grid to an explicit slug (the single grid, or the user's
1873
+ // choice) BEFORE runPlug runs. So on the real create path `grid` is always
1874
+ // populated here.
1875
+ //
1876
+ // `null` is NOT a backstop — it is the DEFECT value. A headerless authed
1877
+ // create does not refuse; it delegates the choice to the server, which
1878
+ // resolves a default and picks a grid silently (the hosted half of this
1879
+ // bug). The only reason a null is ever safe is that the gate above never
1880
+ // lets an unresolved authed create reach here. Refusing on null (returning
1881
+ // needs_grid instead of proceeding) is the stronger fix, tracked as a #327
1882
+ // follow-up — do NOT rely on `orgSlug = grid || null` alone to prevent a
1883
+ // wrong-grid create.
1884
+ orgSlug = grid || null;
1848
1885
  }
1849
1886
  // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1850
1887
  if (orgSlug) {
@@ -61,6 +61,14 @@ export function buildCreateProjectArgs({ kind, name, type, needs, dir, grid, org
61
61
  // exclamation; say "plug" and "grid". The sign-in URL itself lands on its own
62
62
  // line later, from grid_login. No structural change — every caller still returns
63
63
  // { needs_auth: true }.
64
+ // Escape untrusted text before it is interpolated into a page we publish at a
65
+ // public URL. `&` MUST be replaced first, or the later replacements' own
66
+ // ampersands get double-escaped. Both grid_hello sinks are element text
67
+ // (<title> RCDATA and <body>), which this covers.
68
+ const escapeHtml = (s) =>
69
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
70
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
71
+
64
72
  const AUTH_ASK_SIGNED_OUT =
65
73
  "How would you like to plug this?\n" +
66
74
  " - Sign in to your grid — it stays in your CloudGrid account.\n" +
@@ -396,9 +404,15 @@ export function registerTools(server, ctx) {
396
404
  "Re-plugging an anonymously-created drop needs its owner_token instead of sign-in.",
397
405
  ),
398
406
  grid: z.string().optional().describe(
399
- "On create: the grid slug to plug into (omit to use the caller's active grid). On re-plug the entity " +
400
- "never moves grids, but pass its home grid here when it differs from your active grid (the API " +
401
- "checks your membership in the entity's grid). Anonymous → always the Guest grid.",
407
+ "On create: the grid slug to plug into. The destination grid is the USER'S CHOICE — treat it like " +
408
+ "visibility: NEVER infer one silently, and never reuse a previous or active grid without the user. If " +
409
+ "the user has more than one grid, CONFIRM which grid before plugging (omit this and grid_plug returns " +
410
+ "needs_grid with the grid list to choose from); a single-grid user is not asked. State the destination " +
411
+ "in the user's own terms (\"plugging into <grid>\") BEFORE the entity is created — a new entity in the " +
412
+ "wrong grid gets the wrong URL, is exposed to the wrong grid's members, and inherits the wrong datastore " +
413
+ "tier and namespace, and that is expensive to catch after the fact. On re-plug the entity never moves " +
414
+ "grids, but pass its home grid here when it differs from your active grid (the API checks your " +
415
+ "membership in the entity's grid). Anonymous → always the Guest grid.",
402
416
  ),
403
417
  slug: z.string().optional().describe(
404
418
  "Alternative RE-PLUG handle: paired with `grid`, resolves to an existing entity (the pickup " +
@@ -453,6 +467,10 @@ export function registerTools(server, ctx) {
453
467
  "Without target_entity_id: CREATE a new entity with a new URL. " +
454
468
  "With target_entity_id (or grid+slug): UPDATE the existing deployment IN PLACE, keeping the same URL — " +
455
469
  "this is the only deploy/publish tool, so do not look for a separate 'update' or 'redeploy' tool. " +
470
+ "GRID CHOICE (new plug): the destination grid is the user's choice, like visibility — do NOT infer or " +
471
+ "reuse a grid silently. For a user with more than one grid, CONFIRM which grid before plugging (omit " +
472
+ "`grid` and grid_plug returns needs_grid to ask); a single-grid user is not asked. State the " +
473
+ "destination before the entity is created. On re-plug the grid is fixed by the entity — never re-asked. " +
456
474
  "Sources (pass exactly one): `html` — a single self-contained HTML page (instant, any edition)" +
457
475
  (ctx.edition === "web"
458
476
  ? " or `artifact_files` — a multi-file app inline. "
@@ -1145,6 +1163,80 @@ export function registerTools(server, ctx) {
1145
1163
  }
1146
1164
  },
1147
1165
  );
1166
+ // ── grid_hello — creates a hello page and plugs it (both editions) ────────
1167
+ reg(
1168
+ "grid_hello",
1169
+ {
1170
+ title: "Plug a hello page",
1171
+ description:
1172
+ "Create a minimal hello page and plug it into the user's grid, returning the live URL. " +
1173
+ "If the user has more than one grid and `grid` is not given, this returns needs_grid with " +
1174
+ "the list — ASK the user which grid, then call again with `grid` set. Never guess the grid. " +
1175
+ "Requires sign-in.",
1176
+ inputSchema: {
1177
+ grid: z.string().optional()
1178
+ .describe("Grid slug to plug into. Omit on the first call to be asked."),
1179
+ name: z.string().optional()
1180
+ .describe("Who to greet. Defaults to 'world'."),
1181
+ },
1182
+ outputSchema: {
1183
+ url: z.string().optional().describe("The live URL, once plugged."),
1184
+ entity_id: z.string().optional().describe("Re-plug handle."),
1185
+ needs_auth: z.boolean().optional().describe("True when the user must sign in first."),
1186
+ needs_grid: z.boolean().optional().describe("True when the user must choose a grid."),
1187
+ },
1188
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1189
+ },
1190
+ async (input) => {
1191
+ try {
1192
+ // 1. AUTH — same shape as grid_plug (register.js:585). Not an error:
1193
+ // a result the model acts on by running grid_login.
1194
+ const token = await ctx.getToken();
1195
+ if (!token) {
1196
+ // NOT the shared AUTH_ASK_SIGNED_OUT: that offers a guest path via
1197
+ // `grid_plug` with anon:true, which this tool has no parameter for and
1198
+ // which would send the model to a different tool with no source. Say
1199
+ // only what grid_hello can actually do.
1200
+ return okResult({
1201
+ text:
1202
+ "Plugging a hello page needs an account. Call grid_login to sign in, "
1203
+ + "then re-call grid_hello.",
1204
+ structured: { needs_auth: true },
1205
+ });
1206
+ }
1207
+
1208
+ // 2. GRID — never fall back to the active grid on a create (#327).
1209
+ // Four outcomes; three of them are handled right here.
1210
+ const decision = await resolveGridOrAsk(ctx, {
1211
+ token,
1212
+ suppliedGrid: input?.grid,
1213
+ edition: ctx.edition,
1214
+ });
1215
+ if (decision.picker) return okResult(decision.picker); // >1 grid, or 0 grids → ASK
1216
+ const grid = decision.grid || decision.single?.slug; // matched, or exactly one
1217
+ if (!grid) return fail("Could not resolve a grid. Pass `grid` explicitly.");
1218
+
1219
+ // 3. BUILD — escape user input. This page is published to a public URL.
1220
+ const who = escapeHtml(String(input?.name ?? "world").slice(0, 60));
1221
+ const html = `<!doctype html>
1222
+ <html lang="en"><head><meta charset="utf-8">
1223
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1224
+ <title>Hello, ${who}</title>
1225
+ <style>
1226
+ body{margin:0;height:100vh;display:grid;place-items:center;
1227
+ font:600 clamp(2rem,10vw,6rem)/1.1 ui-sans-serif,system-ui,sans-serif;
1228
+ background:#0b0b0f;color:#f4f4f5}
1229
+ </style></head>
1230
+ <body>Hello, ${who}</body></html>`;
1231
+
1232
+ // 4. PLUG — one inline HTML document is an inspiration. runPlug does the rest.
1233
+ const res = await runPlug(ctx, { html, grid });
1234
+ return okResult(res);
1235
+ } catch (err) {
1236
+ return fail(err.message);
1237
+ }
1238
+ },
1239
+ );
1148
1240
 
1149
1241
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
1150
1242