@cloudgrid-io/mcp 0.21.4 → 0.21.6

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.4",
3
+ "version": "0.21.6",
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": {
@@ -17,6 +17,7 @@
17
17
  "start:web": "node src/web.js",
18
18
  "smoke": "node test/smoke.mjs",
19
19
  "test:plug-wire": "node test/plug-wire.test.mjs",
20
+ "test:plug-grid-derive": "node test/plug-grid-derive.test.mjs",
20
21
  "test:session-log": "node test/session-logger.test.mjs",
21
22
  "test:error-reporting": "node test/error-reporting.test.mjs",
22
23
  "test:source": "node test/source-fetch.test.mjs",
@@ -65,7 +66,9 @@
65
66
  "test:pull-grid": "node test/pull-grid-param.test.mjs",
66
67
  "test:pull-output-schema": "node test/pull-output-schema.test.mjs",
67
68
  "test:web-identity": "node --test test/web-identity.test.mjs",
68
- "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs"
69
+ "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
70
+ "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
71
+ "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs"
69
72
  },
70
73
  "dependencies": {
71
74
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -848,10 +848,8 @@ export async function runPull(ctx, { claim_token, claim_url, entity_id, grid } =
848
848
  let msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
849
849
  // The API's "Set the X-CloudGrid-Grid header" error is unactionable for an
850
850
  // MCP client (no client can set HTTP headers on a tool call). Rewrite it to
851
- // name the tool parameter the caller CAN set (#247).
852
- if (res.status === 400 && /X-CloudGrid-Grid/i.test(msg)) {
853
- msg = "You belong to more than one grid. Pass the `grid` parameter to specify which grid to use.";
854
- }
851
+ // name the tool parameter the caller CAN set (#247; class-level since #296).
852
+ if (res.status === 400) msg = rewriteGridHeaderError(msg);
855
853
  throw new Error(`Pull failed (HTTP ${res.status}): ${msg}`);
856
854
  }
857
855
 
@@ -968,7 +966,10 @@ export async function runCollab(ctx, { entity_id, grid } = {}) {
968
966
  if (res.status === 400 && code === "NOT_A_RUNTIME") {
969
967
  throw new Error(data?.error?.message || `'${target}' is an Inspiration, not an app or agent — it has no collaborators. Fork it with grid_pickup instead.`);
970
968
  }
971
- const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
969
+ let msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
970
+ // Never surface the raw "Set the X-CloudGrid-Grid header" text — name the
971
+ // `grid` parameter instead (#296 class-level fix; see rewriteGridHeaderError).
972
+ if (res.status === 400) msg = rewriteGridHeaderError(msg);
972
973
  throw new Error(`Collab failed (HTTP ${res.status}): ${msg}`);
973
974
  }
974
975
 
@@ -1442,10 +1443,38 @@ export const REPORT_OFFER =
1442
1443
  "then call grid_report with the error + the failed request context. " +
1443
1444
  "Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
1444
1445
 
1446
+ // The API's "Set the X-CloudGrid-Grid header" 400 (code GRID_HEADER_REQUIRED) is
1447
+ // STRUCTURALLY unactionable for an MCP client: no client can set an HTTP header
1448
+ // on a tool call, so a model reading it starts guessing (observed live, #296).
1449
+ // Rewrite it to name the tool PARAMETER the caller CAN set. `param` is the grid-
1450
+ // carrying parameter on the calling tool (`grid` on plug/pull/collab/visibility;
1451
+ // `into_org_slug` on pickup). First added inline on the pull path for #247; this
1452
+ // is the class-level version applied at every authed write path.
1453
+ //
1454
+ // A message is a grid-header error when the code says so OR the text names the
1455
+ // header — matching both survives an API message reword. Anything else passes
1456
+ // through unchanged. The rewritten text never contains the words "header" or
1457
+ // "X-CloudGrid-Grid", nor the internal code.
1458
+ export function isGridHeaderError(code, msg) {
1459
+ return code === "GRID_HEADER_REQUIRED" || /X-CloudGrid-Grid/i.test(msg || "");
1460
+ }
1461
+ export function rewriteGridHeaderError(msg, param = "grid") {
1462
+ if (!isGridHeaderError(null, msg)) return msg;
1463
+ return `You belong to more than one grid. Pass the \`${param}\` parameter to specify which grid to use.`;
1464
+ }
1465
+
1445
1466
  // Map friendly plug error statuses to actionable messages (spec v2 §3.3).
1446
1467
  // Appends errorGuidance() for known codes; unknown codes pass through as the
1447
1468
  // bare `base` line, unchanged.
1448
1469
  function plugErrorMessage(status, code, msg, ctxFlags = {}) {
1470
+ // A GRID_HEADER_REQUIRED (or any 400 naming the header) is unactionable —
1471
+ // rewrite it to name the `grid` PARAMETER and DROP the internal code from the
1472
+ // prefix (the code string itself contains "HEADER"). Class-level fix (#296);
1473
+ // mirrors the pull-path rewrite (#247). Part A derives the grid on an edit so
1474
+ // this normally never fires there; it still guards multi-grid CREATES.
1475
+ if (status === 400 && isGridHeaderError(code, msg)) {
1476
+ return `Plug failed (HTTP 400): ${rewriteGridHeaderError(msg)}`;
1477
+ }
1449
1478
  const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
1450
1479
  const guidance = errorGuidance({ status, code, ...ctxFlags });
1451
1480
  return guidance ? `${base} — ${guidance}` : base;
@@ -1743,6 +1772,17 @@ export async function runPlug(ctx, input, deps = {}) {
1743
1772
  // entity's grid, so pass `grid` here too when the target lives outside the
1744
1773
  // active grid.
1745
1774
  orgSlug = grid || (await ctx.getActiveGrid());
1775
+ // #296: an EDIT's destination is NOT ambiguous — the entity already lives in
1776
+ // exactly one grid. When neither an explicit `grid` nor active-grid session
1777
+ // state resolves one (the common hosted case, where active-grid is often
1778
+ // unset), derive the home grid from the target entity itself so the write
1779
+ // never depends on session state the API would otherwise reject for
1780
+ // (GRID_HEADER_REQUIRED). Best-effort metadata resolve via the pickup
1781
+ // contract (no claim); a null result simply falls back to today's behaviour.
1782
+ if (!orgSlug && isEdit && targetEntityId) {
1783
+ const resolved = await resolveEntityViaPickup(ctx, { target: targetEntityId });
1784
+ if (resolved?.grid) orgSlug = resolved.grid;
1785
+ }
1746
1786
  // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1747
1787
  if (orgSlug) {
1748
1788
  headers["X-CloudGrid-Grid"] = orgSlug;
@@ -2134,7 +2174,7 @@ export async function runPlug(ctx, input, deps = {}) {
2134
2174
  return { text: lines.join("\n"), structured };
2135
2175
  }
2136
2176
 
2137
- async function authedApiCall(ctx, { method, pathName, body, verb }) {
2177
+ async function authedApiCall(ctx, { method, pathName, body, verb, gridParam = "grid" }) {
2138
2178
  const token = await ctx.getToken();
2139
2179
  if (!token) {
2140
2180
  throw new Error(`${verb} requires sign-in. Run grid_login first.`);
@@ -2167,7 +2207,13 @@ async function authedApiCall(ctx, { method, pathName, body, verb }) {
2167
2207
  if (!res.ok) {
2168
2208
  const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
2169
2209
  const codeStr = data?.error?.code || null;
2170
- const err = new Error(`${verb} failed (HTTP ${res.status}${codeStr ? ` ${codeStr}` : ""}): ${msg}`);
2210
+ // A GRID_HEADER_REQUIRED is unactionable — name the grid-carrying parameter
2211
+ // (into_org_slug for pickup) and drop the internal code from the prefix (it
2212
+ // contains "HEADER"). #296 class-level fix; see rewriteGridHeaderError.
2213
+ const errText = isGridHeaderError(codeStr, msg)
2214
+ ? `${verb} failed (HTTP ${res.status}): ${rewriteGridHeaderError(msg, gridParam)}`
2215
+ : `${verb} failed (HTTP ${res.status}${codeStr ? ` ${codeStr}` : ""}): ${msg}`;
2216
+ const err = new Error(errText);
2171
2217
  // Expose the structured status/code so callers can branch (e.g. runPickup's
2172
2218
  // NOT_A_RUNTIME → inspiration-route fallback). Additive: existing callers
2173
2219
  // only read `.message`.
@@ -2194,6 +2240,9 @@ export async function runPickup(ctx, { id, into_org_slug, name, source_version_i
2194
2240
  ...(source_version_id ? { source_version_id } : {}),
2195
2241
  },
2196
2242
  verb: "Pickup",
2243
+ // A fork's destination is chosen by the caller via into_org_slug, so a
2244
+ // GRID_HEADER_REQUIRED must point there, not at a non-existent `grid` param.
2245
+ gridParam: "into_org_slug",
2197
2246
  });
2198
2247
  const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
2199
2248
  const lines = [
@@ -2430,8 +2479,11 @@ export async function runVisibility(ctx, { target, visibility, inside, outside,
2430
2479
  if (!canFallback) break;
2431
2480
  }
2432
2481
 
2433
- const msg = last?.data?.error?.message || last?.raw || `HTTP ${last?.res?.status}`;
2482
+ let msg = last?.data?.error?.message || last?.raw || `HTTP ${last?.res?.status}`;
2434
2483
  const hint = last?.data?.error?.details?.[0]?.hint;
2484
+ // Never surface the raw header text — name the `grid` parameter (grid_visibility
2485
+ // accepts one via `org`, exposed to callers as the grid). #296 class-level fix.
2486
+ if (last?.res?.status === 400) msg = rewriteGridHeaderError(msg);
2435
2487
  throw new Error(`Visibility change failed (HTTP ${last?.res?.status}): ${msg}${hint ? ` ${hint}` : ""}`);
2436
2488
  }
2437
2489
 
@@ -47,6 +47,33 @@ export function buildCreateProjectArgs({ kind, name, type, needs, dir, grid, org
47
47
  return args;
48
48
  }
49
49
 
50
+ // ── Sign-in ask (#298) ──────────────────────────────────────────────────────
51
+ // Until a rendered login card is possible (blocked on #297's answer + the
52
+ // directory submission), this text IS the whole login experience on every
53
+ // client, so it is written to be RELAYED nearly verbatim. The user-facing part
54
+ // comes first — a short question, then the two evenly-weighted options (guest is
55
+ // a real choice, not a fallback). The model-directed steps (which tool to call,
56
+ // do not choose, wait) are quarantined in a trailing (assistant: …) line so the
57
+ // model does not read internal directions aloud. §23 voice: plain, no emoji, no
58
+ // exclamation; say "plug" and "grid". The sign-in URL itself lands on its own
59
+ // line later, from grid_login. No structural change — every caller still returns
60
+ // { needs_auth: true }.
61
+ const AUTH_ASK_SIGNED_OUT =
62
+ "How would you like to plug this?\n" +
63
+ " - Sign in to your grid — it stays in your CloudGrid account.\n" +
64
+ " - As a guest — live now at a link that expires in 7 days unless you claim it.\n\n" +
65
+ "(assistant: offer both options and let the user choose — do not choose for them. " +
66
+ "To sign in, call grid_login; as a guest, re-call grid_plug with anon: true. " +
67
+ "Stop and wait for their answer.)";
68
+ const authAskSignedIn = (who) =>
69
+ `You are signed in${who}.\n\n` +
70
+ "How would you like to plug this?\n" +
71
+ " - To your grid — it stays in your CloudGrid account.\n" +
72
+ " - As a guest — live now at a link that expires in 7 days unless you claim it.\n\n" +
73
+ "(assistant: offer both options and let the user choose — do not choose for them. " +
74
+ "For their grid, re-call grid_plug; as a guest, re-call grid_plug with anon: true. " +
75
+ "Stop and wait for their answer.)";
76
+
50
77
  // ── Registration ───────────────────────────────────────────────────────────────
51
78
  // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
52
79
  // tools are included (they need a local machine).
@@ -515,31 +542,19 @@ export function registerTools(server, ctx) {
515
542
  const claims = decodeJwtAuth(token);
516
543
  const who = claims.email ? ` as ${claims.email}` : "";
517
544
  return okResult({
518
- text:
519
- `You are signed in${who}. The user has not explicitly chosen anonymous publishing. Ask the user which they want — do not choose for them:\n` +
520
- ` 1. Publish to your grid (re-call grid_plug without anon: true).\n` +
521
- ` 2. Publish anonymously — a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n` +
522
- `Relay this question to the user and STOP until they answer.`,
545
+ text: authAskSignedIn(who),
523
546
  structured: { needs_auth: true },
524
547
  });
525
548
  }
526
549
  return okResult({
527
- text:
528
- "This is a new publish and the user is not signed in. Ask the user which they want — do not choose for them:\n" +
529
- " 1. Sign in and plug it to their grid — run grid_login (opens the browser), then re-call grid_plug.\n" +
530
- " 2. Plug it as a guest — live immediately at a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n" +
531
- "Relay this question to the user and STOP until they answer.",
550
+ text: AUTH_ASK_SIGNED_OUT,
532
551
  structured: { needs_auth: true },
533
552
  });
534
553
  }
535
554
  if (!token && input?.anon !== true) {
536
555
  if (ctx.state) ctx.state.authChoiceOffered = true;
537
556
  return okResult({
538
- text:
539
- "This is a new publish and the user is not signed in. Ask the user which they want — do not choose for them:\n" +
540
- " 1. Sign in and plug it to their grid — run grid_login (opens the browser), then re-call grid_plug.\n" +
541
- " 2. Plug it as a guest — live immediately at a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n" +
542
- "Relay this question to the user and STOP until they answer.",
557
+ text: AUTH_ASK_SIGNED_OUT,
543
558
  structured: { needs_auth: true },
544
559
  });
545
560
  }
@@ -660,8 +675,11 @@ export function registerTools(server, ctx) {
660
675
  if (ctx.canOpenBrowser) tryOpenBrowser(url);
661
676
  return {
662
677
  content: [{ type: "text", text:
663
- `To sign in, open this URL in your browser and finish with Google:\n${url}\n\n` +
664
- `After you complete it, run grid_login_status to finish signing in.`,
678
+ // #298: the sign-in link on its own line, unadorned, so every client
679
+ // linkifies it and the eye lands on it; the model-directed follow-up is
680
+ // quarantined in the trailing (assistant: …) line.
681
+ `Open this link to sign in, then finish in your browser:\n\n${url}\n\n` +
682
+ `(assistant: after they complete it, call grid_login_status to finish.)`,
665
683
  }],
666
684
  structuredContent: { login_url: url },
667
685
  };
package/src/web.js CHANGED
@@ -22,7 +22,11 @@ import { randomUUID } from "node:crypto";
22
22
  import express from "express";
23
23
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
24
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
25
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
25
+ import {
26
+ isInitializeRequest,
27
+ SUPPORTED_PROTOCOL_VERSIONS,
28
+ LATEST_PROTOCOL_VERSION,
29
+ } from "@modelcontextprotocol/sdk/types.js";
26
30
  import { registerTools } from "./tools.js";
27
31
  import { mountOAuth, bearerChallenge } from "./oauth.js";
28
32
  import { mountLanding } from "./landing.js";
@@ -53,10 +57,25 @@ function bearerOf(req) {
53
57
  return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
54
58
  }
55
59
 
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;
73
+ }
74
+
56
75
  // A web session: identity lives in memory for the session's lifetime only. The
57
76
  // session id doubles as the stable, opaque end-user id for the trusted-server cap.
58
- // The identity is built by the caller so the transport auth guard can consult it
59
- // (hasUsableCredential) before the session is wired up.
77
+ // The identity carries the request's transport token; auth is enforced in-band by
78
+ // the tool layer (needs_auth via grid_login / grid_plug), not by a transport gate.
60
79
  function makeWebContext(sessionId, identity) {
61
80
  return {
62
81
  edition: "web",
@@ -115,54 +134,15 @@ function captureRequestIdentity(sessionId, jwt) {
115
134
  }
116
135
  }
117
136
 
118
- app.post("/mcp", async (req, res) => {
119
- const sessionId = req.headers["mcp-session-id"];
120
- let transport = sessionId ? transports[sessionId] : undefined;
121
- const jwt = bearerOf(req);
122
-
123
- if (REQUIRE_AUTH && !jwt) {
124
- sendAuthChallenge(res);
125
- return;
126
- }
127
-
128
- if (transport) {
129
- captureRequestIdentity(sessionId, jwt);
130
- // An expired/invalid transport token with no valid explicit login means the
131
- // session holds no usable credential — challenge so the client re-runs OAuth,
132
- // rather than proceeding and degrading to an in-band copy-paste login link
133
- // (#286). Consult the identity, not the raw Bearer: an explicit in-session
134
- // login stays authoritative over an expired transport token (#279/#280).
135
- const ctx = sessionContexts[sessionId];
136
- if (REQUIRE_AUTH && ctx && !ctx.identity.hasUsableCredential()) {
137
- sendAuthChallenge(res);
138
- return;
139
- }
140
- await transport.handleRequest(req, res, req.body);
141
- return;
142
- }
143
-
144
- if (sessionId || !isInitializeRequest(req.body)) {
145
- res.status(400).json({
146
- jsonrpc: "2.0",
147
- error: { code: -32000, message: "No valid session. Send an initialize request first." },
148
- id: null,
149
- });
150
- return;
151
- }
152
-
153
- // New session: fresh server + per-session identity context. Generate the session
154
- // id up front so it is also the trusted-server end-user id. (Distinct name from
155
- // the incoming `sessionId` header above.)
156
- const newSessionId = randomUUID();
137
+ // Build a fresh MCP session — server + transport + per-session identity context —
138
+ // keyed by `sessionId`. `sessionId` is also the trusted-server end-user id and,
139
+ // for a rehydrated (restart-evicted) session, the id the client keeps presenting.
140
+ // Identity derives ONLY from the credential passed here (`jwt`), never from
141
+ // anything remembered about the id: a session id is not proof of identity.
142
+ async function buildSession(sessionId, jwt) {
157
143
  const identity = createWebIdentity({ initialTransportToken: jwt });
158
- // A brand-new session has no explicit login yet, so the transport Bearer is the
159
- // only possible credential — challenge if it is expired or undecodable (#286).
160
- if (REQUIRE_AUTH && !identity.hasUsableCredential()) {
161
- sendAuthChallenge(res);
162
- return;
163
- }
164
- transport = new StreamableHTTPServerTransport({
165
- sessionIdGenerator: () => newSessionId,
144
+ const transport = new StreamableHTTPServerTransport({
145
+ sessionIdGenerator: () => sessionId,
166
146
  onsessioninitialized: (sid) => {
167
147
  transports[sid] = transport;
168
148
  },
@@ -177,17 +157,17 @@ app.post("/mcp", async (req, res) => {
177
157
  }
178
158
  };
179
159
  const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
180
- const webCtx = makeWebContext(newSessionId, identity);
181
- sessionContexts[newSessionId] = webCtx;
160
+ const webCtx = makeWebContext(sessionId, identity);
161
+ sessionContexts[sessionId] = webCtx;
182
162
  // QA session log for this MCP session (dark by default). Keyed by the session
183
163
  // id — the host connection boundary. No seed user_request here: current hosts
184
164
  // don't forward the first message. The model-as-courier path fills it instead —
185
165
  // grid_plug's user_request arg lifts the ask into the log (setUserRequest);
186
166
  // absent that, the log records the explicit not-provided line.
187
- webCtx.sessionId = newSessionId;
167
+ webCtx.sessionId = sessionId;
188
168
  webCtx.logger = createSessionLogger({
189
169
  transport: "hosted",
190
- sessionId: newSessionId,
170
+ sessionId,
191
171
  sink: createSink(process.env),
192
172
  ctx: webCtx,
193
173
  });
@@ -201,8 +181,160 @@ app.post("/mcp", async (req, res) => {
201
181
  } catch {
202
182
  webCtx.state.client = null;
203
183
  }
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
+ }
204
206
  };
205
207
  await server.connect(transport);
208
+ return { transport, identity };
209
+ }
210
+
211
+ // Drive an in-process MCP initialize so a freshly-built transport is usable
212
+ // WITHOUT the client sending its own initialize. This is the restart-recovery
213
+ // path: a client whose session was evicted by a pod restart keeps presenting the
214
+ // same stale mcp-session-id on ordinary tool calls and never re-initializes on
215
+ // its own (Claude web does not act on the transport's 400 — see #292). The SDK's
216
+ // StreamableHTTPServerTransport rejects any non-initialize request until it has
217
+ // seen an initialize ("Bad Request: Server not initialized"), so we feed it a
218
+ // synthetic initialize here — adopting the stale id via sessionIdGenerator — then
219
+ // serve the real request on the same session. Verified against the SDK: this sets
220
+ // _initialized, adopts the session id, and fires onsessioninitialized so the
221
+ // transport registers itself for reuse by the client's subsequent requests.
222
+ async function primeSession(transport, req) {
223
+ const wst = transport._webStandardTransport;
224
+ if (!wst || typeof wst.handleRequest !== "function") {
225
+ throw new Error("cannot rehydrate session: transport internals unavailable");
226
+ }
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;
232
+ const initBody = {
233
+ jsonrpc: "2.0",
234
+ id: "cg-rehydrate-init",
235
+ method: "initialize",
236
+ params: {
237
+ protocolVersion,
238
+ capabilities: {},
239
+ clientInfo: { name: "cloudgrid-mcp-rehydrate", version },
240
+ },
241
+ };
242
+ const initReq = new Request(`${PUBLIC_BASE}/mcp`, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
245
+ });
246
+ const initRes = await wst.handleRequest(initReq, { parsedBody: initBody });
247
+ // Drain the synthetic initialize's SSE response so its stream closes and no
248
+ // keep-alive timer leaks. The initialize result itself is discarded — the
249
+ // client never sees it; it only ever sees the response to its real request.
250
+ try { await initRes.text(); } catch { /* the init result is discarded either way */ }
251
+ }
252
+
253
+ app.post("/mcp", async (req, res) => {
254
+ const sessionId = req.headers["mcp-session-id"];
255
+ let transport = sessionId ? transports[sessionId] : undefined;
256
+ const jwt = bearerOf(req);
257
+
258
+ if (REQUIRE_AUTH && !jwt) {
259
+ sendAuthChallenge(res);
260
+ return;
261
+ }
262
+
263
+ if (transport) {
264
+ // Capture the request's transport token into the session identity — #279: an
265
+ // explicit in-session login stays authoritative over an expired transport
266
+ // token. Do NOT gate on expiry here. #288 added a transport-level 401 on an
267
+ // expired/invalid Bearer (the "#286 challenge"); it shipped in 0.21.3/0.21.4,
268
+ // was pulled from production (manifests re-pinned to 0.21.2) hours later, and
269
+ // #286 is closed not-planned. Auth on this connector is IN-BAND via grid_login
270
+ // / grid_plug (2026-08-23 founder decision), so a transport 401 on expiry is
271
+ // wrong by design — it bricks the client with grid_login behind the same 401.
272
+ // An expired-but-present Bearer must reach the tool layer, where the needs_auth
273
+ // ask is the recovery path. The only auth gate is the missing-Bearer guard at
274
+ // the top of this handler (the deployed v0.21.2 posture).
275
+ captureRequestIdentity(sessionId, jwt);
276
+ await transport.handleRequest(req, res, req.body);
277
+ return;
278
+ }
279
+
280
+ const isInit = isInitializeRequest(req.body);
281
+
282
+ // A brand-new connection with no session id that is NOT an initialize is a real
283
+ // protocol error — there is nothing to recover.
284
+ if (!sessionId && !isInit) {
285
+ res.status(400).json({
286
+ jsonrpc: "2.0",
287
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
288
+ id: null,
289
+ });
290
+ return;
291
+ }
292
+
293
+ if (isInit) {
294
+ // A genuine new session — mint a fresh id. The only auth gate is the top-of-
295
+ // handler missing-Bearer guard (the deployed v0.21.2 posture); the #288
296
+ // expired-Bearer transport 401 is reverted (see the transport-present block
297
+ // above), so an expired-but-present Bearer proceeds to the tool layer where
298
+ // the in-band needs_auth ask lives, rather than being challenged.
299
+ const newSessionId = randomUUID();
300
+ ({ transport } = await buildSession(newSessionId, jwt));
301
+ await transport.handleRequest(req, res, req.body);
302
+ return;
303
+ }
304
+
305
+ // A stale session id the process no longer knows — a non-initialize call whose
306
+ // session was evicted by a pod restart (#292). Rebuild ON THE SAME id the client
307
+ // keeps presenting, so it recovers without reconnecting. Identity comes ONLY from
308
+ // this request's Bearer; the stale id carries no remembered identity, and a
309
+ // rebuilt session legitimately loses the prior in-memory explicit-login override
310
+ // — degrading to a fresh grid_login, never silently reusing a login (#279/#280).
311
+ //
312
+ // No auth gate beyond the top-of-handler missing-Bearer guard (the deployed
313
+ // v0.21.2 posture): auth on the connected edge is IN-BAND via grid_login /
314
+ // grid_plug (2026-08-23 founder decision), so an expired-but-present Bearer — a
315
+ // connected session after a restart — must rebuild and reach the tool layer where
316
+ // the needs_auth ask is the recovery path, NOT be challenged with grid_login
317
+ // stuck behind the same 401 (the #286 brick, on the rebuild path). Gating on
318
+ // expiry (hasUsableCredential) here is exactly what #288 got wrong and was pulled
319
+ // from production for.
320
+ ({ transport } = await buildSession(sessionId, jwt));
321
+ // Rehydrate the evicted session. If priming fails (e.g. an SDK internal moved
322
+ // out from under us), tear the half-built session down and fall back to the
323
+ // original 400 rather than leaking a broken session or hanging the request —
324
+ // a re-initialize still recovers the client.
325
+ try {
326
+ await primeSession(transport, req);
327
+ } catch {
328
+ try { await transport.close(); } catch { /* best effort */ }
329
+ delete transports[sessionId];
330
+ delete sessionContexts[sessionId];
331
+ res.status(400).json({
332
+ jsonrpc: "2.0",
333
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
334
+ id: null,
335
+ });
336
+ return;
337
+ }
206
338
  await transport.handleRequest(req, res, req.body);
207
339
  });
208
340