@cloudgrid-io/mcp 0.21.0 → 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/README.md +10 -10
- package/package.json +5 -3
- package/src/tools/deploy.js +85 -24
- package/src/web-identity.js +54 -0
- package/src/web.js +23 -63
package/README.md
CHANGED
|
@@ -197,16 +197,16 @@ recording whether the user agreed to share the conversation (`deploy.js:544`),
|
|
|
197
197
|
and a `context` object the model supplies — which by its declared shape
|
|
198
198
|
(`register.js:964-975`) can carry the failing inputs (for example the HTML or
|
|
199
199
|
args) and the original request. The transcript itself is not sent by this tool.
|
|
200
|
-
The report scrub is **
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`context` you send, or set `CLOUDGRID_TELEMETRY=off` to
|
|
209
|
-
entirely.
|
|
200
|
+
The report scrub is **two-layer**: values AND the `message` field are scanned
|
|
201
|
+
for known secret shapes (API key prefixes such as `sk-ant-`, `sk-proj-`,
|
|
202
|
+
`AKIA…`; JWTs; PEM private-key blocks; Bearer tokens; basic-auth URL
|
|
203
|
+
credentials) and matches are replaced with `[REDACTED]`. Arbitrary or
|
|
204
|
+
unknown-shape secrets that do not match a known pattern still transit
|
|
205
|
+
unredacted. The client-side scrub (`scrubReportContext` + `scrubSecretValues`
|
|
206
|
+
in deploy.js) runs before the POST; the server endpoint applies a server-side
|
|
207
|
+
key-name filter on top. To keep a secret out of a report, do not place it in
|
|
208
|
+
the `message` or `context` you send, or set `CLOUDGRID_TELEMETRY=off` to
|
|
209
|
+
disable reporting entirely.
|
|
210
210
|
|
|
211
211
|
**Version check.** At startup the local edition makes one request to
|
|
212
212
|
`https://registry.npmjs.org/@cloudgrid-io/mcp/latest` to detect a stale `.mcpb`
|
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
|
@@ -43,14 +43,36 @@ function isProvisioningCode(code) {
|
|
|
43
43
|
return PROVISIONING_CODES.has(code);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Same org->grid rename, same dual-accept safety (cloudgrid-io/cloudgrid#2673).
|
|
47
|
+
// These two codes gate the first-time-user "no grid" path and the wrong-grid
|
|
48
|
+
// path — if the MCP recognizes only the old ORG_* spelling, the API renaming it
|
|
49
|
+
// silently darks those paths (the account-has-no-grid guidance and the typo
|
|
50
|
+
// hint stop firing). NO_ACTIVE_GRID is ALREADY emitted by the API alongside
|
|
51
|
+
// NO_ACTIVE_ORG; GRID_NOT_ACCESSIBLE is the anticipated rename of
|
|
52
|
+
// ORG_NOT_ACCESSIBLE. Accept both spellings so the API rename can land anytime.
|
|
53
|
+
const NO_ACTIVE_GRID_CODES = new Set(["NO_ACTIVE_GRID", "NO_ACTIVE_ORG"]);
|
|
54
|
+
function isNoActiveGridCode(code) {
|
|
55
|
+
return NO_ACTIVE_GRID_CODES.has(code);
|
|
56
|
+
}
|
|
57
|
+
const GRID_NOT_ACCESSIBLE_CODES = new Set(["GRID_NOT_ACCESSIBLE", "ORG_NOT_ACCESSIBLE"]);
|
|
58
|
+
function isGridNotAccessibleCode(code) {
|
|
59
|
+
return GRID_NOT_ACCESSIBLE_CODES.has(code);
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
|
|
47
|
-
// ──
|
|
48
|
-
// Fetches the signed-in user's
|
|
49
|
-
// 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
|
|
50
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.
|
|
51
73
|
export async function fetchUserOrgs(token) {
|
|
52
74
|
try {
|
|
53
|
-
const res = await fetch(`${API_BASE}/api/v2/
|
|
75
|
+
const res = await fetch(`${API_BASE}/api/v2/grids`, {
|
|
54
76
|
headers: { Authorization: `Bearer ${token}` },
|
|
55
77
|
});
|
|
56
78
|
if (!res.ok) return [];
|
|
@@ -480,13 +502,51 @@ const REPORT_SECRET_KEY_PATTERNS = [
|
|
|
480
502
|
/access[_-]?key/i,
|
|
481
503
|
];
|
|
482
504
|
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
505
|
+
// Value-level secret patterns — must stay in sync with TEXT_SECRET_PATTERNS
|
|
506
|
+
// in session-logger.js (which scrubs free text for session logs). The lists are
|
|
507
|
+
// separate because this module operates on structured object values via
|
|
508
|
+
// scrubReportContext, while session-logger operates on free text via scrubText.
|
|
509
|
+
// Both scan string content for the same secret shapes; if you add a pattern to
|
|
510
|
+
// one, add it to the other.
|
|
511
|
+
const REPORT_SECRET_VALUE_PATTERNS = [
|
|
512
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
513
|
+
/eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{0,}/g,
|
|
514
|
+
/sk-ant-[A-Za-z0-9_-]{16,}/g,
|
|
515
|
+
/sk-or-v1-[A-Za-z0-9]{16,}/g,
|
|
516
|
+
/sk-proj-[A-Za-z0-9_-]{16,}/g,
|
|
517
|
+
/\bsk-[A-Za-z0-9]{20,}/g,
|
|
518
|
+
/AIza[0-9A-Za-z_-]{30,}/g,
|
|
519
|
+
/ghp_[A-Za-z0-9]{30,}/g,
|
|
520
|
+
/github_pat_[A-Za-z0-9_]{30,}/g,
|
|
521
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
522
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
523
|
+
/\bsk_(?:live|test)_[0-9A-Za-z]{16,}\b/g,
|
|
524
|
+
/\bBearer\s+[A-Za-z0-9._-]{8,}/gi,
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
// Replace known secret shapes inside a string value with [REDACTED].
|
|
528
|
+
// Complementary to the key-name check in scrubReportContext — this catches
|
|
529
|
+
// secrets that live inside values (e.g. an API key pasted into HTML source).
|
|
530
|
+
function scrubSecretValues(str) {
|
|
531
|
+
if (typeof str !== "string") return str;
|
|
532
|
+
let out = str;
|
|
533
|
+
for (const re of REPORT_SECRET_VALUE_PATTERNS) {
|
|
534
|
+
out = out.replace(re, "[REDACTED]");
|
|
535
|
+
}
|
|
536
|
+
out = out.replace(/([a-zA-Z][a-zA-Z0-9+.\-]*:\/\/)[^\s:@/]+:[^\s:@/]+@/g, "$1[REDACTED]@");
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Client-side scrub of the report context. Two layers:
|
|
541
|
+
// 1. Key-name check: values under secret-looking KEYS are fully redacted.
|
|
542
|
+
// 2. Value scan: string values in the tree (up to depth 5) are scanned for known
|
|
543
|
+
// secret shapes (API keys, Bearer tokens) and matches are replaced.
|
|
544
|
+
// Bounded depth so a pathological object can't loop.
|
|
488
545
|
export function scrubReportContext(obj, depth = 0) {
|
|
489
|
-
if (depth > 5 || obj === null || typeof obj !== "object")
|
|
546
|
+
if (depth > 5 || obj === null || typeof obj !== "object") {
|
|
547
|
+
// Leaf value — scrub known secret shapes from strings.
|
|
548
|
+
return typeof obj === "string" ? scrubSecretValues(obj) : obj;
|
|
549
|
+
}
|
|
490
550
|
if (Array.isArray(obj)) return obj.map((item) => scrubReportContext(item, depth + 1));
|
|
491
551
|
const out = {};
|
|
492
552
|
for (const [key, value] of Object.entries(obj)) {
|
|
@@ -575,11 +635,11 @@ export async function runReport(
|
|
|
575
635
|
// category: default "mcp" (or the failing tool name the agent passes).
|
|
576
636
|
category: typeof category === "string" && category.trim() ? category.trim() : "mcp",
|
|
577
637
|
app: "mcp",
|
|
578
|
-
message: summary.slice(0, 5000),
|
|
638
|
+
message: scrubSecretValues(summary).slice(0, 5000),
|
|
579
639
|
context: safeContext,
|
|
580
640
|
// Diagnostic pivots (match the CLI) — only when the agent forwards them.
|
|
581
641
|
...(typeof trace_id === "string" && trace_id ? { trace_id } : {}),
|
|
582
|
-
...(typeof failed_step === "string" && failed_step ? { failed_step } : {}),
|
|
642
|
+
...(typeof failed_step === "string" && failed_step ? { failed_step: scrubSecretValues(failed_step) } : {}),
|
|
583
643
|
...(typeof http_status === "number" && Number.isFinite(http_status) ? { http_status } : {}),
|
|
584
644
|
// Attribution, ALSO top-level (persisted once the handler accepts these keys;
|
|
585
645
|
// context.origin is the fallback until then).
|
|
@@ -750,22 +810,23 @@ export async function runPull(ctx, { claim_token, claim_url, entity_id, grid } =
|
|
|
750
810
|
};
|
|
751
811
|
}
|
|
752
812
|
const code = data?.error?.code || null;
|
|
753
|
-
// ORG_NOT_ACCESSIBLE: the `grid` param has a typo or
|
|
754
|
-
// member of that grid. The server message already contains
|
|
755
|
-
// hint listing valid grid slugs — surface it so the agent
|
|
756
|
-
|
|
813
|
+
// ORG_NOT_ACCESSIBLE / GRID_NOT_ACCESSIBLE: the `grid` param has a typo or
|
|
814
|
+
// the user isn't a member of that grid. The server message already contains
|
|
815
|
+
// an "Available:" hint listing valid grid slugs — surface it so the agent
|
|
816
|
+
// can self-correct.
|
|
817
|
+
if (isGridNotAccessibleCode(code)) {
|
|
757
818
|
const serverMsg = data?.error?.message || "The specified grid is not accessible.";
|
|
758
819
|
const hint = data?.error?.details?.[0]?.hint || "";
|
|
759
820
|
return {
|
|
760
821
|
text:
|
|
761
822
|
`${serverMsg}${hint ? ` ${hint}` : ""} ` +
|
|
762
823
|
`Check the grid slug for typos and retry grid_pull with the correct \`grid\` parameter.`,
|
|
763
|
-
structured: { error: { code
|
|
824
|
+
structured: { error: { code } },
|
|
764
825
|
};
|
|
765
826
|
}
|
|
766
|
-
// NO_ACTIVE_ORG: the account has no grid at all — route to
|
|
767
|
-
// creation instead of the misleading "no push access" advice.
|
|
768
|
-
if (code
|
|
827
|
+
// NO_ACTIVE_ORG / NO_ACTIVE_GRID: the account has no grid at all — route to
|
|
828
|
+
// in-flow grid creation instead of the misleading "no push access" advice.
|
|
829
|
+
if (isNoActiveGridCode(code)) {
|
|
769
830
|
return {
|
|
770
831
|
text:
|
|
771
832
|
"The account has no grid yet. Do not send the user to the console — create one from here: " +
|
|
@@ -1346,10 +1407,10 @@ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn
|
|
|
1346
1407
|
: "Your sign-in is missing or expired. Run grid_login, then retry the same grid_plug. Do not offer anonymous publishing as a fix for a failed sign-in; anonymous is only for a user who explicitly asks to publish without attribution.";
|
|
1347
1408
|
}
|
|
1348
1409
|
if (status === 403) {
|
|
1349
|
-
// NO_ACTIVE_ORG is not a role problem — the account has no
|
|
1350
|
-
// The generic pull/pickup hint here sent a first-time user in
|
|
1351
|
-
// (field bug 2026-07-27); route them to in-flow grid creation instead.
|
|
1352
|
-
if (code
|
|
1410
|
+
// NO_ACTIVE_ORG / NO_ACTIVE_GRID is not a role problem — the account has no
|
|
1411
|
+
// grid at all. The generic pull/pickup hint here sent a first-time user in
|
|
1412
|
+
// circles (field bug 2026-07-27); route them to in-flow grid creation instead.
|
|
1413
|
+
if (isNoActiveGridCode(code)) {
|
|
1353
1414
|
return "The account has no grid yet. Do not send the user to the console — create one from here: suggest a short slug, confirm it with the user, call grid_create_grid, then re-call grid_plug with grid: <slug>.";
|
|
1354
1415
|
}
|
|
1355
1416
|
return "You lack the role to plug this target. To get push access to the SAME entity, use `grid_collab` — it grants permission only and fetches nothing, so run `grid_pull` again once it is granted (if the owner gates access, it becomes a request they approve). To make your own separate copy (a fork), use `grid_pickup`.";
|
|
@@ -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
|
|