@cloudgrid-io/mcp 0.21.4 → 0.21.5

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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/web.js +149 -54
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.5",
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": {
@@ -65,7 +65,8 @@
65
65
  "test:pull-grid": "node test/pull-grid-param.test.mjs",
66
66
  "test:pull-output-schema": "node test/pull-output-schema.test.mjs",
67
67
  "test:web-identity": "node --test test/web-identity.test.mjs",
68
- "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs"
68
+ "test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
69
+ "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs"
69
70
  },
70
71
  "dependencies": {
71
72
  "@modelcontextprotocol/sdk": "^1.30.0",
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";
@@ -55,8 +59,8 @@ function bearerOf(req) {
55
59
 
56
60
  // A web session: identity lives in memory for the session's lifetime only. The
57
61
  // 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.
62
+ // The identity carries the request's transport token; auth is enforced in-band by
63
+ // the tool layer (needs_auth via grid_login / grid_plug), not by a transport gate.
60
64
  function makeWebContext(sessionId, identity) {
61
65
  return {
62
66
  edition: "web",
@@ -115,54 +119,15 @@ function captureRequestIdentity(sessionId, jwt) {
115
119
  }
116
120
  }
117
121
 
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();
122
+ // Build a fresh MCP session — server + transport + per-session identity context —
123
+ // keyed by `sessionId`. `sessionId` is also the trusted-server end-user id and,
124
+ // for a rehydrated (restart-evicted) session, the id the client keeps presenting.
125
+ // Identity derives ONLY from the credential passed here (`jwt`), never from
126
+ // anything remembered about the id: a session id is not proof of identity.
127
+ async function buildSession(sessionId, jwt) {
157
128
  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,
129
+ const transport = new StreamableHTTPServerTransport({
130
+ sessionIdGenerator: () => sessionId,
166
131
  onsessioninitialized: (sid) => {
167
132
  transports[sid] = transport;
168
133
  },
@@ -177,17 +142,17 @@ app.post("/mcp", async (req, res) => {
177
142
  }
178
143
  };
179
144
  const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
180
- const webCtx = makeWebContext(newSessionId, identity);
181
- sessionContexts[newSessionId] = webCtx;
145
+ const webCtx = makeWebContext(sessionId, identity);
146
+ sessionContexts[sessionId] = webCtx;
182
147
  // QA session log for this MCP session (dark by default). Keyed by the session
183
148
  // id — the host connection boundary. No seed user_request here: current hosts
184
149
  // don't forward the first message. The model-as-courier path fills it instead —
185
150
  // grid_plug's user_request arg lifts the ask into the log (setUserRequest);
186
151
  // absent that, the log records the explicit not-provided line.
187
- webCtx.sessionId = newSessionId;
152
+ webCtx.sessionId = sessionId;
188
153
  webCtx.logger = createSessionLogger({
189
154
  transport: "hosted",
190
- sessionId: newSessionId,
155
+ sessionId,
191
156
  sink: createSink(process.env),
192
157
  ctx: webCtx,
193
158
  });
@@ -203,6 +168,136 @@ app.post("/mcp", async (req, res) => {
203
168
  }
204
169
  };
205
170
  await server.connect(transport);
171
+ return { transport, identity };
172
+ }
173
+
174
+ // Drive an in-process MCP initialize so a freshly-built transport is usable
175
+ // WITHOUT the client sending its own initialize. This is the restart-recovery
176
+ // path: a client whose session was evicted by a pod restart keeps presenting the
177
+ // same stale mcp-session-id on ordinary tool calls and never re-initializes on
178
+ // its own (Claude web does not act on the transport's 400 — see #292). The SDK's
179
+ // StreamableHTTPServerTransport rejects any non-initialize request until it has
180
+ // seen an initialize ("Bad Request: Server not initialized"), so we feed it a
181
+ // synthetic initialize here — adopting the stale id via sessionIdGenerator — then
182
+ // serve the real request on the same session. Verified against the SDK: this sets
183
+ // _initialized, adopts the session id, and fires onsessioninitialized so the
184
+ // transport registers itself for reuse by the client's subsequent requests.
185
+ async function primeSession(transport, req) {
186
+ const wst = transport._webStandardTransport;
187
+ if (!wst || typeof wst.handleRequest !== "function") {
188
+ throw new Error("cannot rehydrate session: transport internals unavailable");
189
+ }
190
+ const proposed = req.headers["mcp-protocol-version"];
191
+ const protocolVersion =
192
+ typeof proposed === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(proposed)
193
+ ? proposed
194
+ : LATEST_PROTOCOL_VERSION;
195
+ const initBody = {
196
+ jsonrpc: "2.0",
197
+ id: "cg-rehydrate-init",
198
+ method: "initialize",
199
+ params: {
200
+ protocolVersion,
201
+ capabilities: {},
202
+ clientInfo: { name: "cloudgrid-mcp-rehydrate", version },
203
+ },
204
+ };
205
+ const initReq = new Request(`${PUBLIC_BASE}/mcp`, {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
208
+ });
209
+ const initRes = await wst.handleRequest(initReq, { parsedBody: initBody });
210
+ // Drain the synthetic initialize's SSE response so its stream closes and no
211
+ // keep-alive timer leaks. The initialize result itself is discarded — the
212
+ // client never sees it; it only ever sees the response to its real request.
213
+ try { await initRes.text(); } catch { /* the init result is discarded either way */ }
214
+ }
215
+
216
+ app.post("/mcp", async (req, res) => {
217
+ const sessionId = req.headers["mcp-session-id"];
218
+ let transport = sessionId ? transports[sessionId] : undefined;
219
+ const jwt = bearerOf(req);
220
+
221
+ if (REQUIRE_AUTH && !jwt) {
222
+ sendAuthChallenge(res);
223
+ return;
224
+ }
225
+
226
+ if (transport) {
227
+ // Capture the request's transport token into the session identity — #279: an
228
+ // explicit in-session login stays authoritative over an expired transport
229
+ // token. Do NOT gate on expiry here. #288 added a transport-level 401 on an
230
+ // expired/invalid Bearer (the "#286 challenge"); it shipped in 0.21.3/0.21.4,
231
+ // was pulled from production (manifests re-pinned to 0.21.2) hours later, and
232
+ // #286 is closed not-planned. Auth on this connector is IN-BAND via grid_login
233
+ // / grid_plug (2026-08-23 founder decision), so a transport 401 on expiry is
234
+ // wrong by design — it bricks the client with grid_login behind the same 401.
235
+ // An expired-but-present Bearer must reach the tool layer, where the needs_auth
236
+ // ask is the recovery path. The only auth gate is the missing-Bearer guard at
237
+ // the top of this handler (the deployed v0.21.2 posture).
238
+ captureRequestIdentity(sessionId, jwt);
239
+ await transport.handleRequest(req, res, req.body);
240
+ return;
241
+ }
242
+
243
+ const isInit = isInitializeRequest(req.body);
244
+
245
+ // A brand-new connection with no session id that is NOT an initialize is a real
246
+ // protocol error — there is nothing to recover.
247
+ if (!sessionId && !isInit) {
248
+ res.status(400).json({
249
+ jsonrpc: "2.0",
250
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
251
+ id: null,
252
+ });
253
+ return;
254
+ }
255
+
256
+ if (isInit) {
257
+ // A genuine new session — mint a fresh id. The only auth gate is the top-of-
258
+ // handler missing-Bearer guard (the deployed v0.21.2 posture); the #288
259
+ // expired-Bearer transport 401 is reverted (see the transport-present block
260
+ // above), so an expired-but-present Bearer proceeds to the tool layer where
261
+ // the in-band needs_auth ask lives, rather than being challenged.
262
+ const newSessionId = randomUUID();
263
+ ({ transport } = await buildSession(newSessionId, jwt));
264
+ await transport.handleRequest(req, res, req.body);
265
+ return;
266
+ }
267
+
268
+ // A stale session id the process no longer knows — a non-initialize call whose
269
+ // session was evicted by a pod restart (#292). Rebuild ON THE SAME id the client
270
+ // keeps presenting, so it recovers without reconnecting. Identity comes ONLY from
271
+ // this request's Bearer; the stale id carries no remembered identity, and a
272
+ // rebuilt session legitimately loses the prior in-memory explicit-login override
273
+ // — degrading to a fresh grid_login, never silently reusing a login (#279/#280).
274
+ //
275
+ // No auth gate beyond the top-of-handler missing-Bearer guard (the deployed
276
+ // v0.21.2 posture): auth on the connected edge is IN-BAND via grid_login /
277
+ // grid_plug (2026-08-23 founder decision), so an expired-but-present Bearer — a
278
+ // connected session after a restart — must rebuild and reach the tool layer where
279
+ // the needs_auth ask is the recovery path, NOT be challenged with grid_login
280
+ // stuck behind the same 401 (the #286 brick, on the rebuild path). Gating on
281
+ // expiry (hasUsableCredential) here is exactly what #288 got wrong and was pulled
282
+ // from production for.
283
+ ({ transport } = await buildSession(sessionId, jwt));
284
+ // Rehydrate the evicted session. If priming fails (e.g. an SDK internal moved
285
+ // out from under us), tear the half-built session down and fall back to the
286
+ // original 400 rather than leaking a broken session or hanging the request —
287
+ // a re-initialize still recovers the client.
288
+ try {
289
+ await primeSession(transport, req);
290
+ } catch {
291
+ try { await transport.close(); } catch { /* best effort */ }
292
+ delete transports[sessionId];
293
+ delete sessionContexts[sessionId];
294
+ res.status(400).json({
295
+ jsonrpc: "2.0",
296
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
297
+ id: null,
298
+ });
299
+ return;
300
+ }
206
301
  await transport.handleRequest(req, res, req.body);
207
302
  });
208
303