@cloudgrid-io/mcp 0.21.1 → 0.21.2
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 +5 -3
- package/src/tools/deploy.js +10 -4
- package/src/web-identity.js +54 -0
- package/src/web.js +23 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
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": {
|
|
@@ -63,13 +63,15 @@
|
|
|
63
63
|
"test:collab": "node test/collab.test.mjs",
|
|
64
64
|
"test:url-shape": "node test/url-shape-guard.test.mjs",
|
|
65
65
|
"test:pull-grid": "node test/pull-grid-param.test.mjs",
|
|
66
|
-
"test:pull-output-schema": "node test/pull-output-schema.test.mjs"
|
|
66
|
+
"test:pull-output-schema": "node test/pull-output-schema.test.mjs",
|
|
67
|
+
"test:web-identity": "node --test test/web-identity.test.mjs",
|
|
68
|
+
"test:web-auth-integration": "node --test test/web-auth-integration.test.mjs"
|
|
67
69
|
},
|
|
68
70
|
"dependencies": {
|
|
69
71
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
70
72
|
"express": "^4.22.2",
|
|
71
73
|
"fflate": "^0.8.3",
|
|
72
|
-
"undici": "^
|
|
74
|
+
"undici": "^8.10.0",
|
|
73
75
|
"zod": "^3.23.0"
|
|
74
76
|
},
|
|
75
77
|
"license": "Apache-2.0",
|
package/src/tools/deploy.js
CHANGED
|
@@ -60,13 +60,19 @@ function isGridNotAccessibleCode(code) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
// ──
|
|
64
|
-
// Fetches the signed-in user's
|
|
65
|
-
// carry
|
|
63
|
+
// ── Grid listing (bearer-authed, web edition) ─────────────────────────────────
|
|
64
|
+
// Fetches the signed-in user's grids via GET /api/v2/grids. The JWT does not
|
|
65
|
+
// carry them (claims: sub, email, name, iat, exp), so the API is the canonical
|
|
66
66
|
// source. Returns [{slug, name, role, render_ready}].
|
|
67
|
+
//
|
|
68
|
+
// cloudgrid-io/cloudgrid#2550 — this used to call `/api/v2/orgs`. That mount is
|
|
69
|
+
// being retired, and unlike a renamed FIELD a retired URL cannot be papered over
|
|
70
|
+
// by any client-side compatibility shim: the request simply 404s. Both mounts
|
|
71
|
+
// answer identically today, so moving now is a no-op that stops this tool
|
|
72
|
+
// breaking when `/orgs` goes.
|
|
67
73
|
export async function fetchUserOrgs(token) {
|
|
68
74
|
try {
|
|
69
|
-
const res = await fetch(`${API_BASE}/api/v2/
|
|
75
|
+
const res = await fetch(`${API_BASE}/api/v2/grids`, {
|
|
70
76
|
headers: { Authorization: `Bearer ${token}` },
|
|
71
77
|
});
|
|
72
78
|
if (!res.ok) return [];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { decodeJwt } from "./auth.js";
|
|
2
|
+
|
|
3
|
+
function isTokenExpired(jwt, now) {
|
|
4
|
+
if (!jwt) return false;
|
|
5
|
+
return decodeJwt(jwt).exp * 1000 <= now();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createWebIdentity({
|
|
9
|
+
initialTransportToken = null,
|
|
10
|
+
now = () => Date.now(),
|
|
11
|
+
} = {}) {
|
|
12
|
+
let transportToken = initialTransportToken;
|
|
13
|
+
let explicitToken = null;
|
|
14
|
+
let hasExplicitLogin = false;
|
|
15
|
+
|
|
16
|
+
// An explicit login wins over transport-token refreshes for the same subject.
|
|
17
|
+
// A genuine transport subject change ends that override so credentials from
|
|
18
|
+
// the previous person cannot persist on a connection now owned by another.
|
|
19
|
+
function effectiveToken() {
|
|
20
|
+
return hasExplicitLogin ? explicitToken : transportToken;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
captureTransportToken(jwt) {
|
|
25
|
+
const previousSub = decodeJwt(transportToken).sub;
|
|
26
|
+
const nextSub = decodeJwt(jwt).sub;
|
|
27
|
+
const identityChanged = Boolean(previousSub && nextSub && previousSub !== nextSub);
|
|
28
|
+
transportToken = jwt;
|
|
29
|
+
if (identityChanged && hasExplicitLogin) {
|
|
30
|
+
explicitToken = null;
|
|
31
|
+
hasExplicitLogin = false;
|
|
32
|
+
}
|
|
33
|
+
return { identityChanged };
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async getToken() {
|
|
37
|
+
const jwt = effectiveToken();
|
|
38
|
+
return isTokenExpired(jwt, now) ? null : jwt;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async getCredentialsStatus() {
|
|
42
|
+
const jwt = effectiveToken();
|
|
43
|
+
if (!jwt) return { creds: null, expired: false };
|
|
44
|
+
if (isTokenExpired(jwt, now)) return { creds: null, expired: true };
|
|
45
|
+
return { creds: { jwt }, expired: false };
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async saveToken(jwt) {
|
|
49
|
+
explicitToken = jwt;
|
|
50
|
+
hasExplicitLogin = true;
|
|
51
|
+
return decodeJwt(jwt);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/web.js
CHANGED
|
@@ -23,12 +23,13 @@ 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
25
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
-
import { registerTools
|
|
26
|
+
import { registerTools } from "./tools.js";
|
|
27
27
|
import { mountOAuth, bearerChallenge } from "./oauth.js";
|
|
28
28
|
import { mountLanding } from "./landing.js";
|
|
29
29
|
import { createSessionLogger } from "./session-logger.js";
|
|
30
30
|
import { createSink } from "./log-sink.js";
|
|
31
31
|
import { INSTRUCTIONS_WEB } from "./playbook.js";
|
|
32
|
+
import { createWebIdentity } from "./web-identity.js";
|
|
32
33
|
|
|
33
34
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)));
|
|
34
35
|
|
|
@@ -47,45 +48,24 @@ const REQUIRE_AUTH = process.env.MCP_REQUIRE_AUTH === "1";
|
|
|
47
48
|
// server-side, so it is safe to leave unset.
|
|
48
49
|
const TRUSTED_SERVER_SECRET = process.env.MCP_TRUSTED_SERVER_SECRET || null;
|
|
49
50
|
|
|
50
|
-
// Transport-level identity per MCP session: a Bearer presented on /mcp requests
|
|
51
|
-
// becomes the session's CloudGrid identity (takes precedence over in-tool login).
|
|
52
|
-
const sessionAuth = Object.create(null); // sid -> jwt
|
|
53
|
-
|
|
54
51
|
function bearerOf(req) {
|
|
55
52
|
const h = req.headers.authorization;
|
|
56
53
|
return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
function isTokenExpired(jwt) {
|
|
60
|
-
if (!jwt) return false;
|
|
61
|
-
const claims = decodeJwt(jwt);
|
|
62
|
-
return Boolean(claims.exp && claims.exp * 1000 <= Date.now());
|
|
63
|
-
}
|
|
64
|
-
|
|
65
56
|
// A web session: identity lives in memory for the session's lifetime only. The
|
|
66
57
|
// session id doubles as the stable, opaque end-user id for the trusted-server cap.
|
|
67
|
-
function makeWebContext(sessionId) {
|
|
68
|
-
|
|
58
|
+
function makeWebContext(sessionId, initialTransportToken = null) {
|
|
59
|
+
const identity = createWebIdentity({ initialTransportToken });
|
|
69
60
|
return {
|
|
70
61
|
edition: "web",
|
|
71
62
|
state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
|
|
72
63
|
canOpenBrowser: false,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return jwt;
|
|
77
|
-
},
|
|
78
|
-
getCredentialsStatus: async () => {
|
|
79
|
-
const jwt = sessionAuth[sessionId] ?? sessionToken;
|
|
80
|
-
if (!jwt) return { creds: null, expired: false };
|
|
81
|
-
if (isTokenExpired(jwt)) return { creds: null, expired: true };
|
|
82
|
-
return { creds: { jwt }, expired: false };
|
|
83
|
-
},
|
|
64
|
+
identity,
|
|
65
|
+
getToken: identity.getToken,
|
|
66
|
+
getCredentialsStatus: identity.getCredentialsStatus,
|
|
84
67
|
getActiveGrid: async () => null,
|
|
85
|
-
saveToken:
|
|
86
|
-
sessionToken = jwt;
|
|
87
|
-
return decodeJwt(jwt);
|
|
88
|
-
},
|
|
68
|
+
saveToken: identity.saveToken,
|
|
89
69
|
savedLocationNote: () => "You are signed in for this session.",
|
|
90
70
|
trustedServer: TRUSTED_SERVER_SECRET
|
|
91
71
|
? { secret: TRUSTED_SERVER_SECRET, endUserId: sessionId }
|
|
@@ -109,6 +89,18 @@ mountOAuth(app, PUBLIC_BASE, { requireAuth: REQUIRE_AUTH });
|
|
|
109
89
|
const transports = Object.create(null);
|
|
110
90
|
const sessionContexts = Object.create(null);
|
|
111
91
|
|
|
92
|
+
function captureRequestIdentity(sessionId, jwt) {
|
|
93
|
+
if (!jwt) return;
|
|
94
|
+
const ctx = sessionContexts[sessionId];
|
|
95
|
+
if (!ctx) return;
|
|
96
|
+
const { identityChanged } = ctx.identity.captureTransportToken(jwt);
|
|
97
|
+
if (identityChanged) {
|
|
98
|
+
ctx.state.identityChanged = true;
|
|
99
|
+
ctx.state.pendingLoginCode = null;
|
|
100
|
+
ctx.state.authChoiceOffered = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
112
104
|
app.post("/mcp", async (req, res) => {
|
|
113
105
|
const sessionId = req.headers["mcp-session-id"];
|
|
114
106
|
let transport = sessionId ? transports[sessionId] : undefined;
|
|
@@ -125,22 +117,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
125
117
|
}
|
|
126
118
|
|
|
127
119
|
if (transport) {
|
|
128
|
-
|
|
129
|
-
const prev = sessionAuth[sessionId];
|
|
130
|
-
if (prev) {
|
|
131
|
-
const prevSub = decodeJwt(prev).sub;
|
|
132
|
-
const newSub = decodeJwt(jwt).sub;
|
|
133
|
-
if (prevSub && newSub && prevSub !== newSub) {
|
|
134
|
-
const ctxForSession = sessionContexts[sessionId];
|
|
135
|
-
if (ctxForSession) {
|
|
136
|
-
ctxForSession.state.identityChanged = true;
|
|
137
|
-
ctxForSession.state.pendingLoginCode = null;
|
|
138
|
-
ctxForSession.state.authChoiceOffered = false;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
sessionAuth[sessionId] = jwt;
|
|
143
|
-
}
|
|
120
|
+
captureRequestIdentity(sessionId, jwt);
|
|
144
121
|
await transport.handleRequest(req, res, req.body);
|
|
145
122
|
return;
|
|
146
123
|
}
|
|
@@ -158,7 +135,6 @@ app.post("/mcp", async (req, res) => {
|
|
|
158
135
|
// id up front so it is also the trusted-server end-user id. (Distinct name from
|
|
159
136
|
// the incoming `sessionId` header above.)
|
|
160
137
|
const newSessionId = randomUUID();
|
|
161
|
-
if (jwt) sessionAuth[newSessionId] = jwt;
|
|
162
138
|
transport = new StreamableHTTPServerTransport({
|
|
163
139
|
sessionIdGenerator: () => newSessionId,
|
|
164
140
|
onsessioninitialized: (sid) => {
|
|
@@ -171,12 +147,11 @@ app.post("/mcp", async (req, res) => {
|
|
|
171
147
|
try { webCtx.logger?.flush("abandoned").catch(() => {}); } catch { /* never */ }
|
|
172
148
|
if (transport.sessionId) {
|
|
173
149
|
delete transports[transport.sessionId];
|
|
174
|
-
delete sessionAuth[transport.sessionId];
|
|
175
150
|
delete sessionContexts[transport.sessionId];
|
|
176
151
|
}
|
|
177
152
|
};
|
|
178
153
|
const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
|
|
179
|
-
const webCtx = makeWebContext(newSessionId);
|
|
154
|
+
const webCtx = makeWebContext(newSessionId, jwt);
|
|
180
155
|
sessionContexts[newSessionId] = webCtx;
|
|
181
156
|
// QA session log for this MCP session (dark by default). Keyed by the session
|
|
182
157
|
// id — the host connection boundary. No seed user_request here: current hosts
|
|
@@ -214,22 +189,7 @@ async function handleSessionRequest(req, res) {
|
|
|
214
189
|
return;
|
|
215
190
|
}
|
|
216
191
|
const jwt = bearerOf(req);
|
|
217
|
-
|
|
218
|
-
const prev = sessionAuth[sessionId];
|
|
219
|
-
if (prev) {
|
|
220
|
-
const prevSub = decodeJwt(prev).sub;
|
|
221
|
-
const newSub = decodeJwt(jwt).sub;
|
|
222
|
-
if (prevSub && newSub && prevSub !== newSub) {
|
|
223
|
-
const ctxForSession = sessionContexts[sessionId];
|
|
224
|
-
if (ctxForSession) {
|
|
225
|
-
ctxForSession.state.identityChanged = true;
|
|
226
|
-
ctxForSession.state.pendingLoginCode = null;
|
|
227
|
-
ctxForSession.state.authChoiceOffered = false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
sessionAuth[sessionId] = jwt;
|
|
232
|
-
}
|
|
192
|
+
captureRequestIdentity(sessionId, jwt);
|
|
233
193
|
await transport.handleRequest(req, res);
|
|
234
194
|
}
|
|
235
195
|
|