@cloudgrid-io/mcp 0.21.15 → 0.21.16

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.15",
3
+ "version": "0.21.16",
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": {
@@ -74,6 +74,7 @@
74
74
  "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
75
75
  "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
76
76
  "test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
77
+ "test:oauth-resource-indicators": "node --test test/oauth-resource-indicators.test.mjs",
77
78
  "test:oauth-observability": "node --test test/oauth-flow-observability.test.mjs",
78
79
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
79
80
  "test:mcp-transport-observability": "node --test test/mcp-transport-observability.test.mjs",
package/src/oauth.js CHANGED
@@ -21,8 +21,8 @@ const CODE_TTL_MS = 5 * 60 * 1000; // authorize sessions + auth codes live 5 min
21
21
  // are 5-minute, retry-on-failure sessions: losing them to a restart just means
22
22
  // the user clicks "sign in" again, never that they must re-add the connector.
23
23
  // Client REGISTRATIONS are NOT here — they are stateless (see below, #3060).
24
- const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, created }
25
- const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, created }
24
+ const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, resource, created }
25
+ const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, resource, created }
26
26
 
27
27
  function sweep(map) {
28
28
  const now = Date.now();
@@ -58,6 +58,38 @@ function hostOf(uri) {
58
58
  }
59
59
  }
60
60
 
61
+ // --- RFC 8707 Resource Indicators (#329) --------------------------------------
62
+ // The MCP 2025-06-18 authorization spec has clients send `resource` — the
63
+ // canonical URI of the MCP server the token is for — on BOTH the authorize and
64
+ // token requests. We accept it, validate it against this server's own resource
65
+ // identifier (the `${base}/mcp` value /.well-known/oauth-protected-resource
66
+ // returns), and bind it to the code. A mismatch is `invalid_target` (RFC 8707
67
+ // §2). A MISSING resource stays acceptable: Claude web / Claude Code work today
68
+ // and may not send it, and the spec is explicit that clients "MUST send this
69
+ // parameter regardless of whether authorization servers support it" — i.e. the
70
+ // server may still honour requests without it.
71
+ //
72
+ // Comparison is on the CANONICAL form, not a raw string, so a cosmetically
73
+ // different but equivalent URI (uppercase scheme/host, a trailing slash, a
74
+ // default port) is not falsely rejected. Per the spec: lowercase scheme and
75
+ // host (URL parsing does this), no fragment, and the trailing-slash-free path.
76
+ // The QUERY STRING is also dropped by construction (we rebuild from protocol +
77
+ // host + path only), so `…/mcp?x=1` compares EQUAL to `…/mcp`. That is the
78
+ // permissive direction and fine here — our resource identifier carries no
79
+ // query — but it is stated because nobody would predict it from the code.
80
+ // Returns null for anything that is not an absolute URI — that then fails the
81
+ // equality check as a mismatch.
82
+ function normalizeResource(uri) {
83
+ try {
84
+ const u = new URL(String(uri));
85
+ if (!u.host) return null;
86
+ const path = u.pathname.replace(/\/+$/, ""); // …/mcp/ ≡ …/mcp
87
+ return `${u.protocol}//${u.host}${path}`; // protocol + host are already lowercased & default-port-stripped
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
61
93
  // --- Stateless client registration (#3060) -----------------------------------
62
94
  // A `client_id` is a signed token that CARRIES its own redirect-URI set, not a
63
95
  // random handle into a process-local Map. Registration signs the (sorted,
@@ -198,6 +230,10 @@ function interstitialHtml(sid, loginUrl) {
198
230
  export function mountOAuth(app, publicBase, opts = {}) {
199
231
  if (!opts.requireAuth) return;
200
232
  const base = publicBase.replace(/\/+$/, "");
233
+ // This server's resource identifier — the exact value the protected-resource
234
+ // metadata returns and the canonical `resource` a client must send (#329).
235
+ const resourceId = `${base}/mcp`;
236
+ const expectedResource = normalizeResource(resourceId);
201
237
 
202
238
  // Resolve the client-registration signing secret(s) once at mount. Log which
203
239
  // mode is active — durable vs. ephemeral — so a production deploy can be
@@ -236,6 +272,14 @@ export function mountOAuth(app, publicBase, opts = {}) {
236
272
  code_challenge_methods_supported: ["S256"],
237
273
  token_endpoint_auth_methods_supported: ["none"],
238
274
  scopes_supported: ["cloudgrid"],
275
+ // RFC 8707 Resource Indicators support (#329). RFC 8707 defines NO
276
+ // authorization-server metadata field of its own, and neither does the MCP
277
+ // spec; the only IANA-registered resource-related AS-metadata field is
278
+ // `protected_resources` (RFC 9728 §4) — a JSON array of the resource
279
+ // identifiers this AS issues tokens for. Advertising ours here lets a client
280
+ // discover the canonical `resource` value to send, and signals that we honour
281
+ // the parameter rather than ignoring it.
282
+ protected_resources: [resourceId],
239
283
  };
240
284
 
241
285
  app.options(/^\/(\.well-known|oauth)\/.*/, (_req, res) => {
@@ -302,16 +346,19 @@ export function mountOAuth(app, publicBase, opts = {}) {
302
346
  // Authorization endpoint — render the bridge interstitial.
303
347
  app.get("/oauth/authorize", (req, res) => {
304
348
  sweep(authSessions);
305
- const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
306
- // Observability (#345): the request SHAPE the client actually sent, logged
307
- // before any check so a rejected request is still visible. Presence, not
308
- // values — nothing here is reusable in a log aggregator.
349
+ const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type, resource } = req.query;
350
+ // Observability (#345/#329): the request SHAPE the client actually sent,
351
+ // logged before any check so a rejected request is still visible. Presence,
352
+ // not values — nothing here is reusable in a log aggregator. The resource
353
+ // HOST is included: it is not a secret, and its presence/absence is the exact
354
+ // signal that distinguishes ChatGPT (sends it) from Claude (does not).
309
355
  console.error(
310
356
  `[oauth] authorize request: client_id=${clientIdPrefix(client_id)} ` +
311
357
  `response_type=${response_type ? String(response_type) : "absent"} ` +
312
358
  `state=${state ? "present" : "absent"} ` +
313
359
  `pkce=${code_challenge ? (code_challenge_method ? String(code_challenge_method) : "present(no-method)") : "absent"} ` +
314
- `redirect_host=${hostOf(redirect_uri)}`,
360
+ `redirect_host=${hostOf(redirect_uri)} ` +
361
+ `resource_host=${hostOf(resource)}`,
315
362
  );
316
363
  const client = verifyClientId(client_id, clientSecrets.secrets);
317
364
  if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
@@ -322,6 +369,39 @@ export function mountOAuth(app, publicBase, opts = {}) {
322
369
  res.status(400).send("This server requires response_type=code with PKCE (S256).");
323
370
  return;
324
371
  }
372
+ // RFC 8707 (#329): if a `resource` is present it must be THIS server's
373
+ // resource identifier. Compared on the canonical form so an equivalent URI
374
+ // (case, trailing slash, default port) is accepted. A mismatch is
375
+ // invalid_target; a MISSING resource stays acceptable (Claude compatibility).
376
+ // The bound value is the canonical string, so the token endpoint can compare
377
+ // apples to apples later.
378
+ let boundResource = "";
379
+ if (resource !== undefined && String(resource) !== "") {
380
+ const requested = normalizeResource(resource);
381
+ if (!requested || requested !== expectedResource) {
382
+ // RFC 6749 §4.1.2.1: the redirect_uri is already verified as registered
383
+ // (the render-path 400 above), so an authorization error is returned by
384
+ // REDIRECTING to it with error= and state=, NOT by rendering an opaque
385
+ // body the client cannot read — RFC 8707 §2 defines invalid_target as
386
+ // exactly such an OAuth error code. This is the whole point of the PR:
387
+ // a spec fix that makes a silent failure legible must not add a new
388
+ // illegible one (an opaque 400 where ChatGPT expects
389
+ // error=invalid_target&state=… would just be another #329-shaped
390
+ // symptom). The two 400s above STAY renders: an unverified client or
391
+ // redirect_uri must NEVER be redirected to, so they are not precedent.
392
+ console.error(
393
+ `[oauth] authorize refused (redirect): invalid_target resource_host=${hostOf(resource)} expected_host=${hostOf(resourceId)}`,
394
+ );
395
+ const sep = String(redirect_uri).includes("?") ? "&" : "?";
396
+ let location = `${redirect_uri}${sep}error=invalid_target&error_description=${encodeURIComponent(
397
+ "The requested resource does not identify this server.",
398
+ )}`;
399
+ if (state) location += `&state=${encodeURIComponent(String(state))}`;
400
+ res.redirect(302, location);
401
+ return;
402
+ }
403
+ boundResource = requested;
404
+ }
325
405
  const sid = randomUUID();
326
406
  const cgCode = newLoginCode();
327
407
  authSessions.set(sid, {
@@ -330,6 +410,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
330
410
  redirect_uri: String(redirect_uri),
331
411
  state: state ? String(state) : "",
332
412
  code_challenge: String(code_challenge),
413
+ resource: boundResource,
333
414
  created: Date.now(),
334
415
  });
335
416
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -358,6 +439,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
358
439
  client_id: sess.client_id,
359
440
  redirect_uri: sess.redirect_uri,
360
441
  code_challenge: sess.code_challenge,
442
+ resource: sess.resource, // RFC 8707 binding carried from the authorize session (#329)
361
443
  created: Date.now(),
362
444
  });
363
445
  authSessions.delete(String(req.query.sid));
@@ -420,7 +502,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
420
502
  app.post("/oauth/token", (req, res) => {
421
503
  corsOk(res);
422
504
  sweep(authCodes);
423
- const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body ?? {};
505
+ const { grant_type, code, code_verifier, redirect_uri, client_id, resource } = req.body ?? {};
424
506
  if (grant_type !== "authorization_code") {
425
507
  denyToken(res, "unsupported_grant_type", "grant_type must be authorization_code", `(got ${JSON.stringify(grant_type ?? null)})`);
426
508
  return;
@@ -448,6 +530,23 @@ export function mountOAuth(app, publicBase, opts = {}) {
448
530
  denyToken(res, "invalid_grant", "redirect_uri does not match the authorize request", `(sent ${JSON.stringify(redirect_uri ?? null)}, expected ${JSON.stringify(rec.redirect_uri)})`);
449
531
  return;
450
532
  }
533
+ // RFC 8707 (#329): if the client repeats `resource` at the token endpoint it
534
+ // must be the one bound at authorize. Mismatch → invalid_target, refused via
535
+ // denyToken so it is logged. A token request with NO resource stays
536
+ // acceptable (Claude compatibility), even when one was bound. Only the HOSTS
537
+ // are logged — the resource URI is not a secret but nothing bearer-shaped is.
538
+ if (resource !== undefined && String(resource) !== "") {
539
+ const requested = normalizeResource(resource);
540
+ if (!requested || requested !== rec.resource) {
541
+ denyToken(
542
+ res,
543
+ "invalid_target",
544
+ "resource does not match the authorize request",
545
+ `(resource_host ${hostOf(resource)} vs bound ${hostOf(rec.resource)})`,
546
+ );
547
+ return;
548
+ }
549
+ }
451
550
  const challenge = b64url(createHash("sha256").update(String(code_verifier ?? "")).digest());
452
551
  if (challenge !== rec.code_challenge) {
453
552
  denyToken(res, "invalid_grant", code_verifier === undefined ? "code_verifier is required (PKCE)" : "PKCE verification failed");
@@ -2382,15 +2382,16 @@ export async function runPlug(ctx, input, deps = {}) {
2382
2382
  // /grids/<slug> form 404s; /home?grid= does not. Falls back to the root when
2383
2383
  // the slug is unknown.
2384
2384
  //
2385
- // NOT on the building branch: the line says "view it live", and a still-building
2386
- // app is not live — emitting it two lines under "Do NOT tell the user it is live"
2387
- // would hand a model the word "live" next to a pending app. For an async runtime
2388
- // build the console line arrives when liveness is confirmed, in runCheckDeploy's
2389
- // success branch.
2385
+ // NOT on the building branch. This line invites the user to "see and manage all
2386
+ // their apps in their grid" — an invitation that presumes the app is live. A
2387
+ // still-building app is not, and it sits two lines under "Do NOT tell the user it
2388
+ // is live", so relaying a manage-your-apps prompt there contradicts the honesty
2389
+ // gate. For an async runtime build the console line arrives once liveness is
2390
+ // confirmed, in runCheckDeploy's success branch.
2390
2391
  if (data.entity_id && !isBuilding) {
2391
2392
  const consoleUrl = consoleGridUrl(data.grid ?? grid ?? null);
2392
2393
  structured.console_url = consoleUrl;
2393
- lines.push(`You can view it live in your grid along with all the apps you build here: ${consoleUrl}`);
2394
+ lines.push(`Give the user this link so they can see and manage all their apps in their grid: ${consoleUrl}`);
2394
2395
  }
2395
2396
 
2396
2397
  // Visibility is the user's choice — never set silently. On a NEW deploy,
@@ -2881,9 +2882,10 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
2881
2882
  return {
2882
2883
  // The "now live" moment for an async runtime build lands here, not in
2883
2884
  // runPlug (which returned "building"). Point the user at their grid too
2884
- // (#321) — this is where liveness is confirmed, so "view it live" is true.
2885
+ // (#321) — this is where liveness is confirmed, so the manage-your-apps
2886
+ // invitation is honest to make.
2885
2887
  text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
2886
- `You can view it live in your grid along with all the apps you build here: ${consoleGridUrl(gridSlug)}`,
2888
+ `Give the user this link so they can see and manage all their apps in their grid: ${consoleGridUrl(gridSlug)}`,
2887
2889
  structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: consoleGridUrl(gridSlug) },
2888
2890
  };
2889
2891
  }
@@ -865,7 +865,7 @@ export function registerTools(server, ctx) {
865
865
  status: z.string().describe("The build status: success | failed | building | queued | unknown."),
866
866
  live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
867
867
  url: z.string().optional().describe("The live URL, when known."),
868
- console_url: z.string().optional().describe("On success: URL to view the app live in the grid alongside all the user's apps."),
868
+ console_url: z.string().optional().describe("On success: a link to see and manage all the apps in the grid, in the console."),
869
869
  error: z.string().optional().describe("User-language failure reason, when the build failed."),
870
870
  build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
871
871
  suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),