@cloudgrid-io/mcp 0.21.14 → 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/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
- 38 tools registered (16 shared across both editions, 22 local-only). No deprecated aliases. MIN_CLI_VERSION: 0.15.26.
87
+ 38 tools registered (17 shared across both editions, 21 local-only). No deprecated aliases. MIN_CLI_VERSION: 0.15.26.
88
88
 
89
89
  ### Direct-API tools (both editions)
90
90
 
@@ -106,6 +106,7 @@ posted). `CLOUDGRID_QA_IDLE_MS` overrides the abandoned-idle window (default
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
108
  | `grid_hello` | API | Create a minimal hello page and plug it into the user's grid, returning the live URL. |
109
+ | `grid_delete` | `grid delete entity` | Archive a CloudGrid inspiration. |
109
110
 
110
111
  The direct-API tools call the platform without the CLI, so they also work in
111
112
  the web edition. `grid_login` writes the same `~/.cloudgrid/credentials` the
@@ -129,7 +130,6 @@ CLI uses, so the two share one identity.
129
130
  | `grid_edit_existing_app` | `grid pull` | Continue/edit an EXISTING entity locally: download its source + cloudgrid.yaml and link the folder so your next `grid... |
130
131
  | `grid_rename` | `grid rename` | Rename a CloudGrid entity's display name (slug stays the same). |
131
132
  | `grid_take_offline` | `grid unplug` | Take an entity off the grid. |
132
- | `grid_delete` | `grid delete entity` | Archive a CloudGrid inspiration. |
133
133
  | `grid_rollback_deploy` | `grid rollback` | Rollback an entity to a previous version. |
134
134
  | `grid_list_versions` | `grid versions` | List published versions for an entity. |
135
135
  | `grid_set_env` | `grid env` | Manage environment variables for an entity. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.21.14",
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": {
@@ -58,6 +58,7 @@
58
58
  "test:widgets": "node test/apps-widgets.test.mjs",
59
59
  "test:set-sharing": "node test/set-sharing.test.mjs",
60
60
  "test:check-deploy": "node test/check-deploy.test.mjs",
61
+ "test:console-grid-url": "node test/console-grid-url.test.mjs",
61
62
  "test:staleness": "node test/staleness.test.mjs",
62
63
  "test:proxy": "node test/proxy.test.mjs",
63
64
  "test:system-ca": "node test/system-ca.test.mjs",
@@ -73,11 +74,14 @@
73
74
  "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
74
75
  "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
75
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",
76
78
  "test:oauth-observability": "node --test test/oauth-flow-observability.test.mjs",
77
79
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
80
+ "test:mcp-transport-observability": "node --test test/mcp-transport-observability.test.mjs",
78
81
  "test:internal-refs": "node --test test/no-internal-refs.test.mjs",
79
82
  "test:login-app": "node test/login-app.test.mjs",
80
83
  "test:login-poller": "node test/login-poller.test.mjs",
84
+ "test:delete-hosted": "node test/delete-hosted.test.mjs",
81
85
  "build:login-widget": "node scripts/build-login-widget.mjs"
82
86
  },
83
87
  "dependencies": {
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");
@@ -29,6 +29,16 @@ export const ANON_HTML_MAX_BYTES = 2_000_000;
29
29
  export const AUTHED_HTML_MAX_BYTES = 25_000_000;
30
30
  export const CONSOLE_URL = "https://console.cloudgrid.io/";
31
31
 
32
+ // The console link handed to a user AFTER a plug. When the grid slug is known it
33
+ // points at THAT grid (verified live: /home?grid=<slug> -> 200) instead of the
34
+ // bare root, so a user with more than one grid lands on the one they just
35
+ // plugged to (#355). Falls back to the root when the slug is unknown. The
36
+ // earlier code hardcoded the root on the belief that "there is no per-grid page"
37
+ // — that was wrong; only the /grids/<slug> form 404s, /home?grid= does not.
38
+ export function consoleGridUrl(slug) {
39
+ return slug ? `${CONSOLE_URL}home?grid=${encodeURIComponent(slug)}` : CONSOLE_URL;
40
+ }
41
+
32
42
  // Display labels for visibility values (two-axis model). The OPTIONS list is what
33
43
  // the post-plug ask offers; the LABELS map also keeps legacy keys (org,
34
44
  // authenticated, space) so an entity's CURRENT stored value still renders — do
@@ -11,7 +11,7 @@ import {
11
11
  MCP_VERSION,
12
12
  ANON_HTML_MAX_BYTES,
13
13
  AUTHED_HTML_MAX_BYTES,
14
- CONSOLE_URL,
14
+ consoleGridUrl,
15
15
  VISIBILITY_LABELS,
16
16
  VISIBILITY_OPTIONS,
17
17
  APPS_WIDGETS_ENABLED,
@@ -71,30 +71,31 @@ function isGridNotAccessibleCode(code) {
71
71
  // answer identically today, so moving now is a no-op that stops this tool
72
72
  // breaking when `/orgs` goes.
73
73
  export async function fetchUserOrgs(token) {
74
- try {
75
- const res = await fetch(`${API_BASE}/api/v2/grids`, {
76
- headers: { Authorization: `Bearer ${token}` },
77
- });
78
- if (!res.ok) return [];
79
- const data = await res.json();
80
- // 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
81
- // `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
82
- const grids = Array.isArray(data?.grids)
83
- ? data.grids
84
- : Array.isArray(data?.orgs)
85
- ? data.orgs
86
- : Array.isArray(data)
87
- ? data
88
- : [];
89
- return grids.map((o) => ({
90
- slug: o.slug ?? "",
91
- name: o.name ?? o.slug ?? "",
92
- role: o.role ?? "member",
93
- render_ready: o.render_ready ?? true, // default true for older APIs
94
- }));
95
- } catch {
96
- return [];
74
+ const res = await fetch(`${API_BASE}/api/v2/grids`, {
75
+ headers: { Authorization: `Bearer ${token}` },
76
+ });
77
+ if (res.status === 401) {
78
+ const err = new Error(`Grid listing failed (HTTP 401)`);
79
+ err.status = 401;
80
+ throw err;
97
81
  }
82
+ if (!res.ok) throw new Error(`Grid listing failed (HTTP ${res.status})`);
83
+ const data = await res.json();
84
+ // 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
85
+ // `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
86
+ const grids = Array.isArray(data?.grids)
87
+ ? data.grids
88
+ : Array.isArray(data?.orgs)
89
+ ? data.orgs
90
+ : Array.isArray(data)
91
+ ? data
92
+ : [];
93
+ return grids.map((o) => ({
94
+ slug: o.slug ?? "",
95
+ name: o.name ?? o.slug ?? "",
96
+ role: o.role ?? "member",
97
+ render_ready: o.render_ready ?? true, // default true for older APIs
98
+ }));
98
99
  }
99
100
 
100
101
  // ── Shared grid disambiguation (grid_plug) ──────────────────
@@ -104,7 +105,8 @@ export async function fetchUserOrgs(token) {
104
105
  // - >1 grid and none supplied → { picker } (a ready-to-return result)
105
106
  // - exactly one grid → { single: annotatedOrg } — the caller
106
107
  // decides how to treat a not-ready single grid (drop blocks; plug warns)
107
- // - no orgs / listing failed → { picker } needs_grid_create (asks)
108
+ // - no orgs → { picker } needs_grid_create (asks)
109
+ // - listing failed → { picker } { error: true } (asks; NOT needs_grid_create)
108
110
  // Every return shape either ASKS (a picker) or names an explicit grid — none is a
109
111
  // silent proceed. That is load-bearing: register.js returns the picker or sets
110
112
  // input.grid from `grid`/`single` before runPlug, so the create path never
@@ -119,7 +121,26 @@ export async function fetchUserOrgs(token) {
119
121
  // dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
120
122
  export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
121
123
  const listGrids = deps.fetchUserOrgs || fetchUserOrgs;
122
- const grids = await listGrids(token);
124
+ let grids;
125
+ try {
126
+ grids = await listGrids(token);
127
+ } catch (err) {
128
+ if (err.status === 401) {
129
+ ctx.markTokenRevoked?.(token);
130
+ return {
131
+ picker: {
132
+ text: "Session expired — please log in again.",
133
+ structured: { error: true },
134
+ },
135
+ };
136
+ }
137
+ return {
138
+ picker: {
139
+ text: "Could not fetch your grids — please try again.",
140
+ structured: { error: true },
141
+ },
142
+ };
143
+ }
123
144
  const activeGrid = await ctx.getActiveGrid();
124
145
  const matched = suppliedGrid && grids.find((o) => o.slug === suppliedGrid);
125
146
  if (matched) {
@@ -1017,6 +1038,80 @@ export async function runCollab(ctx, { entity_id, grid } = {}) {
1017
1038
  };
1018
1039
  }
1019
1040
 
1041
+ // ── grid_delete (hosted edition) — archive an inspiration via the API (#343) ──
1042
+ // The handler in register.js resolves auth + grid (via resolveGridOrAsk) before
1043
+ // calling this function, so token and grid are always present.
1044
+ export async function runDelete(ctx, { name, grid, confirm } = {}) {
1045
+ if (confirm !== true) {
1046
+ throw new Error("`confirm` must be true to proceed with deletion.");
1047
+ }
1048
+ const token = await ctx.getToken();
1049
+ if (!token) {
1050
+ throw new Error("You are not signed in. Run grid_login first.");
1051
+ }
1052
+ if (!name) {
1053
+ throw new Error("`name` (entity slug) is required.");
1054
+ }
1055
+ if (!grid) {
1056
+ throw new Error("`grid` is required — the handler must resolve it before calling runDelete.");
1057
+ }
1058
+
1059
+ const headers = {
1060
+ Authorization: `Bearer ${token}`,
1061
+ "X-CloudGrid-Grid": grid,
1062
+ "X-CloudGrid-Org": grid,
1063
+ };
1064
+
1065
+ // Resolve the entity to get its id and kind.
1066
+ const lookupUrl = `${API_BASE}/api/v2/inspirations/${encodeURIComponent(name)}`;
1067
+ let lookupRes;
1068
+ try {
1069
+ lookupRes = await fetch(lookupUrl, { headers });
1070
+ } catch (err) {
1071
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1072
+ }
1073
+
1074
+ if (!lookupRes.ok) {
1075
+ const raw = await lookupRes.text();
1076
+ let data = null;
1077
+ try { data = JSON.parse(raw); } catch { /* */ }
1078
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${lookupRes.status}`;
1079
+ if (lookupRes.status === 404) {
1080
+ throw new Error(`No inspiration found for '${name}'. Check the slug and grid.`);
1081
+ }
1082
+ throw new Error(`Lookup failed (HTTP ${lookupRes.status}): ${msg}`);
1083
+ }
1084
+
1085
+ const entity = await lookupRes.json();
1086
+ // GET /api/v2/inspirations/:slug returns `id`; defensive fallback for `entity_id`.
1087
+ const entityId = entity?.id || entity?.entity_id;
1088
+ if (!entityId) {
1089
+ throw new Error(`Could not resolve entity id for '${name}'.`);
1090
+ }
1091
+
1092
+ // Archive the inspiration.
1093
+ const deleteUrl = `${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`;
1094
+ let delRes;
1095
+ try {
1096
+ delRes = await fetch(deleteUrl, { method: "DELETE", headers });
1097
+ } catch (err) {
1098
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1099
+ }
1100
+
1101
+ if (!delRes.ok) {
1102
+ const raw = await delRes.text();
1103
+ let data = null;
1104
+ try { data = JSON.parse(raw); } catch { /* */ }
1105
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${delRes.status}`;
1106
+ throw new Error(`Delete failed (HTTP ${delRes.status}): ${msg}`);
1107
+ }
1108
+
1109
+ return {
1110
+ text: `Deleted '${name}' (${entityId}) from ${grid}.`,
1111
+ structured: { deleted: true, entity_id: entityId, slug: name, grid },
1112
+ };
1113
+ }
1114
+
1020
1115
  // Turn a policy 403 into a request: POST /:id/collab-requests and tell the user
1021
1116
  // what happens next (mirrors CLI collab-requests.ts requestCollabAccess). A
1022
1117
  // request IS a success outcome, so this returns a normal result, never throws for
@@ -1038,7 +1133,7 @@ async function requestCollabAccess(ctx, target, headers) {
1038
1133
  return {
1039
1134
  text:
1040
1135
  `The owner gates who can collaborate on '${target}', so I asked them for push access on your behalf. ` +
1041
- `They've been notified — once they approve, run grid_collab again to join, then grid_pull to get the code.`,
1136
+ `They've been notified — once they approve, access is live immediately. Then run grid_pull to get the code.`,
1042
1137
  structured: { access_requested: true, request_pending: true, owner_is_you: false, can_edit: false },
1043
1138
  };
1044
1139
  }
@@ -1483,6 +1578,9 @@ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
1483
1578
  if (status === 400 && isGridHeaderError(code, msg)) {
1484
1579
  return `Plug failed (HTTP 400): ${rewriteGridHeaderError(msg)}`;
1485
1580
  }
1581
+ if (status === 400 && /cloudgrid\.yaml.*required/i.test(msg)) {
1582
+ return `Plug failed (HTTP 400): no cloudgrid.yaml found — provide it via the \`cloudgrid_yaml\` parameter or include a \`cloudgrid.yaml\` entry in \`artifact_files\`. Server: ${msg}`;
1583
+ }
1486
1584
  const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
1487
1585
  const guidance = errorGuidance({ status, code, ...ctxFlags });
1488
1586
  return guidance ? `${base} — ${guidance}` : base;
@@ -1895,20 +1993,24 @@ export async function runPlug(ctx, input, deps = {}) {
1895
1993
  }
1896
1994
  if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1897
1995
 
1996
+ // ── Resolve manifest from all sources ────────────────────────────────────────
1997
+ // The explicit `cloudgrid_yaml` param takes precedence; fall back to a
1998
+ // `cloudgrid.yaml` entry in `artifact_files`. This resolves the asymmetry
1999
+ // where create honoured artifact_files but re-plug ignored it (#314).
2000
+ const resolvedManifestYaml = cloudgrid_yaml
2001
+ || (hasArtifacts && artifact_files.find((f) => f?.path === "cloudgrid.yaml")?.content)
2002
+ || null;
2003
+
1898
2004
  // ── CREATE manifest injection (issue #48) ───────────────────────────────────
1899
- // On a folder-walk create, a `cloudgrid.yaml` on disk is walked into the tree
1900
- // and — because directory reads surface it before the nested `services/…`
1901
- // files — rides the multipart body as the FIRST `artifact` part, with the
1902
- // walk's uniform `application/octet-stream` content-type. The runtime build
1903
- // orchestrator relies on the manifest leading the bundle (it drives the
1904
- // service graph + the entity name). The inline `artifact_files` create used to
1905
- // APPEND the `cloudgrid_yaml` manifest LAST and as `text/plain`, so a
2005
+ // The runtime build orchestrator relies on the manifest leading the bundle
2006
+ // (it drives the service graph + the entity name). The inline `artifact_files`
2007
+ // create used to APPEND the manifest LAST and as `text/plain`, so a
1906
2008
  // multi-service runtime rolled out with no service graph (0 replicas /
1907
2009
  // rollout_failed) and an auto `drop-XXXX` name. Fold the manifest into the
1908
2010
  // artifact list as the first entry (deduping any `cloudgrid.yaml` the caller
1909
2011
  // already inlined) so both create paths emit a byte-equivalent bundle.
1910
- if (!isEdit && cloudgrid_yaml) {
1911
- const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(cloudgrid_yaml, "utf8") };
2012
+ if (!isEdit && resolvedManifestYaml) {
2013
+ const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(resolvedManifestYaml, "utf8") };
1912
2014
  const rest = artifacts.filter((a) => a.path !== "cloudgrid.yaml");
1913
2015
  artifacts = [manifest, ...rest];
1914
2016
  }
@@ -1933,7 +2035,7 @@ export async function runPlug(ctx, input, deps = {}) {
1933
2035
  // (materializePlugTarball); an inspiration edit ignores its content.
1934
2036
  form.append(
1935
2037
  "cloudgrid.yaml",
1936
- new Blob([cloudgrid_yaml || ""], { type: "text/plain" }),
2038
+ new Blob([resolvedManifestYaml || ""], { type: "text/plain" }),
1937
2039
  "cloudgrid.yaml",
1938
2040
  );
1939
2041
  }
@@ -1943,7 +2045,7 @@ export async function runPlug(ctx, input, deps = {}) {
1943
2045
  // auto `drop-XXXX` slug. Harmless if the server owns slug generation; on a
1944
2046
  // re-plug the entity's name is authoritative, so only send on create.
1945
2047
  if (!isEdit) {
1946
- const manifestName = parseManifestName(cloudgrid_yaml);
2048
+ const manifestName = parseManifestName(resolvedManifestYaml);
1947
2049
  if (manifestName) {
1948
2050
  form.append("name", manifestName);
1949
2051
  form.append("slug", manifestName);
@@ -2057,6 +2159,9 @@ export async function runPlug(ctx, input, deps = {}) {
2057
2159
  ) {
2058
2160
  return plugViaCliFallback(ctx, artifacts, deps);
2059
2161
  }
2162
+ if (res.status === 401 && authToken && !useAnonWire) {
2163
+ ctx.markTokenRevoked?.(authToken);
2164
+ }
2060
2165
  throw new Error(plugErrorMessage(res.status, code, msg, flags));
2061
2166
  }
2062
2167
 
@@ -2271,17 +2376,22 @@ export async function runPlug(ctx, input, deps = {}) {
2271
2376
  // or re-plug (#321). The app URL says where the app lives; the console line says
2272
2377
  // where the grid lives, so the common follow-up interaction (an edit) does not
2273
2378
  // end with a bare URL and no way back. The console root self-routes per account
2274
- // state (sign-in wall → their grid → onboarding); there is no per-grid page to
2275
- // link, so keep the root — a `/grids/<slug>` path 404s today.
2379
+ // state (sign-in wall → their grid → onboarding). Point at the SPECIFIC grid
2380
+ // when we know its slug — /home?grid=<slug> renders (verified), so a user with
2381
+ // several grids lands on the one they just plugged to (#355). Only the
2382
+ // /grids/<slug> form 404s; /home?grid= does not. Falls back to the root when
2383
+ // the slug is unknown.
2276
2384
  //
2277
- // NOT on the building branch: the line says "view it live", and a still-building
2278
- // app is not live — emitting it two lines under "Do NOT tell the user it is live"
2279
- // would hand a model the word "live" next to a pending app. For an async runtime
2280
- // build the console line arrives when liveness is confirmed, in runCheckDeploy's
2281
- // 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.
2282
2391
  if (data.entity_id && !isBuilding) {
2283
- structured.console_url = CONSOLE_URL;
2284
- lines.push(`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`);
2392
+ const consoleUrl = consoleGridUrl(data.grid ?? grid ?? null);
2393
+ structured.console_url = consoleUrl;
2394
+ lines.push(`Give the user this link so they can see and manage all their apps in their grid: ${consoleUrl}`);
2285
2395
  }
2286
2396
 
2287
2397
  // Visibility is the user's choice — never set silently. On a NEW deploy,
@@ -2772,10 +2882,11 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
2772
2882
  return {
2773
2883
  // The "now live" moment for an async runtime build lands here, not in
2774
2884
  // runPlug (which returned "building"). Point the user at their grid too
2775
- // (#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.
2776
2887
  text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
2777
- `You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`,
2778
- structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: CONSOLE_URL },
2888
+ `Give the user this link so they can see and manage all their apps in their grid: ${consoleGridUrl(gridSlug)}`,
2889
+ structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: consoleGridUrl(gridSlug) },
2779
2890
  };
2780
2891
  }
2781
2892
  if (verdict.status === "failed") {
@@ -25,6 +25,7 @@ import {
25
25
  runReport,
26
26
  runPull,
27
27
  runCollab,
28
+ runDelete,
28
29
  runPlug,
29
30
  runPickup,
30
31
  runCreateGrid,
@@ -273,7 +274,7 @@ export function registerTools(server, ctx) {
273
274
  "grid_collab",
274
275
  {
275
276
  title: "Get push access to an app you don't own",
276
- description: "Collab: GET PUSH ACCESS to the SAME live entity that someone else owns — you become a collaborator on THAT entity, not a copy. This grants PERMISSION ONLY and fetches nothing: after it succeeds, run grid_pull to get the code, then grid_plug (with its target_entity_id) updates the SHARED entity in place (the team sees the new version and can roll it back). This is NOT a fork — it never mints a new entity and never carries forked_from lineage; if you want your OWN separate copy instead, that is grid_pickup. If the owner GATES who may join, this does NOT dead-end: it sends the owner a request for access on your behalf, and once they approve you run grid_collab again to join. Use this — not grid_pickup — whenever the user asks to \"collab\" on, \"join\", or \"get push/write access to\" an app they don't own. Requires sign-in. Calls the API directly (both editions).",
277
+ description: "Collab: GET PUSH ACCESS to the SAME live entity that someone else owns — you become a collaborator on THAT entity, not a copy. This grants PERMISSION ONLY and fetches nothing: after it succeeds, run grid_pull to get the code, then grid_plug (with its target_entity_id) updates the SHARED entity in place (the team sees the new version and can roll it back). This is NOT a fork — it never mints a new entity and never carries forked_from lineage; if you want your OWN separate copy instead, that is grid_pickup. If the owner GATES who may join, this does NOT dead-end: it sends the owner a request for access on your behalf, and once they approve, access is live immediately — no need to re-run grid_collab. Use this — not grid_pickup — whenever the user asks to \"collab\" on, \"join\", or \"get push/write access to\" an app they don't own. Requires sign-in. Calls the API directly (both editions).",
277
278
  inputSchema: {
278
279
  entity_id: z.string().describe("The app to get push access to: a canonical UUID or <grid-slug>/<entity-slug>."),
279
280
  grid: z.string().optional().describe("Grid to resolve a bare slug in. Required only when a bare slug is ambiguous across grids you belong to."),
@@ -286,7 +287,7 @@ export function registerTools(server, ctx) {
286
287
  owner_is_you: z.boolean().optional().describe("True if you already own it (nothing to grant)."),
287
288
  can_edit: z.boolean().optional().describe("True once you have push access (owner or collaborator). False = still view-only."),
288
289
  access_requested: z.boolean().optional().describe("True when the owner gates access and a request was sent on your behalf."),
289
- request_pending: z.boolean().optional().describe("True when a request is awaiting the owner's decision — join with grid_collab again once approved."),
290
+ request_pending: z.boolean().optional().describe("True when a request is awaiting the owner's decision — access is live as soon as they approve (no re-run needed)."),
290
291
  },
291
292
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
292
293
  },
@@ -501,7 +502,7 @@ export function registerTools(server, ctx) {
501
502
  claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
502
503
  claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
503
504
  owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
504
- console_url: z.string().optional().describe("Web authed inspiration create: URL to manage all apps in the grid."),
505
+ console_url: z.string().optional().describe("Emitted on every non-building plug (both editions): a link to the grid in the console."),
505
506
  current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
506
507
  visibility_options: z.array(z.object({
507
508
  value: z.string().describe("Visibility value to pass to grid_visibility."),
@@ -531,6 +532,7 @@ export function registerTools(server, ctx) {
531
532
  })).optional().describe("Alias of grids (legacy picker alias)."),
532
533
  via: z.string().optional().describe("Recovery marker: 'cli-fallback' when a signed-in create was published through the bundled CloudGrid CLI."),
533
534
  needs_grid_create: z.boolean().optional().describe("Zero-grid ask: the account has no grid yet — suggest a slug, confirm with the user, call grid_create_grid, then re-call grid_plug with grid: <slug>."),
535
+ error: z.boolean().optional().describe("True when the grid listing failed (network/auth error). The text carries a human-readable message. Do NOT treat this as needs_grid_create."),
534
536
  },
535
537
  // destructiveHint (M3): a re-plug with target_entity_id REPLACES what is
536
538
  // live at a public URL in place. Versions + grid rollback exist, but the
@@ -863,7 +865,7 @@ export function registerTools(server, ctx) {
863
865
  status: z.string().describe("The build status: success | failed | building | queued | unknown."),
864
866
  live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
865
867
  url: z.string().optional().describe("The live URL, when known."),
866
- 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."),
867
869
  error: z.string().optional().describe("User-language failure reason, when the build failed."),
868
870
  build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
869
871
  suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),
@@ -903,7 +905,12 @@ export function registerTools(server, ctx) {
903
905
  if (!token) {
904
906
  return fail("You are not signed in. Run grid_login first.");
905
907
  }
906
- const grids = await fetchUserOrgs(token);
908
+ let grids;
909
+ try {
910
+ grids = await fetchUserOrgs(token);
911
+ } catch {
912
+ return fail("Could not list your grids — please check your connection and try again.");
913
+ }
907
914
  if (grids.length === 0) {
908
915
  // Structured output stays `orgs` (its declared schema); user text says grid.
909
916
  return okResult({ text: "No grids found.", structured: { orgs: [] } });
@@ -1184,6 +1191,7 @@ export function registerTools(server, ctx) {
1184
1191
  entity_id: z.string().optional().describe("Re-plug handle."),
1185
1192
  needs_auth: z.boolean().optional().describe("True when the user must sign in first."),
1186
1193
  needs_grid: z.boolean().optional().describe("True when the user must choose a grid."),
1194
+ error: z.boolean().optional().describe("True when the grid listing failed (network/auth error). The text carries a human-readable message. Do NOT treat this as needs_grid_create."),
1187
1195
  },
1188
1196
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1189
1197
  },
@@ -1238,6 +1246,66 @@ export function registerTools(server, ctx) {
1238
1246
  },
1239
1247
  );
1240
1248
 
1249
+ // grid_delete (hosted edition) — archive an inspiration via the direct API
1250
+ // (#343). The local edition has a CLI-wrapping version below the edition gate;
1251
+ // this direct-API version runs on web (hosted) where the CLI is unavailable.
1252
+ if (ctx.edition !== "local") {
1253
+ reg(
1254
+ "grid_delete",
1255
+ {
1256
+ title: "Delete an inspiration",
1257
+ description:
1258
+ "Archive a CloudGrid inspiration by slug. Destructive — requires `confirm: true`. " +
1259
+ "If the user has more than one grid and `grid` is not given, this returns needs_grid with " +
1260
+ "the list — ASK the user which grid, then call again with `grid` set. Never guess the grid. " +
1261
+ "Only works for inspirations (static pages); runtime apps and agents must be removed " +
1262
+ "with `grid unplug --hard` from the CLI. Requires sign-in. Calls the API directly.",
1263
+ inputSchema: {
1264
+ name: z.string().describe("Entity slug to delete (required)."),
1265
+ grid: z.string().optional().describe("Grid slug to delete from. Omit on the first call to be asked."),
1266
+ confirm: z.literal(true).describe("Must be true to proceed."),
1267
+ },
1268
+ outputSchema: {
1269
+ deleted: z.boolean().describe("True when the entity was archived."),
1270
+ entity_id: z.string().optional().describe("The archived entity's id."),
1271
+ slug: z.string().optional().describe("The archived entity's slug."),
1272
+ grid: z.string().optional().describe("The grid the entity was deleted from."),
1273
+ needs_auth: z.boolean().optional().describe("True when the user must sign in first."),
1274
+ needs_grid: z.boolean().optional().describe("True when the user must choose a grid."),
1275
+ error: z.boolean().optional().describe("True when the grid listing failed (network/auth error)."),
1276
+ },
1277
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1278
+ },
1279
+ async (input) => {
1280
+ try {
1281
+ // 1. AUTH
1282
+ const token = await ctx.getToken();
1283
+ if (!token) {
1284
+ return okResult({
1285
+ text: "Deleting an inspiration needs an account. Call grid_login to sign in, then re-call grid_delete.",
1286
+ structured: { needs_auth: true },
1287
+ });
1288
+ }
1289
+
1290
+ // 2. GRID — never fall back to the active grid on a destructive op (#343).
1291
+ const decision = await resolveGridOrAsk(ctx, {
1292
+ token,
1293
+ suppliedGrid: input?.grid,
1294
+ edition: ctx.edition,
1295
+ });
1296
+ if (decision.picker) return okResult(decision.picker);
1297
+ const grid = decision.grid || decision.single?.slug;
1298
+ if (!grid) return fail("Could not resolve a grid. Pass `grid` explicitly.");
1299
+
1300
+ // 3. DELETE
1301
+ return okResult(await runDelete(ctx, { ...input, grid }));
1302
+ } catch (err) {
1303
+ return fail(err.message);
1304
+ }
1305
+ },
1306
+ );
1307
+ }
1308
+
1241
1309
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
1242
1310
 
1243
1311
  // ── CLI-wrapping tools (local edition only) ───────────────────────────────
@@ -25,14 +25,20 @@ export function createWebIdentity({
25
25
  let transportToken = initialTransportToken;
26
26
  let explicitToken = null;
27
27
  let hasExplicitLogin = false;
28
+ const revokedTokens = new Set();
28
29
 
29
30
  // An explicit login wins over transport-token refreshes for the same subject.
30
31
  // A genuine transport subject change ends that override so credentials from
31
32
  // the previous person cannot persist on a connection now owned by another.
32
- function effectiveToken() {
33
+ function rawEffectiveToken() {
33
34
  return hasExplicitLogin ? explicitToken : transportToken;
34
35
  }
35
36
 
37
+ function effectiveToken() {
38
+ const tok = rawEffectiveToken();
39
+ return revokedTokens.has(tok) ? null : tok;
40
+ }
41
+
36
42
  return {
37
43
  captureTransportToken(jwt) {
38
44
  const previousSub = decodeJwt(transportToken).sub;
@@ -62,7 +68,11 @@ export function createWebIdentity({
62
68
 
63
69
  async getCredentialsStatus() {
64
70
  const jwt = effectiveToken();
65
- if (!jwt) return { creds: null, expired: false };
71
+ if (!jwt) {
72
+ // A revoked token is surfaced as expired so the client triggers re-auth.
73
+ const revoked = revokedTokens.has(rawEffectiveToken());
74
+ return { creds: null, expired: revoked || isTokenExpired(rawEffectiveToken(), now) };
75
+ }
66
76
  if (isTokenExpired(jwt, now)) return { creds: null, expired: true };
67
77
  return { creds: { jwt }, expired: false };
68
78
  },
@@ -72,5 +82,9 @@ export function createWebIdentity({
72
82
  hasExplicitLogin = true;
73
83
  return decodeJwt(jwt);
74
84
  },
85
+
86
+ markRevoked(jwt) {
87
+ if (jwt) revokedTokens.add(jwt);
88
+ },
75
89
  };
76
90
  }
@@ -0,0 +1,120 @@
1
+ // Observability for POST /mcp on the hosted edge (#353). Makes the four transport
2
+ // exits visible in the log — the 401 auth challenge, both 400 branches, and a
3
+ // successful session — so a client that completes OAuth and then fails to
4
+ // establish a session (#329) can be diagnosed instead of vanishing silently.
5
+ //
6
+ // Redaction — same standard as #347. Never log a bearer token, any fragment of
7
+ // it, or its length in a way that narrows it: log PRESENCE and PARSE RESULT only
8
+ // (see authHeaderState). Client-supplied text (clientInfo.name, the presented
9
+ // mcp-session-id) is untrusted — logSafe bounds its length and flattens control
10
+ // characters so a client cannot forge a log line or break log-line parsing.
11
+ //
12
+ // Scope note (mirroring #347's explicit statement): these helpers never emit the
13
+ // bearer or the mcp-session-id header value except as a bounded, flattened
14
+ // echo of the client-presented id on the rehydrate-failed path — an
15
+ // already-public session identifier, not a credential.
16
+
17
+ function defaultEmit(msg) {
18
+ // eslint-disable-next-line no-console
19
+ console.error(msg);
20
+ }
21
+
22
+ // Bound and flatten client-supplied text so it cannot forge a log line (strip
23
+ // CR/LF and other control chars) or flood one (cap length). Presence/shape only.
24
+ export function logSafe(value, max = 64) {
25
+ const s = String(value ?? "");
26
+ const flat = s.replace(/[\u0000-\u001f\u007f]/g, " ");
27
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
28
+ }
29
+
30
+ // Classify the Authorization header WITHOUT revealing the token. Distinguishing
31
+ // absent / not-bearer / empty-token is the entire diagnostic value of the 401
32
+ // (#353): it says why no usable Bearer was found, never what it was. The four
33
+ // categories:
34
+ // absent — no Authorization header at all
35
+ // not-bearer — a header of some OTHER scheme (e.g. "Basic …")
36
+ // empty-token — the Bearer scheme but no token ("Bearer", "Bearer ")
37
+ // bearer — the Bearer scheme with a token (never reaches the 401)
38
+ // Note (#353): an HTTP parser strips a header value's trailing whitespace, so a
39
+ // client's "Bearer " arrives as "Bearer" — the empty-token case. This mirrors
40
+ // bearerOf() in web.js, which yields a token only for "Bearer <non-space…>".
41
+ export function authHeaderState(req) {
42
+ const raw = req?.headers?.authorization;
43
+ const h = Array.isArray(raw) ? raw[0] : raw;
44
+ if (h == null || h === "") return "absent";
45
+ if (!/^Bearer\b/i.test(h)) return "not-bearer";
46
+ return /^Bearer\s+\S/i.test(h) ? "bearer" : "empty-token";
47
+ }
48
+
49
+ // Reduce an object to its KEY SHAPE — key names mapped to the TYPE of each value,
50
+ // recursively — never the values themselves. Used to log the client's advertised
51
+ // MCP capabilities at initialize (#297) while guaranteeing BY CONSTRUCTION that
52
+ // no value ever leaves the process: only key names and JS type names are emitted,
53
+ // so no token, header, tool argument, or user content can appear even if the
54
+ // object carried one. Depth- and breadth-capped so a malformed or huge object
55
+ // cannot blow the stack or flood the log.
56
+ export function keyShape(value, depth = 0) {
57
+ if (value === null || typeof value !== "object" || depth >= 6) return typeof value;
58
+ if (Array.isArray(value)) return `array[${value.length}]`;
59
+ const out = {};
60
+ for (const k of Object.keys(value).slice(0, 64)) out[k] = keyShape(value[k], depth + 1);
61
+ return out;
62
+ }
63
+
64
+ // The 401 auth challenge on /mcp (REQUIRE_AUTH && no usable Bearer).
65
+ export function logAuthChallenge(req, emit = defaultEmit) {
66
+ emit(`cloudgrid-mcp: /mcp 401 auth-challenge authorization=${authHeaderState(req)}`);
67
+ }
68
+
69
+ // The first 400 branch: a request with no session id that is not an initialize.
70
+ export function logNoSession(emit = defaultEmit) {
71
+ emit("cloudgrid-mcp: /mcp 400 no-session reason=missing-session-id-and-not-initialize");
72
+ }
73
+
74
+ // The second 400 branch: a stale-id rehydrate that failed to prime and fell back
75
+ // to the 400. session-id is the client-presented header — echoed, bounded, never
76
+ // a credential.
77
+ export function logRehydrateFailed(sessionId, emit = defaultEmit) {
78
+ emit(`cloudgrid-mcp: /mcp 400 rehydrate-failed session-id=${logSafe(sessionId)}`);
79
+ }
80
+
81
+ // The successful-session exit, emitted from the SDK's `oninitialized`. TWO lines
82
+ // with a DELIBERATE separation:
83
+ //
84
+ // 1. session-established — UNCONDITIONAL, emitted FIRST, outside any swallowing
85
+ // try/catch. The record that a session exists must never depend on the
86
+ // best-effort probe below. This is the fix for the #329 trap: the #297
87
+ // capability line is observation-only and could silently NOT fire on a good
88
+ // session, and its absence was mis-read as "no session existed". A separate
89
+ // always-emitted line removes that ambiguity.
90
+ // 2. client-capabilities — best-effort observation only (#297). A failure here
91
+ // is swallowed and MUST NOT suppress line 1 or affect the session. The
92
+ // synthetic rehydrate initialize is skipped so the capability signal stays
93
+ // clean (line 1 still fires for it — a rehydrate is a real session).
94
+ //
95
+ // `protocolVersion` is passed in (the SDK exposes no getter for the negotiated
96
+ // version post-init); web.js negotiates it with the same rule the SDK uses.
97
+ export function logInitialize(server, sessionId, protocolVersion, emit = defaultEmit) {
98
+ let clientName = "unknown";
99
+ try {
100
+ clientName = server?.getClientVersion?.()?.name ?? "unknown";
101
+ } catch {
102
+ /* name only — the session-established line below still fires */
103
+ }
104
+ emit(
105
+ `cloudgrid-mcp: session established session-id=${logSafe(sessionId)} ` +
106
+ `protocol=${logSafe(protocolVersion, 32)} client=${logSafe(clientName)}`,
107
+ );
108
+
109
+ try {
110
+ if (clientName !== "cloudgrid-mcp-rehydrate") {
111
+ const caps = server?.getClientCapabilities?.();
112
+ const shape = caps && typeof caps === "object" ? keyShape(caps) : null;
113
+ emit(
114
+ `cloudgrid-mcp: client-capabilities client=${logSafe(clientName)} capabilities=${JSON.stringify(shape)}`,
115
+ );
116
+ }
117
+ } catch {
118
+ /* observation-only — never affects the session or the line above */
119
+ }
120
+ }
package/src/web.js CHANGED
@@ -34,6 +34,12 @@ import { createSessionLogger } from "./session-logger.js";
34
34
  import { createSink } from "./log-sink.js";
35
35
  import { INSTRUCTIONS_WEB } from "./playbook.js";
36
36
  import { createWebIdentity } from "./web-identity.js";
37
+ import {
38
+ logAuthChallenge,
39
+ logNoSession,
40
+ logRehydrateFailed,
41
+ logInitialize,
42
+ } from "./web-observability.js";
37
43
 
38
44
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)));
39
45
 
@@ -57,19 +63,14 @@ function bearerOf(req) {
57
63
  return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
58
64
  }
59
65
 
60
- // Reduce an object to its KEY SHAPE — key names mapped to the TYPE of each value,
61
- // recursively — never the values themselves. Used to log the client's advertised
62
- // MCP capabilities at initialize (#297) while guaranteeing BY CONSTRUCTION that
63
- // no value ever leaves the process: only key names and JS type names are emitted,
64
- // so no token, header, tool argument, or user content can appear even if the
65
- // object carried one. Depth- and breadth-capped so a malformed or huge object
66
- // cannot blow the stack or flood the log.
67
- function keyShape(value, depth = 0) {
68
- if (value === null || typeof value !== "object" || depth >= 6) return typeof value;
69
- if (Array.isArray(value)) return `array[${value.length}]`;
70
- const out = {};
71
- for (const k of Object.keys(value).slice(0, 64)) out[k] = keyShape(value[k], depth + 1);
72
- return out;
66
+ // Negotiate the MCP protocol version with the same rule the SDK's _oninitialize
67
+ // uses (a supported requested version wins, else LATEST). Used only to report the
68
+ // negotiated version on the session-established log line (#353) — the SDK exposes
69
+ // no getter for it post-init — and to build the synthetic rehydrate initialize.
70
+ function negotiateProtocol(requested) {
71
+ return typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
72
+ ? requested
73
+ : LATEST_PROTOCOL_VERSION;
73
74
  }
74
75
 
75
76
  // A web session: identity lives in memory for the session's lifetime only. The
@@ -86,6 +87,7 @@ function makeWebContext(sessionId, identity) {
86
87
  getCredentialsStatus: identity.getCredentialsStatus,
87
88
  getActiveGrid: async () => null,
88
89
  saveToken: identity.saveToken,
90
+ markTokenRevoked(jwt) { identity.markRevoked(jwt); },
89
91
  savedLocationNote: () => "You are signed in for this session.",
90
92
  trustedServer: TRUSTED_SERVER_SECRET
91
93
  ? { secret: TRUSTED_SERVER_SECRET, endUserId: sessionId }
@@ -139,7 +141,7 @@ function captureRequestIdentity(sessionId, jwt) {
139
141
  // for a rehydrated (restart-evicted) session, the id the client keeps presenting.
140
142
  // Identity derives ONLY from the credential passed here (`jwt`), never from
141
143
  // anything remembered about the id: a session id is not proof of identity.
142
- async function buildSession(sessionId, jwt) {
144
+ async function buildSession(sessionId, jwt, protocolVersion) {
143
145
  const identity = createWebIdentity({ initialTransportToken: jwt });
144
146
  const transport = new StreamableHTTPServerTransport({
145
147
  sessionIdGenerator: () => sessionId,
@@ -181,28 +183,12 @@ async function buildSession(sessionId, jwt) {
181
183
  } catch {
182
184
  webCtx.state.client = null;
183
185
  }
184
- // #297: record the client's advertised MCP capabilities once per session, so
185
- // we can settle whether Claude web ever advertises a UI extension (e.g.
186
- // io.modelcontextprotocol/ui) — the plumbing a rendered login card would
187
- // need — instead of guessing while the directory submission sits unanswered.
188
- // Observation-only: only the capability KEY SHAPE (names + value types, never
189
- // values — see keyShape) is logged, so no credential or user content can
190
- // appear. Skip the synthetic rehydrate initialize so the signal stays clean.
191
- // Wrapped so a logging failure can never affect the session (zero behaviour
192
- // change if capabilities is absent or malformed).
193
- try {
194
- const client = server.server.getClientVersion() ?? null;
195
- if (client?.name !== "cloudgrid-mcp-rehydrate") {
196
- const caps = server.server.getClientCapabilities();
197
- const shape = caps && typeof caps === "object" ? keyShape(caps) : null;
198
- // eslint-disable-next-line no-console
199
- console.error(
200
- `cloudgrid-mcp: client-capabilities client=${client?.name ?? "unknown"} capabilities=${JSON.stringify(shape)}`,
201
- );
202
- }
203
- } catch {
204
- /* observation-only — never affects the session */
205
- }
186
+ // #353/#297: emit the session-established line UNCONDITIONALLY (id, protocol,
187
+ // client) and then the best-effort #297 capability key-shape line. The two
188
+ // are deliberately separate — see logInitialize: the fact that a session was
189
+ // created must not depend on the swallow-everything capability probe, whose
190
+ // silent non-firing on a good session sent the #329 diagnosis sideways.
191
+ logInitialize(server.server, sessionId, protocolVersion);
206
192
  };
207
193
  await server.connect(transport);
208
194
  return { transport, identity };
@@ -224,11 +210,7 @@ async function primeSession(transport, req) {
224
210
  if (!wst || typeof wst.handleRequest !== "function") {
225
211
  throw new Error("cannot rehydrate session: transport internals unavailable");
226
212
  }
227
- const proposed = req.headers["mcp-protocol-version"];
228
- const protocolVersion =
229
- typeof proposed === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(proposed)
230
- ? proposed
231
- : LATEST_PROTOCOL_VERSION;
213
+ const protocolVersion = negotiateProtocol(req.headers["mcp-protocol-version"]);
232
214
  const initBody = {
233
215
  jsonrpc: "2.0",
234
216
  id: "cg-rehydrate-init",
@@ -256,6 +238,9 @@ app.post("/mcp", async (req, res) => {
256
238
  const jwt = bearerOf(req);
257
239
 
258
240
  if (REQUIRE_AUTH && !jwt) {
241
+ // #353: name WHY no usable Bearer was found — absent / not-bearer / empty-token
242
+ // — without revealing the token. This is the whole diagnostic value of the 401.
243
+ logAuthChallenge(req);
259
244
  sendAuthChallenge(res);
260
245
  return;
261
246
  }
@@ -282,6 +267,8 @@ app.post("/mcp", async (req, res) => {
282
267
  // A brand-new connection with no session id that is NOT an initialize is a real
283
268
  // protocol error — there is nothing to recover.
284
269
  if (!sessionId && !isInit) {
270
+ // #353: 400 branch 1 — a brand-new connection that is not an initialize.
271
+ logNoSession();
285
272
  res.status(400).json({
286
273
  jsonrpc: "2.0",
287
274
  error: { code: -32000, message: "No valid session. Send an initialize request first." },
@@ -297,7 +284,7 @@ app.post("/mcp", async (req, res) => {
297
284
  // above), so an expired-but-present Bearer proceeds to the tool layer where
298
285
  // the in-band needs_auth ask lives, rather than being challenged.
299
286
  const newSessionId = randomUUID();
300
- ({ transport } = await buildSession(newSessionId, jwt));
287
+ ({ transport } = await buildSession(newSessionId, jwt, negotiateProtocol(req.body?.params?.protocolVersion)));
301
288
  await transport.handleRequest(req, res, req.body);
302
289
  return;
303
290
  }
@@ -317,7 +304,7 @@ app.post("/mcp", async (req, res) => {
317
304
  // stuck behind the same 401 (the #286 brick, on the rebuild path). Gating on
318
305
  // expiry (hasUsableCredential) here is exactly what #288 got wrong and was pulled
319
306
  // from production for.
320
- ({ transport } = await buildSession(sessionId, jwt));
307
+ ({ transport } = await buildSession(sessionId, jwt, negotiateProtocol(req.headers["mcp-protocol-version"])));
321
308
  // Rehydrate the evicted session. If priming fails (e.g. an SDK internal moved
322
309
  // out from under us), tear the half-built session down and fall back to the
323
310
  // original 400 rather than leaking a broken session or hanging the request —
@@ -325,6 +312,8 @@ app.post("/mcp", async (req, res) => {
325
312
  try {
326
313
  await primeSession(transport, req);
327
314
  } catch {
315
+ // #353: 400 branch 2 — a stale-id rehydrate that could not be primed.
316
+ logRehydrateFailed(sessionId);
328
317
  try { await transport.close(); } catch { /* best effort */ }
329
318
  delete transports[sessionId];
330
319
  delete sessionContexts[sessionId];