@cloudgrid-io/mcp 0.21.9 → 0.21.10

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.9",
3
+ "version": "0.21.10",
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": {
@@ -69,6 +69,7 @@
69
69
  "test:web-identity": "node --test test/web-identity.test.mjs",
70
70
  "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
71
71
  "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
72
+ "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
72
73
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
73
74
  "test:internal-refs": "node --test test/no-internal-refs.test.mjs",
74
75
  "test:login-app": "node test/login-app.test.mjs",
package/src/oauth.js CHANGED
@@ -8,15 +8,19 @@
8
8
  // then redirect back to the client with an authorization code. /oauth/token
9
9
  // exchanges it (PKCE-verified) for the CloudGrid JWT as the access token.
10
10
  //
11
- // No new identity provider. State is in-memory (single replica, like sessions).
11
+ // No new identity provider. Client REGISTRATIONS are stateless (a signed,
12
+ // self-describing client_id — durable across restarts and replicas, see #3060);
13
+ // only the short-lived authorize sessions and auth codes are in-memory.
12
14
 
13
- import { randomUUID, createHash } from "node:crypto";
15
+ import { randomUUID, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
14
16
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
15
17
 
16
18
  const CODE_TTL_MS = 5 * 60 * 1000; // authorize sessions + auth codes live 5 minutes
17
19
 
18
- // In-memory stores. { [id]: record } with created timestamps for TTL sweeps.
19
- const clients = new Map(); // client_id -> { redirect_uris }
20
+ // In-memory stores for the SHORT-LIVED, single-flight halves of the flow. These
21
+ // are 5-minute, retry-on-failure sessions: losing them to a restart just means
22
+ // the user clicks "sign in" again, never that they must re-add the connector.
23
+ // Client REGISTRATIONS are NOT here — they are stateless (see below, #3060).
20
24
  const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, created }
21
25
  const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, created }
22
26
 
@@ -29,6 +33,95 @@ function b64url(buf) {
29
33
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
34
  }
31
35
 
36
+ // --- Stateless client registration (#3060) -----------------------------------
37
+ // A `client_id` is a signed token that CARRIES its own redirect-URI set, not a
38
+ // random handle into a process-local Map. Registration signs the (sorted,
39
+ // deduped) set under a server secret; /oauth/authorize verifies by recomputing
40
+ // the HMAC and checking `redirect_uri` against the signed set. This makes
41
+ // registration durable across restarts, deploys, and ANY replica count with no
42
+ // datastore, and removes the old unbounded-growth `clients` Map entirely.
43
+ //
44
+ // PKCE and redirect validation are untouched — if anything stricter: the
45
+ // redirect set is now cryptographically bound into the client_id, so a tampered
46
+ // client_id fails the HMAC and is rejected.
47
+ //
48
+ // SECRET: MCP_OAUTH_HMAC_SECRET, read from env, sourced from a Kubernetes Secret
49
+ // (NEVER a ConfigMap), never logged. It is a whitespace/comma-separated list:
50
+ // the FIRST entry signs, ALL entries verify.
51
+ //
52
+ // ROTATION (rolling-update safe — order matters). Do NOT simply prepend the new
53
+ // secret: during a rollout, new pods would sign under a key the not-yet-updated
54
+ // old pods cannot verify, so authorize returns transient 400s. Instead:
55
+ // 1. APPEND the new secret (last position = verify-only) and deploy. Every pod
56
+ // now VERIFIES both keys; all pods still SIGN under the old first entry.
57
+ // 2. Once the rollout is complete, PROMOTE the new secret to first position
58
+ // (the signing slot) and deploy. New client_ids are signed under it; the
59
+ // old key still verifies outstanding ones.
60
+ // 3. Later, DROP the old key. client_ids minted under it stop verifying and
61
+ // those users re-add the connector ONCE — the deliberate-rotation cost,
62
+ // versus today's every-deploy breakage.
63
+ // An attacker who obtains the secret can forge a client_id for an arbitrary
64
+ // redirect set — but /oauth/register already mints one for any redirect set
65
+ // unauthenticated, so the secret gates INTEGRITY, not registration; PKCE +
66
+ // CloudGrid sign-in still gate getting a token.
67
+ const CLIENT_ID_PREFIX = "cg1"; // versioned so the scheme can evolve
68
+
69
+ // A client_id CARRIES its redirect set and travels in a query string, so the set
70
+ // must stay bounded or the token grows without limit. Caps chosen well above any
71
+ // real client (ChatGPT/Claude register one or two URIs).
72
+ const MAX_REDIRECT_URIS = 10;
73
+ const MAX_REDIRECT_URIS_BYTES = 4096;
74
+ // A configured signing key shorter than this is weak for an HMAC secret; we warn
75
+ // but still accept it — any configured key beats the ephemeral fallback (#3060).
76
+ const MIN_SECRET_LENGTH = 32;
77
+
78
+ function resolveClientSecrets(env = process.env) {
79
+ const configured = (env.MCP_OAUTH_HMAC_SECRET || "").split(/[\s,]+/).filter(Boolean);
80
+ if (configured.length > 0) return { secrets: configured, ephemeral: false };
81
+ // No configured secret: a per-process ephemeral one. The flow works within a
82
+ // single process, but registrations do NOT survive a restart — i.e. #3060 is
83
+ // unfixed. Production MUST set the secret; the boot log states which mode is
84
+ // active so a deploy can be verified.
85
+ return { secrets: [randomBytes(32).toString("hex")], ephemeral: true };
86
+ }
87
+
88
+ // Canonical form the signature covers: sorted + deduped, as JSON.
89
+ function canonicalRedirectUris(uris) {
90
+ return JSON.stringify([...new Set(uris.map((u) => String(u)))].sort());
91
+ }
92
+
93
+ function signClientId(canonical, secret) {
94
+ const payload = b64url(Buffer.from(canonical, "utf8"));
95
+ const signingInput = `${CLIENT_ID_PREFIX}.${payload}`;
96
+ const sig = b64url(createHmac("sha256", secret).update(signingInput).digest());
97
+ return `${signingInput}.${sig}`;
98
+ }
99
+
100
+ // Verify a client_id against any accepted secret; returns { redirect_uris } or null.
101
+ function verifyClientId(clientId, secrets) {
102
+ const parts = String(clientId).split(".");
103
+ if (parts.length !== 3 || parts[0] !== CLIENT_ID_PREFIX) return null;
104
+ const [, payload, sig] = parts;
105
+ const signingInput = `${CLIENT_ID_PREFIX}.${payload}`;
106
+ const given = Buffer.from(sig);
107
+ let ok = false;
108
+ for (const secret of secrets) {
109
+ const expected = Buffer.from(b64url(createHmac("sha256", secret).update(signingInput).digest()));
110
+ if (expected.length === given.length && timingSafeEqual(expected, given)) {
111
+ ok = true;
112
+ break;
113
+ }
114
+ }
115
+ if (!ok) return null;
116
+ try {
117
+ const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
118
+ const uris = JSON.parse(json);
119
+ return Array.isArray(uris) ? { redirect_uris: uris } : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
32
125
  function corsOk(res) {
33
126
  res.setHeader("Access-Control-Allow-Origin", "*");
34
127
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -81,6 +174,33 @@ export function mountOAuth(app, publicBase, opts = {}) {
81
174
  if (!opts.requireAuth) return;
82
175
  const base = publicBase.replace(/\/+$/, "");
83
176
 
177
+ // Resolve the client-registration signing secret(s) once at mount. Log which
178
+ // mode is active — durable vs. ephemeral — so a production deploy can be
179
+ // verified from the boot log without ever printing the secret itself.
180
+ const clientSecrets = resolveClientSecrets();
181
+ if (clientSecrets.ephemeral) {
182
+ console.warn(
183
+ "[oauth] MCP_OAUTH_HMAC_SECRET is NOT set — OAuth client registrations use a per-process " +
184
+ "ephemeral secret (see #3060). This is NOT just a survives-restart issue: with more than " +
185
+ "one replica, each process mints under its own key, so a client_id registered on one " +
186
+ "replica fails on another — authorize breaks intermittently, per request, with no restart " +
187
+ "involved, which diagnoses as an unrelated bug. Set the secret in production.",
188
+ );
189
+ } else {
190
+ const weak = clientSecrets.secrets.filter((s) => s.length < MIN_SECRET_LENGTH).length;
191
+ if (weak > 0) {
192
+ console.warn(
193
+ `[oauth] MCP_OAUTH_HMAC_SECRET has ${weak} key(s) shorter than ${MIN_SECRET_LENGTH} chars — ` +
194
+ "weak for an HMAC signing key; use a long random value (e.g. `openssl rand -hex 32`). " +
195
+ "Accepted anyway (still better than the ephemeral fallback).",
196
+ );
197
+ }
198
+ console.log(
199
+ `[oauth] client-registration secret configured (${clientSecrets.secrets.length} key(s)); ` +
200
+ "registrations are durable across restarts and replicas.",
201
+ );
202
+ }
203
+
84
204
  const asMetadata = {
85
205
  issuer: base,
86
206
  authorization_endpoint: `${base}/oauth/authorize`,
@@ -122,14 +242,28 @@ export function mountOAuth(app, publicBase, opts = {}) {
122
242
  res.status(400).json({ error: "invalid_client_metadata", error_description: "redirect_uris is required." });
123
243
  return;
124
244
  }
125
- const clientId = randomUUID();
126
- clients.set(clientId, { redirect_uris: redirectUris, created: Date.now() });
245
+ if (redirectUris.length > MAX_REDIRECT_URIS) {
246
+ res.status(400).json({
247
+ error: "invalid_client_metadata",
248
+ error_description: `At most ${MAX_REDIRECT_URIS} redirect_uris are allowed.`,
249
+ });
250
+ return;
251
+ }
252
+ const canonical = canonicalRedirectUris(redirectUris);
253
+ if (Buffer.byteLength(canonical, "utf8") > MAX_REDIRECT_URIS_BYTES) {
254
+ res.status(400).json({
255
+ error: "invalid_client_metadata",
256
+ error_description: `redirect_uris exceed the ${MAX_REDIRECT_URIS_BYTES}-byte limit.`,
257
+ });
258
+ return;
259
+ }
260
+ const clientId = signClientId(canonical, clientSecrets.secrets[0]);
127
261
  res.status(201).json({
128
262
  client_id: clientId,
129
263
  token_endpoint_auth_method: "none",
130
264
  grant_types: ["authorization_code"],
131
265
  response_types: ["code"],
132
- redirect_uris: redirectUris,
266
+ redirect_uris: JSON.parse(canonical),
133
267
  });
134
268
  });
135
269
 
@@ -137,7 +271,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
137
271
  app.get("/oauth/authorize", (req, res) => {
138
272
  sweep(authSessions);
139
273
  const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
140
- const client = clients.get(String(client_id));
274
+ const client = verifyClientId(client_id, clientSecrets.secrets);
141
275
  if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
142
276
  res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
143
277
  return;
@@ -2230,16 +2230,32 @@ export async function runPlug(ctx, input, deps = {}) {
2230
2230
  lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
2231
2231
  }
2232
2232
 
2233
+ // Point the user at their grid (the console) after a deploy that is LIVE — new
2234
+ // or re-plug (#321). The app URL says where the app lives; the console line says
2235
+ // where the grid lives, so the common follow-up interaction (an edit) does not
2236
+ // end with a bare URL and no way back. The console root self-routes per account
2237
+ // state (sign-in wall → their grid → onboarding); there is no per-grid page to
2238
+ // link, so keep the root — a `/grids/<slug>` path 404s today.
2239
+ //
2240
+ // NOT on the building branch: the line says "view it live", and a still-building
2241
+ // app is not live — emitting it two lines under "Do NOT tell the user it is live"
2242
+ // would hand a model the word "live" next to a pending app. For an async runtime
2243
+ // build the console line arrives when liveness is confirmed, in runCheckDeploy's
2244
+ // success branch.
2245
+ if (data.entity_id && !isBuilding) {
2246
+ structured.console_url = CONSOLE_URL;
2247
+ lines.push(`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`);
2248
+ }
2249
+
2233
2250
  // Visibility is the user's choice — never set silently. On a NEW deploy,
2234
2251
  // surface the current visibility + the full option set and have the agent ASK
2235
2252
  // the user, then apply their answer via grid_visibility. On an edit, leave the
2236
- // entity's existing visibility untouched (don't re-ask on every re-plug).
2253
+ // entity's existing visibility untouched (don't re-ask on every re-plug) — this
2254
+ // stays `!isEdit` deliberately, decoupled from the console line above.
2237
2255
  if (!isEdit && data.entity_id) {
2238
2256
  const current = typeof data.visibility === "string" ? data.visibility : null;
2239
- structured.console_url = CONSOLE_URL;
2240
2257
  if (current) structured.current_visibility = current;
2241
2258
  structured.visibility_options = VISIBILITY_OPTIONS.map((v) => ({ value: v, label: VISIBILITY_LABELS[v] }));
2242
- lines.push(`Manage all your apps in your grid: ${CONSOLE_URL}`);
2243
2259
  lines.push(
2244
2260
  `Now ASK the user who should be able to open this${current ? ` (currently ${VISIBILITY_LABELS[current] ?? current})` : ""}, then set their choice with grid_visibility — do not decide it for them. Options: ${
2245
2261
  VISIBILITY_OPTIONS
@@ -2717,8 +2733,12 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
2717
2733
  const url = ctx.state.lastDrop?.url;
2718
2734
  if (verdict.status === "success") {
2719
2735
  return {
2720
- text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.`,
2721
- structured: { status: "success", live: true, ...(url ? { url } : {}) },
2736
+ // The "now live" moment for an async runtime build lands here, not in
2737
+ // runPlug (which returned "building"). Point the user at their grid too
2738
+ // (#321) — this is where liveness is confirmed, so "view it live" is true.
2739
+ text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
2740
+ `You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`,
2741
+ structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: CONSOLE_URL },
2722
2742
  };
2723
2743
  }
2724
2744
  if (verdict.status === "failed") {
@@ -845,6 +845,7 @@ export function registerTools(server, ctx) {
845
845
  status: z.string().describe("The build status: success | failed | building | queued | unknown."),
846
846
  live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
847
847
  url: z.string().optional().describe("The live URL, when known."),
848
+ console_url: z.string().optional().describe("On success: URL to view the app live in the grid alongside all the user's apps."),
848
849
  error: z.string().optional().describe("User-language failure reason, when the build failed."),
849
850
  build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
850
851
  suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),