@cloudgrid-io/mcp 0.21.3 → 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.
- package/package.json +3 -2
- package/src/web-identity.js +22 -0
- package/src/web.js +164 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
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-identity.js
CHANGED
|
@@ -5,6 +5,19 @@ function isTokenExpired(jwt, now) {
|
|
|
5
5
|
return decodeJwt(jwt).exp * 1000 <= now();
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// Is this token a credential the server can actually authenticate with right
|
|
9
|
+
// now? A present, decodable token whose numeric `exp` is still in the future.
|
|
10
|
+
// Anything else — absent, undecodable, or lacking a real expiry — is unusable.
|
|
11
|
+
// Unlike isTokenExpired (which treats an undecodable token as "not expired"
|
|
12
|
+
// because NaN <= now is false), this answers the transport guard's question,
|
|
13
|
+
// where a token we cannot vouch for must not pass.
|
|
14
|
+
function isCredentialUsable(jwt, now) {
|
|
15
|
+
if (!jwt) return false;
|
|
16
|
+
const { exp } = decodeJwt(jwt);
|
|
17
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
|
|
18
|
+
return exp * 1000 > now();
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
export function createWebIdentity({
|
|
9
22
|
initialTransportToken = null,
|
|
10
23
|
now = () => Date.now(),
|
|
@@ -33,6 +46,15 @@ export function createWebIdentity({
|
|
|
33
46
|
return { identityChanged };
|
|
34
47
|
},
|
|
35
48
|
|
|
49
|
+
// Whether the session currently holds any credential the server can use:
|
|
50
|
+
// the effective token (explicit login shadowing transport per #279) is
|
|
51
|
+
// present, decodable, and unexpired. The transport guard asks this before
|
|
52
|
+
// accepting a request, so an expired/invalid Bearer with no valid explicit
|
|
53
|
+
// login is challenged rather than degrading to an in-band login link.
|
|
54
|
+
hasUsableCredential() {
|
|
55
|
+
return isCredentialUsable(effectiveToken(), now);
|
|
56
|
+
},
|
|
57
|
+
|
|
36
58
|
async getToken() {
|
|
37
59
|
const jwt = effectiveToken();
|
|
38
60
|
return isTokenExpired(jwt, now) ? null : jwt;
|
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 {
|
|
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,9 @@ 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
|
-
|
|
59
|
-
|
|
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.
|
|
64
|
+
function makeWebContext(sessionId, identity) {
|
|
60
65
|
return {
|
|
61
66
|
edition: "web",
|
|
62
67
|
state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
|
|
@@ -89,6 +94,19 @@ mountOAuth(app, PUBLIC_BASE, { requireAuth: REQUIRE_AUTH });
|
|
|
89
94
|
const transports = Object.create(null);
|
|
90
95
|
const sessionContexts = Object.create(null);
|
|
91
96
|
|
|
97
|
+
// Per the MCP authorization spec, a resource server that receives no usable
|
|
98
|
+
// credential answers 401 with a Bearer WWW-Authenticate challenge — the signal
|
|
99
|
+
// Claude web needs to re-run OAuth and render its native login UI. Reused for
|
|
100
|
+
// the missing, expired, and invalid transport-token cases.
|
|
101
|
+
function sendAuthChallenge(res) {
|
|
102
|
+
res.setHeader("WWW-Authenticate", bearerChallenge(PUBLIC_BASE));
|
|
103
|
+
res.status(401).json({
|
|
104
|
+
jsonrpc: "2.0",
|
|
105
|
+
error: { code: -32001, message: "Authorization required. Complete the OAuth connect." },
|
|
106
|
+
id: null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
function captureRequestIdentity(sessionId, jwt) {
|
|
93
111
|
if (!jwt) return;
|
|
94
112
|
const ctx = sessionContexts[sessionId];
|
|
@@ -101,42 +119,15 @@ function captureRequestIdentity(sessionId, jwt) {
|
|
|
101
119
|
}
|
|
102
120
|
}
|
|
103
121
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
error: { code: -32001, message: "Authorization required. Complete the OAuth connect." },
|
|
114
|
-
id: null,
|
|
115
|
-
});
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (transport) {
|
|
120
|
-
captureRequestIdentity(sessionId, jwt);
|
|
121
|
-
await transport.handleRequest(req, res, req.body);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (sessionId || !isInitializeRequest(req.body)) {
|
|
126
|
-
res.status(400).json({
|
|
127
|
-
jsonrpc: "2.0",
|
|
128
|
-
error: { code: -32000, message: "No valid session. Send an initialize request first." },
|
|
129
|
-
id: null,
|
|
130
|
-
});
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// New session: fresh server + per-session identity context. Generate the session
|
|
135
|
-
// id up front so it is also the trusted-server end-user id. (Distinct name from
|
|
136
|
-
// the incoming `sessionId` header above.)
|
|
137
|
-
const newSessionId = randomUUID();
|
|
138
|
-
transport = new StreamableHTTPServerTransport({
|
|
139
|
-
sessionIdGenerator: () => newSessionId,
|
|
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) {
|
|
128
|
+
const identity = createWebIdentity({ initialTransportToken: jwt });
|
|
129
|
+
const transport = new StreamableHTTPServerTransport({
|
|
130
|
+
sessionIdGenerator: () => sessionId,
|
|
140
131
|
onsessioninitialized: (sid) => {
|
|
141
132
|
transports[sid] = transport;
|
|
142
133
|
},
|
|
@@ -151,17 +142,17 @@ app.post("/mcp", async (req, res) => {
|
|
|
151
142
|
}
|
|
152
143
|
};
|
|
153
144
|
const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
|
|
154
|
-
const webCtx = makeWebContext(
|
|
155
|
-
sessionContexts[
|
|
145
|
+
const webCtx = makeWebContext(sessionId, identity);
|
|
146
|
+
sessionContexts[sessionId] = webCtx;
|
|
156
147
|
// QA session log for this MCP session (dark by default). Keyed by the session
|
|
157
148
|
// id — the host connection boundary. No seed user_request here: current hosts
|
|
158
149
|
// don't forward the first message. The model-as-courier path fills it instead —
|
|
159
150
|
// grid_plug's user_request arg lifts the ask into the log (setUserRequest);
|
|
160
151
|
// absent that, the log records the explicit not-provided line.
|
|
161
|
-
webCtx.sessionId =
|
|
152
|
+
webCtx.sessionId = sessionId;
|
|
162
153
|
webCtx.logger = createSessionLogger({
|
|
163
154
|
transport: "hosted",
|
|
164
|
-
sessionId
|
|
155
|
+
sessionId,
|
|
165
156
|
sink: createSink(process.env),
|
|
166
157
|
ctx: webCtx,
|
|
167
158
|
});
|
|
@@ -177,6 +168,136 @@ app.post("/mcp", async (req, res) => {
|
|
|
177
168
|
}
|
|
178
169
|
};
|
|
179
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
|
+
}
|
|
180
301
|
await transport.handleRequest(req, res, req.body);
|
|
181
302
|
});
|
|
182
303
|
|