@cloudgrid-io/mcp 0.21.13 → 0.21.15
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 +2 -2
- package/package.json +5 -1
- package/src/oauth.js +46 -2
- package/src/tools/constants.js +10 -0
- package/src/tools/deploy.js +153 -44
- package/src/tools/register.js +72 -4
- package/src/web-identity.js +16 -2
- package/src/web-observability.js +120 -0
- package/src/web.js +32 -43
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ posted). `CLOUDGRID_QA_IDLE_MS` overrides the abandoned-idle window (default
|
|
|
84
84
|
<!-- gen:tools -->
|
|
85
85
|
## Tools
|
|
86
86
|
|
|
87
|
-
38 tools registered (
|
|
87
|
+
38 tools registered (17 shared across both editions, 21 local-only). No deprecated aliases. MIN_CLI_VERSION: 0.15.26.
|
|
88
88
|
|
|
89
89
|
### Direct-API tools (both editions)
|
|
90
90
|
|
|
@@ -106,6 +106,7 @@ posted). `CLOUDGRID_QA_IDLE_MS` overrides the abandoned-idle window (default
|
|
|
106
106
|
| `grid_get_template` | corpus | Load a specific CloudGrid workflow, template, example, rule, or doc by name — deterministic retrieval from the bundle... |
|
|
107
107
|
| `grid_report` | API | Report a genuine CloudGrid failure to the CloudGrid team — ONLY with the user's explicit consent. |
|
|
108
108
|
| `grid_hello` | API | Create a minimal hello page and plug it into the user's grid, returning the live URL. |
|
|
109
|
+
| `grid_delete` | `grid delete entity` | Archive a CloudGrid inspiration. |
|
|
109
110
|
|
|
110
111
|
The direct-API tools call the platform without the CLI, so they also work in
|
|
111
112
|
the web edition. `grid_login` writes the same `~/.cloudgrid/credentials` the
|
|
@@ -129,7 +130,6 @@ CLI uses, so the two share one identity.
|
|
|
129
130
|
| `grid_edit_existing_app` | `grid pull` | Continue/edit an EXISTING entity locally: download its source + cloudgrid.yaml and link the folder so your next `grid... |
|
|
130
131
|
| `grid_rename` | `grid rename` | Rename a CloudGrid entity's display name (slug stays the same). |
|
|
131
132
|
| `grid_take_offline` | `grid unplug` | Take an entity off the grid. |
|
|
132
|
-
| `grid_delete` | `grid delete entity` | Archive a CloudGrid inspiration. |
|
|
133
133
|
| `grid_rollback_deploy` | `grid rollback` | Rollback an entity to a previous version. |
|
|
134
134
|
| `grid_list_versions` | `grid versions` | List published versions for an entity. |
|
|
135
135
|
| `grid_set_env` | `grid env` | Manage environment variables for an entity. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.15",
|
|
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": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"test:widgets": "node test/apps-widgets.test.mjs",
|
|
59
59
|
"test:set-sharing": "node test/set-sharing.test.mjs",
|
|
60
60
|
"test:check-deploy": "node test/check-deploy.test.mjs",
|
|
61
|
+
"test:console-grid-url": "node test/console-grid-url.test.mjs",
|
|
61
62
|
"test:staleness": "node test/staleness.test.mjs",
|
|
62
63
|
"test:proxy": "node test/proxy.test.mjs",
|
|
63
64
|
"test:system-ca": "node test/system-ca.test.mjs",
|
|
@@ -73,10 +74,13 @@
|
|
|
73
74
|
"test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
|
|
74
75
|
"test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
|
|
75
76
|
"test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
|
|
77
|
+
"test:oauth-observability": "node --test test/oauth-flow-observability.test.mjs",
|
|
76
78
|
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
|
|
79
|
+
"test:mcp-transport-observability": "node --test test/mcp-transport-observability.test.mjs",
|
|
77
80
|
"test:internal-refs": "node --test test/no-internal-refs.test.mjs",
|
|
78
81
|
"test:login-app": "node test/login-app.test.mjs",
|
|
79
82
|
"test:login-poller": "node test/login-poller.test.mjs",
|
|
83
|
+
"test:delete-hosted": "node test/delete-hosted.test.mjs",
|
|
80
84
|
"build:login-widget": "node scripts/build-login-widget.mjs"
|
|
81
85
|
},
|
|
82
86
|
"dependencies": {
|
package/src/oauth.js
CHANGED
|
@@ -33,6 +33,31 @@ function b64url(buf) {
|
|
|
33
33
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Observability helpers (#345). The OAuth flow was only observable on the paths
|
|
37
|
+
// it did NOT take — a refused token exchange logs (denyToken), but a successful
|
|
38
|
+
// one and the authorize arrival logged nothing, so "no log line" meant both
|
|
39
|
+
// "never happened" and "worked fine". These emit SHAPE and PRESENCE, never
|
|
40
|
+
// values: this is an auth path and a log aggregator is not private. See the
|
|
41
|
+
// issue's redaction list — code, verifier, code_challenge value, JWT, secret,
|
|
42
|
+
// the full client_id, and the full redirect_uri all stay out of the log.
|
|
43
|
+
|
|
44
|
+
// A ~12-char prefix correlates lines without printing the signed client_id,
|
|
45
|
+
// which embeds the redirect set.
|
|
46
|
+
function clientIdPrefix(clientId) {
|
|
47
|
+
return clientId ? `${String(clientId).slice(0, 12)}…` : "absent";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The HOST is the diagnostic value; the path can carry a per-install identifier,
|
|
51
|
+
// so it is dropped.
|
|
52
|
+
function hostOf(uri) {
|
|
53
|
+
if (!uri) return "absent";
|
|
54
|
+
try {
|
|
55
|
+
return new URL(String(uri)).host;
|
|
56
|
+
} catch {
|
|
57
|
+
return "invalid";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
// --- Stateless client registration (#3060) -----------------------------------
|
|
37
62
|
// A `client_id` is a signed token that CARRIES its own redirect-URI set, not a
|
|
38
63
|
// random handle into a process-local Map. Registration signs the (sorted,
|
|
@@ -278,6 +303,16 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
278
303
|
app.get("/oauth/authorize", (req, res) => {
|
|
279
304
|
sweep(authSessions);
|
|
280
305
|
const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
|
|
306
|
+
// Observability (#345): the request SHAPE the client actually sent, logged
|
|
307
|
+
// before any check so a rejected request is still visible. Presence, not
|
|
308
|
+
// values — nothing here is reusable in a log aggregator.
|
|
309
|
+
console.error(
|
|
310
|
+
`[oauth] authorize request: client_id=${clientIdPrefix(client_id)} ` +
|
|
311
|
+
`response_type=${response_type ? String(response_type) : "absent"} ` +
|
|
312
|
+
`state=${state ? "present" : "absent"} ` +
|
|
313
|
+
`pkce=${code_challenge ? (code_challenge_method ? String(code_challenge_method) : "present(no-method)") : "absent"} ` +
|
|
314
|
+
`redirect_host=${hostOf(redirect_uri)}`,
|
|
315
|
+
);
|
|
281
316
|
const client = verifyClientId(client_id, clientSecrets.secrets);
|
|
282
317
|
if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
|
|
283
318
|
res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
|
|
@@ -331,6 +366,10 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
331
366
|
console.error("[oauth] authorization code issued after sign-in; awaiting token exchange");
|
|
332
367
|
const sep = sess.redirect_uri.includes("?") ? "&" : "?";
|
|
333
368
|
const redirect = `${sess.redirect_uri}${sep}code=${encodeURIComponent(code)}${sess.state ? `&state=${encodeURIComponent(sess.state)}` : ""}`;
|
|
369
|
+
// Observability (#345): we handed control back to the client, and to
|
|
370
|
+
// where. The host only — the full redirect carries the code and a
|
|
371
|
+
// per-install path.
|
|
372
|
+
console.error(`[oauth] redirect handed back to client; redirect_host=${hostOf(sess.redirect_uri)}`);
|
|
334
373
|
res.json({ status: "ready", redirect });
|
|
335
374
|
return;
|
|
336
375
|
}
|
|
@@ -386,13 +425,15 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
386
425
|
denyToken(res, "unsupported_grant_type", "grant_type must be authorization_code", `(got ${JSON.stringify(grant_type ?? null)})`);
|
|
387
426
|
return;
|
|
388
427
|
}
|
|
389
|
-
const
|
|
428
|
+
const codeKey = String(code);
|
|
429
|
+
const rec = authCodes.get(codeKey);
|
|
390
430
|
if (!rec) {
|
|
391
431
|
// Also the retry case: codes are single-use, so a second exchange of a
|
|
392
432
|
// code that already succeeded lands here.
|
|
393
433
|
denyToken(res, "invalid_grant", "authorization code is unknown, expired or already exchanged");
|
|
394
434
|
return;
|
|
395
435
|
}
|
|
436
|
+
authCodes.delete(codeKey); // single use — consume before validation so a failed attempt cannot be retried
|
|
396
437
|
if (rec.client_id !== String(client_id)) {
|
|
397
438
|
denyToken(
|
|
398
439
|
res,
|
|
@@ -412,9 +453,12 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
412
453
|
denyToken(res, "invalid_grant", code_verifier === undefined ? "code_verifier is required (PKCE)" : "PKCE verification failed");
|
|
413
454
|
return;
|
|
414
455
|
}
|
|
415
|
-
authCodes.delete(String(code)); // single use
|
|
416
456
|
const claims = decodeJwt(rec.jwt);
|
|
417
457
|
const expiresIn = claims.exp ? Math.max(60, claims.exp - Math.floor(Date.now() / 1000)) : 30 * 86400;
|
|
458
|
+
// Observability (#345): the success path. Without this, a successful
|
|
459
|
+
// exchange and total silence were the same observation. The token is never
|
|
460
|
+
// logged; client_id is a prefix only.
|
|
461
|
+
console.error(`[oauth] token exchange succeeded; access token issued (client_id=${clientIdPrefix(client_id)})`);
|
|
418
462
|
res.json({ access_token: rec.jwt, token_type: "Bearer", expires_in: expiresIn, scope: "cloudgrid" });
|
|
419
463
|
});
|
|
420
464
|
}
|
package/src/tools/constants.js
CHANGED
|
@@ -29,6 +29,16 @@ export const ANON_HTML_MAX_BYTES = 2_000_000;
|
|
|
29
29
|
export const AUTHED_HTML_MAX_BYTES = 25_000_000;
|
|
30
30
|
export const CONSOLE_URL = "https://console.cloudgrid.io/";
|
|
31
31
|
|
|
32
|
+
// The console link handed to a user AFTER a plug. When the grid slug is known it
|
|
33
|
+
// points at THAT grid (verified live: /home?grid=<slug> -> 200) instead of the
|
|
34
|
+
// bare root, so a user with more than one grid lands on the one they just
|
|
35
|
+
// plugged to (#355). Falls back to the root when the slug is unknown. The
|
|
36
|
+
// earlier code hardcoded the root on the belief that "there is no per-grid page"
|
|
37
|
+
// — that was wrong; only the /grids/<slug> form 404s, /home?grid= does not.
|
|
38
|
+
export function consoleGridUrl(slug) {
|
|
39
|
+
return slug ? `${CONSOLE_URL}home?grid=${encodeURIComponent(slug)}` : CONSOLE_URL;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
// Display labels for visibility values (two-axis model). The OPTIONS list is what
|
|
33
43
|
// the post-plug ask offers; the LABELS map also keeps legacy keys (org,
|
|
34
44
|
// authenticated, space) so an entity's CURRENT stored value still renders — do
|
package/src/tools/deploy.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
MCP_VERSION,
|
|
12
12
|
ANON_HTML_MAX_BYTES,
|
|
13
13
|
AUTHED_HTML_MAX_BYTES,
|
|
14
|
-
|
|
14
|
+
consoleGridUrl,
|
|
15
15
|
VISIBILITY_LABELS,
|
|
16
16
|
VISIBILITY_OPTIONS,
|
|
17
17
|
APPS_WIDGETS_ENABLED,
|
|
@@ -71,30 +71,31 @@ function isGridNotAccessibleCode(code) {
|
|
|
71
71
|
// answer identically today, so moving now is a no-op that stops this tool
|
|
72
72
|
// breaking when `/orgs` goes.
|
|
73
73
|
export async function fetchUserOrgs(token) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
|
|
82
|
-
const grids = Array.isArray(data?.grids)
|
|
83
|
-
? data.grids
|
|
84
|
-
: Array.isArray(data?.orgs)
|
|
85
|
-
? data.orgs
|
|
86
|
-
: Array.isArray(data)
|
|
87
|
-
? data
|
|
88
|
-
: [];
|
|
89
|
-
return grids.map((o) => ({
|
|
90
|
-
slug: o.slug ?? "",
|
|
91
|
-
name: o.name ?? o.slug ?? "",
|
|
92
|
-
role: o.role ?? "member",
|
|
93
|
-
render_ready: o.render_ready ?? true, // default true for older APIs
|
|
94
|
-
}));
|
|
95
|
-
} catch {
|
|
96
|
-
return [];
|
|
74
|
+
const res = await fetch(`${API_BASE}/api/v2/grids`, {
|
|
75
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
76
|
+
});
|
|
77
|
+
if (res.status === 401) {
|
|
78
|
+
const err = new Error(`Grid listing failed (HTTP 401)`);
|
|
79
|
+
err.status = 401;
|
|
80
|
+
throw err;
|
|
97
81
|
}
|
|
82
|
+
if (!res.ok) throw new Error(`Grid listing failed (HTTP ${res.status})`);
|
|
83
|
+
const data = await res.json();
|
|
84
|
+
// 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
|
|
85
|
+
// `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
|
|
86
|
+
const grids = Array.isArray(data?.grids)
|
|
87
|
+
? data.grids
|
|
88
|
+
: Array.isArray(data?.orgs)
|
|
89
|
+
? data.orgs
|
|
90
|
+
: Array.isArray(data)
|
|
91
|
+
? data
|
|
92
|
+
: [];
|
|
93
|
+
return grids.map((o) => ({
|
|
94
|
+
slug: o.slug ?? "",
|
|
95
|
+
name: o.name ?? o.slug ?? "",
|
|
96
|
+
role: o.role ?? "member",
|
|
97
|
+
render_ready: o.render_ready ?? true, // default true for older APIs
|
|
98
|
+
}));
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
// ── Shared grid disambiguation (grid_plug) ──────────────────
|
|
@@ -104,7 +105,8 @@ export async function fetchUserOrgs(token) {
|
|
|
104
105
|
// - >1 grid and none supplied → { picker } (a ready-to-return result)
|
|
105
106
|
// - exactly one grid → { single: annotatedOrg } — the caller
|
|
106
107
|
// decides how to treat a not-ready single grid (drop blocks; plug warns)
|
|
107
|
-
// - no orgs
|
|
108
|
+
// - no orgs → { picker } needs_grid_create (asks)
|
|
109
|
+
// - listing failed → { picker } { error: true } (asks; NOT needs_grid_create)
|
|
108
110
|
// Every return shape either ASKS (a picker) or names an explicit grid — none is a
|
|
109
111
|
// silent proceed. That is load-bearing: register.js returns the picker or sets
|
|
110
112
|
// input.grid from `grid`/`single` before runPlug, so the create path never
|
|
@@ -119,7 +121,26 @@ export async function fetchUserOrgs(token) {
|
|
|
119
121
|
// dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
|
|
120
122
|
export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
|
|
121
123
|
const listGrids = deps.fetchUserOrgs || fetchUserOrgs;
|
|
122
|
-
|
|
124
|
+
let grids;
|
|
125
|
+
try {
|
|
126
|
+
grids = await listGrids(token);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.status === 401) {
|
|
129
|
+
ctx.markTokenRevoked?.(token);
|
|
130
|
+
return {
|
|
131
|
+
picker: {
|
|
132
|
+
text: "Session expired — please log in again.",
|
|
133
|
+
structured: { error: true },
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
picker: {
|
|
139
|
+
text: "Could not fetch your grids — please try again.",
|
|
140
|
+
structured: { error: true },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
123
144
|
const activeGrid = await ctx.getActiveGrid();
|
|
124
145
|
const matched = suppliedGrid && grids.find((o) => o.slug === suppliedGrid);
|
|
125
146
|
if (matched) {
|
|
@@ -1017,6 +1038,80 @@ export async function runCollab(ctx, { entity_id, grid } = {}) {
|
|
|
1017
1038
|
};
|
|
1018
1039
|
}
|
|
1019
1040
|
|
|
1041
|
+
// ── grid_delete (hosted edition) — archive an inspiration via the API (#343) ──
|
|
1042
|
+
// The handler in register.js resolves auth + grid (via resolveGridOrAsk) before
|
|
1043
|
+
// calling this function, so token and grid are always present.
|
|
1044
|
+
export async function runDelete(ctx, { name, grid, confirm } = {}) {
|
|
1045
|
+
if (confirm !== true) {
|
|
1046
|
+
throw new Error("`confirm` must be true to proceed with deletion.");
|
|
1047
|
+
}
|
|
1048
|
+
const token = await ctx.getToken();
|
|
1049
|
+
if (!token) {
|
|
1050
|
+
throw new Error("You are not signed in. Run grid_login first.");
|
|
1051
|
+
}
|
|
1052
|
+
if (!name) {
|
|
1053
|
+
throw new Error("`name` (entity slug) is required.");
|
|
1054
|
+
}
|
|
1055
|
+
if (!grid) {
|
|
1056
|
+
throw new Error("`grid` is required — the handler must resolve it before calling runDelete.");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const headers = {
|
|
1060
|
+
Authorization: `Bearer ${token}`,
|
|
1061
|
+
"X-CloudGrid-Grid": grid,
|
|
1062
|
+
"X-CloudGrid-Org": grid,
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
// Resolve the entity to get its id and kind.
|
|
1066
|
+
const lookupUrl = `${API_BASE}/api/v2/inspirations/${encodeURIComponent(name)}`;
|
|
1067
|
+
let lookupRes;
|
|
1068
|
+
try {
|
|
1069
|
+
lookupRes = await fetch(lookupUrl, { headers });
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (!lookupRes.ok) {
|
|
1075
|
+
const raw = await lookupRes.text();
|
|
1076
|
+
let data = null;
|
|
1077
|
+
try { data = JSON.parse(raw); } catch { /* */ }
|
|
1078
|
+
const msg = data?.error?.message || data?.message || raw || `HTTP ${lookupRes.status}`;
|
|
1079
|
+
if (lookupRes.status === 404) {
|
|
1080
|
+
throw new Error(`No inspiration found for '${name}'. Check the slug and grid.`);
|
|
1081
|
+
}
|
|
1082
|
+
throw new Error(`Lookup failed (HTTP ${lookupRes.status}): ${msg}`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const entity = await lookupRes.json();
|
|
1086
|
+
// GET /api/v2/inspirations/:slug returns `id`; defensive fallback for `entity_id`.
|
|
1087
|
+
const entityId = entity?.id || entity?.entity_id;
|
|
1088
|
+
if (!entityId) {
|
|
1089
|
+
throw new Error(`Could not resolve entity id for '${name}'.`);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Archive the inspiration.
|
|
1093
|
+
const deleteUrl = `${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`;
|
|
1094
|
+
let delRes;
|
|
1095
|
+
try {
|
|
1096
|
+
delRes = await fetch(deleteUrl, { method: "DELETE", headers });
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (!delRes.ok) {
|
|
1102
|
+
const raw = await delRes.text();
|
|
1103
|
+
let data = null;
|
|
1104
|
+
try { data = JSON.parse(raw); } catch { /* */ }
|
|
1105
|
+
const msg = data?.error?.message || data?.message || raw || `HTTP ${delRes.status}`;
|
|
1106
|
+
throw new Error(`Delete failed (HTTP ${delRes.status}): ${msg}`);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
text: `Deleted '${name}' (${entityId}) from ${grid}.`,
|
|
1111
|
+
structured: { deleted: true, entity_id: entityId, slug: name, grid },
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1020
1115
|
// Turn a policy 403 into a request: POST /:id/collab-requests and tell the user
|
|
1021
1116
|
// what happens next (mirrors CLI collab-requests.ts requestCollabAccess). A
|
|
1022
1117
|
// request IS a success outcome, so this returns a normal result, never throws for
|
|
@@ -1038,7 +1133,7 @@ async function requestCollabAccess(ctx, target, headers) {
|
|
|
1038
1133
|
return {
|
|
1039
1134
|
text:
|
|
1040
1135
|
`The owner gates who can collaborate on '${target}', so I asked them for push access on your behalf. ` +
|
|
1041
|
-
`They've been notified — once they approve,
|
|
1136
|
+
`They've been notified — once they approve, access is live immediately. Then run grid_pull to get the code.`,
|
|
1042
1137
|
structured: { access_requested: true, request_pending: true, owner_is_you: false, can_edit: false },
|
|
1043
1138
|
};
|
|
1044
1139
|
}
|
|
@@ -1483,6 +1578,9 @@ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
|
|
|
1483
1578
|
if (status === 400 && isGridHeaderError(code, msg)) {
|
|
1484
1579
|
return `Plug failed (HTTP 400): ${rewriteGridHeaderError(msg)}`;
|
|
1485
1580
|
}
|
|
1581
|
+
if (status === 400 && /cloudgrid\.yaml.*required/i.test(msg)) {
|
|
1582
|
+
return `Plug failed (HTTP 400): no cloudgrid.yaml found — provide it via the \`cloudgrid_yaml\` parameter or include a \`cloudgrid.yaml\` entry in \`artifact_files\`. Server: ${msg}`;
|
|
1583
|
+
}
|
|
1486
1584
|
const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
|
|
1487
1585
|
const guidance = errorGuidance({ status, code, ...ctxFlags });
|
|
1488
1586
|
return guidance ? `${base} — ${guidance}` : base;
|
|
@@ -1895,20 +1993,24 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1895
1993
|
}
|
|
1896
1994
|
if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
|
|
1897
1995
|
|
|
1996
|
+
// ── Resolve manifest from all sources ────────────────────────────────────────
|
|
1997
|
+
// The explicit `cloudgrid_yaml` param takes precedence; fall back to a
|
|
1998
|
+
// `cloudgrid.yaml` entry in `artifact_files`. This resolves the asymmetry
|
|
1999
|
+
// where create honoured artifact_files but re-plug ignored it (#314).
|
|
2000
|
+
const resolvedManifestYaml = cloudgrid_yaml
|
|
2001
|
+
|| (hasArtifacts && artifact_files.find((f) => f?.path === "cloudgrid.yaml")?.content)
|
|
2002
|
+
|| null;
|
|
2003
|
+
|
|
1898
2004
|
// ── CREATE manifest injection (issue #48) ───────────────────────────────────
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
// walk's uniform `application/octet-stream` content-type. The runtime build
|
|
1903
|
-
// orchestrator relies on the manifest leading the bundle (it drives the
|
|
1904
|
-
// service graph + the entity name). The inline `artifact_files` create used to
|
|
1905
|
-
// APPEND the `cloudgrid_yaml` manifest LAST and as `text/plain`, so a
|
|
2005
|
+
// The runtime build orchestrator relies on the manifest leading the bundle
|
|
2006
|
+
// (it drives the service graph + the entity name). The inline `artifact_files`
|
|
2007
|
+
// create used to APPEND the manifest LAST and as `text/plain`, so a
|
|
1906
2008
|
// multi-service runtime rolled out with no service graph (0 replicas /
|
|
1907
2009
|
// rollout_failed) and an auto `drop-XXXX` name. Fold the manifest into the
|
|
1908
2010
|
// artifact list as the first entry (deduping any `cloudgrid.yaml` the caller
|
|
1909
2011
|
// already inlined) so both create paths emit a byte-equivalent bundle.
|
|
1910
|
-
if (!isEdit &&
|
|
1911
|
-
const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(
|
|
2012
|
+
if (!isEdit && resolvedManifestYaml) {
|
|
2013
|
+
const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(resolvedManifestYaml, "utf8") };
|
|
1912
2014
|
const rest = artifacts.filter((a) => a.path !== "cloudgrid.yaml");
|
|
1913
2015
|
artifacts = [manifest, ...rest];
|
|
1914
2016
|
}
|
|
@@ -1933,7 +2035,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1933
2035
|
// (materializePlugTarball); an inspiration edit ignores its content.
|
|
1934
2036
|
form.append(
|
|
1935
2037
|
"cloudgrid.yaml",
|
|
1936
|
-
new Blob([
|
|
2038
|
+
new Blob([resolvedManifestYaml || ""], { type: "text/plain" }),
|
|
1937
2039
|
"cloudgrid.yaml",
|
|
1938
2040
|
);
|
|
1939
2041
|
}
|
|
@@ -1943,7 +2045,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1943
2045
|
// auto `drop-XXXX` slug. Harmless if the server owns slug generation; on a
|
|
1944
2046
|
// re-plug the entity's name is authoritative, so only send on create.
|
|
1945
2047
|
if (!isEdit) {
|
|
1946
|
-
const manifestName = parseManifestName(
|
|
2048
|
+
const manifestName = parseManifestName(resolvedManifestYaml);
|
|
1947
2049
|
if (manifestName) {
|
|
1948
2050
|
form.append("name", manifestName);
|
|
1949
2051
|
form.append("slug", manifestName);
|
|
@@ -2057,6 +2159,9 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2057
2159
|
) {
|
|
2058
2160
|
return plugViaCliFallback(ctx, artifacts, deps);
|
|
2059
2161
|
}
|
|
2162
|
+
if (res.status === 401 && authToken && !useAnonWire) {
|
|
2163
|
+
ctx.markTokenRevoked?.(authToken);
|
|
2164
|
+
}
|
|
2060
2165
|
throw new Error(plugErrorMessage(res.status, code, msg, flags));
|
|
2061
2166
|
}
|
|
2062
2167
|
|
|
@@ -2271,8 +2376,11 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2271
2376
|
// or re-plug (#321). The app URL says where the app lives; the console line says
|
|
2272
2377
|
// where the grid lives, so the common follow-up interaction (an edit) does not
|
|
2273
2378
|
// end with a bare URL and no way back. The console root self-routes per account
|
|
2274
|
-
// state (sign-in wall → their grid → onboarding)
|
|
2275
|
-
//
|
|
2379
|
+
// state (sign-in wall → their grid → onboarding). Point at the SPECIFIC grid
|
|
2380
|
+
// when we know its slug — /home?grid=<slug> renders (verified), so a user with
|
|
2381
|
+
// several grids lands on the one they just plugged to (#355). Only the
|
|
2382
|
+
// /grids/<slug> form 404s; /home?grid= does not. Falls back to the root when
|
|
2383
|
+
// the slug is unknown.
|
|
2276
2384
|
//
|
|
2277
2385
|
// NOT on the building branch: the line says "view it live", and a still-building
|
|
2278
2386
|
// app is not live — emitting it two lines under "Do NOT tell the user it is live"
|
|
@@ -2280,8 +2388,9 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2280
2388
|
// build the console line arrives when liveness is confirmed, in runCheckDeploy's
|
|
2281
2389
|
// success branch.
|
|
2282
2390
|
if (data.entity_id && !isBuilding) {
|
|
2283
|
-
|
|
2284
|
-
|
|
2391
|
+
const consoleUrl = consoleGridUrl(data.grid ?? grid ?? null);
|
|
2392
|
+
structured.console_url = consoleUrl;
|
|
2393
|
+
lines.push(`You can view it live in your grid along with all the apps you build here: ${consoleUrl}`);
|
|
2285
2394
|
}
|
|
2286
2395
|
|
|
2287
2396
|
// Visibility is the user's choice — never set silently. On a NEW deploy,
|
|
@@ -2774,8 +2883,8 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2774
2883
|
// runPlug (which returned "building"). Point the user at their grid too
|
|
2775
2884
|
// (#321) — this is where liveness is confirmed, so "view it live" is true.
|
|
2776
2885
|
text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
|
|
2777
|
-
`You can view it live in your grid along with all the apps you build here: ${
|
|
2778
|
-
structured: { status: "success", live: true, ...(url ? { url } : {}), console_url:
|
|
2886
|
+
`You can view it live in your grid along with all the apps you build here: ${consoleGridUrl(gridSlug)}`,
|
|
2887
|
+
structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: consoleGridUrl(gridSlug) },
|
|
2779
2888
|
};
|
|
2780
2889
|
}
|
|
2781
2890
|
if (verdict.status === "failed") {
|
package/src/tools/register.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
runReport,
|
|
26
26
|
runPull,
|
|
27
27
|
runCollab,
|
|
28
|
+
runDelete,
|
|
28
29
|
runPlug,
|
|
29
30
|
runPickup,
|
|
30
31
|
runCreateGrid,
|
|
@@ -273,7 +274,7 @@ export function registerTools(server, ctx) {
|
|
|
273
274
|
"grid_collab",
|
|
274
275
|
{
|
|
275
276
|
title: "Get push access to an app you don't own",
|
|
276
|
-
description: "Collab: GET PUSH ACCESS to the SAME live entity that someone else owns — you become a collaborator on THAT entity, not a copy. This grants PERMISSION ONLY and fetches nothing: after it succeeds, run grid_pull to get the code, then grid_plug (with its target_entity_id) updates the SHARED entity in place (the team sees the new version and can roll it back). This is NOT a fork — it never mints a new entity and never carries forked_from lineage; if you want your OWN separate copy instead, that is grid_pickup. If the owner GATES who may join, this does NOT dead-end: it sends the owner a request for access on your behalf, and once they approve
|
|
277
|
+
description: "Collab: GET PUSH ACCESS to the SAME live entity that someone else owns — you become a collaborator on THAT entity, not a copy. This grants PERMISSION ONLY and fetches nothing: after it succeeds, run grid_pull to get the code, then grid_plug (with its target_entity_id) updates the SHARED entity in place (the team sees the new version and can roll it back). This is NOT a fork — it never mints a new entity and never carries forked_from lineage; if you want your OWN separate copy instead, that is grid_pickup. If the owner GATES who may join, this does NOT dead-end: it sends the owner a request for access on your behalf, and once they approve, access is live immediately — no need to re-run grid_collab. Use this — not grid_pickup — whenever the user asks to \"collab\" on, \"join\", or \"get push/write access to\" an app they don't own. Requires sign-in. Calls the API directly (both editions).",
|
|
277
278
|
inputSchema: {
|
|
278
279
|
entity_id: z.string().describe("The app to get push access to: a canonical UUID or <grid-slug>/<entity-slug>."),
|
|
279
280
|
grid: z.string().optional().describe("Grid to resolve a bare slug in. Required only when a bare slug is ambiguous across grids you belong to."),
|
|
@@ -286,7 +287,7 @@ export function registerTools(server, ctx) {
|
|
|
286
287
|
owner_is_you: z.boolean().optional().describe("True if you already own it (nothing to grant)."),
|
|
287
288
|
can_edit: z.boolean().optional().describe("True once you have push access (owner or collaborator). False = still view-only."),
|
|
288
289
|
access_requested: z.boolean().optional().describe("True when the owner gates access and a request was sent on your behalf."),
|
|
289
|
-
request_pending: z.boolean().optional().describe("True when a request is awaiting the owner's decision —
|
|
290
|
+
request_pending: z.boolean().optional().describe("True when a request is awaiting the owner's decision — access is live as soon as they approve (no re-run needed)."),
|
|
290
291
|
},
|
|
291
292
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
292
293
|
},
|
|
@@ -501,7 +502,7 @@ export function registerTools(server, ctx) {
|
|
|
501
502
|
claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
|
|
502
503
|
claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
|
|
503
504
|
owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
|
|
504
|
-
console_url: z.string().optional().describe("
|
|
505
|
+
console_url: z.string().optional().describe("Emitted on every non-building plug (both editions): a link to the grid in the console."),
|
|
505
506
|
current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
|
|
506
507
|
visibility_options: z.array(z.object({
|
|
507
508
|
value: z.string().describe("Visibility value to pass to grid_visibility."),
|
|
@@ -531,6 +532,7 @@ export function registerTools(server, ctx) {
|
|
|
531
532
|
})).optional().describe("Alias of grids (legacy picker alias)."),
|
|
532
533
|
via: z.string().optional().describe("Recovery marker: 'cli-fallback' when a signed-in create was published through the bundled CloudGrid CLI."),
|
|
533
534
|
needs_grid_create: z.boolean().optional().describe("Zero-grid ask: the account has no grid yet — suggest a slug, confirm with the user, call grid_create_grid, then re-call grid_plug with grid: <slug>."),
|
|
535
|
+
error: z.boolean().optional().describe("True when the grid listing failed (network/auth error). The text carries a human-readable message. Do NOT treat this as needs_grid_create."),
|
|
534
536
|
},
|
|
535
537
|
// destructiveHint (M3): a re-plug with target_entity_id REPLACES what is
|
|
536
538
|
// live at a public URL in place. Versions + grid rollback exist, but the
|
|
@@ -903,7 +905,12 @@ export function registerTools(server, ctx) {
|
|
|
903
905
|
if (!token) {
|
|
904
906
|
return fail("You are not signed in. Run grid_login first.");
|
|
905
907
|
}
|
|
906
|
-
|
|
908
|
+
let grids;
|
|
909
|
+
try {
|
|
910
|
+
grids = await fetchUserOrgs(token);
|
|
911
|
+
} catch {
|
|
912
|
+
return fail("Could not list your grids — please check your connection and try again.");
|
|
913
|
+
}
|
|
907
914
|
if (grids.length === 0) {
|
|
908
915
|
// Structured output stays `orgs` (its declared schema); user text says grid.
|
|
909
916
|
return okResult({ text: "No grids found.", structured: { orgs: [] } });
|
|
@@ -1184,6 +1191,7 @@ export function registerTools(server, ctx) {
|
|
|
1184
1191
|
entity_id: z.string().optional().describe("Re-plug handle."),
|
|
1185
1192
|
needs_auth: z.boolean().optional().describe("True when the user must sign in first."),
|
|
1186
1193
|
needs_grid: z.boolean().optional().describe("True when the user must choose a grid."),
|
|
1194
|
+
error: z.boolean().optional().describe("True when the grid listing failed (network/auth error). The text carries a human-readable message. Do NOT treat this as needs_grid_create."),
|
|
1187
1195
|
},
|
|
1188
1196
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1189
1197
|
},
|
|
@@ -1238,6 +1246,66 @@ export function registerTools(server, ctx) {
|
|
|
1238
1246
|
},
|
|
1239
1247
|
);
|
|
1240
1248
|
|
|
1249
|
+
// grid_delete (hosted edition) — archive an inspiration via the direct API
|
|
1250
|
+
// (#343). The local edition has a CLI-wrapping version below the edition gate;
|
|
1251
|
+
// this direct-API version runs on web (hosted) where the CLI is unavailable.
|
|
1252
|
+
if (ctx.edition !== "local") {
|
|
1253
|
+
reg(
|
|
1254
|
+
"grid_delete",
|
|
1255
|
+
{
|
|
1256
|
+
title: "Delete an inspiration",
|
|
1257
|
+
description:
|
|
1258
|
+
"Archive a CloudGrid inspiration by slug. Destructive — requires `confirm: true`. " +
|
|
1259
|
+
"If the user has more than one grid and `grid` is not given, this returns needs_grid with " +
|
|
1260
|
+
"the list — ASK the user which grid, then call again with `grid` set. Never guess the grid. " +
|
|
1261
|
+
"Only works for inspirations (static pages); runtime apps and agents must be removed " +
|
|
1262
|
+
"with `grid unplug --hard` from the CLI. Requires sign-in. Calls the API directly.",
|
|
1263
|
+
inputSchema: {
|
|
1264
|
+
name: z.string().describe("Entity slug to delete (required)."),
|
|
1265
|
+
grid: z.string().optional().describe("Grid slug to delete from. Omit on the first call to be asked."),
|
|
1266
|
+
confirm: z.literal(true).describe("Must be true to proceed."),
|
|
1267
|
+
},
|
|
1268
|
+
outputSchema: {
|
|
1269
|
+
deleted: z.boolean().describe("True when the entity was archived."),
|
|
1270
|
+
entity_id: z.string().optional().describe("The archived entity's id."),
|
|
1271
|
+
slug: z.string().optional().describe("The archived entity's slug."),
|
|
1272
|
+
grid: z.string().optional().describe("The grid the entity was deleted from."),
|
|
1273
|
+
needs_auth: z.boolean().optional().describe("True when the user must sign in first."),
|
|
1274
|
+
needs_grid: z.boolean().optional().describe("True when the user must choose a grid."),
|
|
1275
|
+
error: z.boolean().optional().describe("True when the grid listing failed (network/auth error)."),
|
|
1276
|
+
},
|
|
1277
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1278
|
+
},
|
|
1279
|
+
async (input) => {
|
|
1280
|
+
try {
|
|
1281
|
+
// 1. AUTH
|
|
1282
|
+
const token = await ctx.getToken();
|
|
1283
|
+
if (!token) {
|
|
1284
|
+
return okResult({
|
|
1285
|
+
text: "Deleting an inspiration needs an account. Call grid_login to sign in, then re-call grid_delete.",
|
|
1286
|
+
structured: { needs_auth: true },
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// 2. GRID — never fall back to the active grid on a destructive op (#343).
|
|
1291
|
+
const decision = await resolveGridOrAsk(ctx, {
|
|
1292
|
+
token,
|
|
1293
|
+
suppliedGrid: input?.grid,
|
|
1294
|
+
edition: ctx.edition,
|
|
1295
|
+
});
|
|
1296
|
+
if (decision.picker) return okResult(decision.picker);
|
|
1297
|
+
const grid = decision.grid || decision.single?.slug;
|
|
1298
|
+
if (!grid) return fail("Could not resolve a grid. Pass `grid` explicitly.");
|
|
1299
|
+
|
|
1300
|
+
// 3. DELETE
|
|
1301
|
+
return okResult(await runDelete(ctx, { ...input, grid }));
|
|
1302
|
+
} catch (err) {
|
|
1303
|
+
return fail(err.message);
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1241
1309
|
if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
|
|
1242
1310
|
|
|
1243
1311
|
// ── CLI-wrapping tools (local edition only) ───────────────────────────────
|
package/src/web-identity.js
CHANGED
|
@@ -25,14 +25,20 @@ export function createWebIdentity({
|
|
|
25
25
|
let transportToken = initialTransportToken;
|
|
26
26
|
let explicitToken = null;
|
|
27
27
|
let hasExplicitLogin = false;
|
|
28
|
+
const revokedTokens = new Set();
|
|
28
29
|
|
|
29
30
|
// An explicit login wins over transport-token refreshes for the same subject.
|
|
30
31
|
// A genuine transport subject change ends that override so credentials from
|
|
31
32
|
// the previous person cannot persist on a connection now owned by another.
|
|
32
|
-
function
|
|
33
|
+
function rawEffectiveToken() {
|
|
33
34
|
return hasExplicitLogin ? explicitToken : transportToken;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function effectiveToken() {
|
|
38
|
+
const tok = rawEffectiveToken();
|
|
39
|
+
return revokedTokens.has(tok) ? null : tok;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
return {
|
|
37
43
|
captureTransportToken(jwt) {
|
|
38
44
|
const previousSub = decodeJwt(transportToken).sub;
|
|
@@ -62,7 +68,11 @@ export function createWebIdentity({
|
|
|
62
68
|
|
|
63
69
|
async getCredentialsStatus() {
|
|
64
70
|
const jwt = effectiveToken();
|
|
65
|
-
if (!jwt)
|
|
71
|
+
if (!jwt) {
|
|
72
|
+
// A revoked token is surfaced as expired so the client triggers re-auth.
|
|
73
|
+
const revoked = revokedTokens.has(rawEffectiveToken());
|
|
74
|
+
return { creds: null, expired: revoked || isTokenExpired(rawEffectiveToken(), now) };
|
|
75
|
+
}
|
|
66
76
|
if (isTokenExpired(jwt, now)) return { creds: null, expired: true };
|
|
67
77
|
return { creds: { jwt }, expired: false };
|
|
68
78
|
},
|
|
@@ -72,5 +82,9 @@ export function createWebIdentity({
|
|
|
72
82
|
hasExplicitLogin = true;
|
|
73
83
|
return decodeJwt(jwt);
|
|
74
84
|
},
|
|
85
|
+
|
|
86
|
+
markRevoked(jwt) {
|
|
87
|
+
if (jwt) revokedTokens.add(jwt);
|
|
88
|
+
},
|
|
75
89
|
};
|
|
76
90
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Observability for POST /mcp on the hosted edge (#353). Makes the four transport
|
|
2
|
+
// exits visible in the log — the 401 auth challenge, both 400 branches, and a
|
|
3
|
+
// successful session — so a client that completes OAuth and then fails to
|
|
4
|
+
// establish a session (#329) can be diagnosed instead of vanishing silently.
|
|
5
|
+
//
|
|
6
|
+
// Redaction — same standard as #347. Never log a bearer token, any fragment of
|
|
7
|
+
// it, or its length in a way that narrows it: log PRESENCE and PARSE RESULT only
|
|
8
|
+
// (see authHeaderState). Client-supplied text (clientInfo.name, the presented
|
|
9
|
+
// mcp-session-id) is untrusted — logSafe bounds its length and flattens control
|
|
10
|
+
// characters so a client cannot forge a log line or break log-line parsing.
|
|
11
|
+
//
|
|
12
|
+
// Scope note (mirroring #347's explicit statement): these helpers never emit the
|
|
13
|
+
// bearer or the mcp-session-id header value except as a bounded, flattened
|
|
14
|
+
// echo of the client-presented id on the rehydrate-failed path — an
|
|
15
|
+
// already-public session identifier, not a credential.
|
|
16
|
+
|
|
17
|
+
function defaultEmit(msg) {
|
|
18
|
+
// eslint-disable-next-line no-console
|
|
19
|
+
console.error(msg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Bound and flatten client-supplied text so it cannot forge a log line (strip
|
|
23
|
+
// CR/LF and other control chars) or flood one (cap length). Presence/shape only.
|
|
24
|
+
export function logSafe(value, max = 64) {
|
|
25
|
+
const s = String(value ?? "");
|
|
26
|
+
const flat = s.replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
27
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Classify the Authorization header WITHOUT revealing the token. Distinguishing
|
|
31
|
+
// absent / not-bearer / empty-token is the entire diagnostic value of the 401
|
|
32
|
+
// (#353): it says why no usable Bearer was found, never what it was. The four
|
|
33
|
+
// categories:
|
|
34
|
+
// absent — no Authorization header at all
|
|
35
|
+
// not-bearer — a header of some OTHER scheme (e.g. "Basic …")
|
|
36
|
+
// empty-token — the Bearer scheme but no token ("Bearer", "Bearer ")
|
|
37
|
+
// bearer — the Bearer scheme with a token (never reaches the 401)
|
|
38
|
+
// Note (#353): an HTTP parser strips a header value's trailing whitespace, so a
|
|
39
|
+
// client's "Bearer " arrives as "Bearer" — the empty-token case. This mirrors
|
|
40
|
+
// bearerOf() in web.js, which yields a token only for "Bearer <non-space…>".
|
|
41
|
+
export function authHeaderState(req) {
|
|
42
|
+
const raw = req?.headers?.authorization;
|
|
43
|
+
const h = Array.isArray(raw) ? raw[0] : raw;
|
|
44
|
+
if (h == null || h === "") return "absent";
|
|
45
|
+
if (!/^Bearer\b/i.test(h)) return "not-bearer";
|
|
46
|
+
return /^Bearer\s+\S/i.test(h) ? "bearer" : "empty-token";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Reduce an object to its KEY SHAPE — key names mapped to the TYPE of each value,
|
|
50
|
+
// recursively — never the values themselves. Used to log the client's advertised
|
|
51
|
+
// MCP capabilities at initialize (#297) while guaranteeing BY CONSTRUCTION that
|
|
52
|
+
// no value ever leaves the process: only key names and JS type names are emitted,
|
|
53
|
+
// so no token, header, tool argument, or user content can appear even if the
|
|
54
|
+
// object carried one. Depth- and breadth-capped so a malformed or huge object
|
|
55
|
+
// cannot blow the stack or flood the log.
|
|
56
|
+
export function keyShape(value, depth = 0) {
|
|
57
|
+
if (value === null || typeof value !== "object" || depth >= 6) return typeof value;
|
|
58
|
+
if (Array.isArray(value)) return `array[${value.length}]`;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const k of Object.keys(value).slice(0, 64)) out[k] = keyShape(value[k], depth + 1);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The 401 auth challenge on /mcp (REQUIRE_AUTH && no usable Bearer).
|
|
65
|
+
export function logAuthChallenge(req, emit = defaultEmit) {
|
|
66
|
+
emit(`cloudgrid-mcp: /mcp 401 auth-challenge authorization=${authHeaderState(req)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The first 400 branch: a request with no session id that is not an initialize.
|
|
70
|
+
export function logNoSession(emit = defaultEmit) {
|
|
71
|
+
emit("cloudgrid-mcp: /mcp 400 no-session reason=missing-session-id-and-not-initialize");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The second 400 branch: a stale-id rehydrate that failed to prime and fell back
|
|
75
|
+
// to the 400. session-id is the client-presented header — echoed, bounded, never
|
|
76
|
+
// a credential.
|
|
77
|
+
export function logRehydrateFailed(sessionId, emit = defaultEmit) {
|
|
78
|
+
emit(`cloudgrid-mcp: /mcp 400 rehydrate-failed session-id=${logSafe(sessionId)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The successful-session exit, emitted from the SDK's `oninitialized`. TWO lines
|
|
82
|
+
// with a DELIBERATE separation:
|
|
83
|
+
//
|
|
84
|
+
// 1. session-established — UNCONDITIONAL, emitted FIRST, outside any swallowing
|
|
85
|
+
// try/catch. The record that a session exists must never depend on the
|
|
86
|
+
// best-effort probe below. This is the fix for the #329 trap: the #297
|
|
87
|
+
// capability line is observation-only and could silently NOT fire on a good
|
|
88
|
+
// session, and its absence was mis-read as "no session existed". A separate
|
|
89
|
+
// always-emitted line removes that ambiguity.
|
|
90
|
+
// 2. client-capabilities — best-effort observation only (#297). A failure here
|
|
91
|
+
// is swallowed and MUST NOT suppress line 1 or affect the session. The
|
|
92
|
+
// synthetic rehydrate initialize is skipped so the capability signal stays
|
|
93
|
+
// clean (line 1 still fires for it — a rehydrate is a real session).
|
|
94
|
+
//
|
|
95
|
+
// `protocolVersion` is passed in (the SDK exposes no getter for the negotiated
|
|
96
|
+
// version post-init); web.js negotiates it with the same rule the SDK uses.
|
|
97
|
+
export function logInitialize(server, sessionId, protocolVersion, emit = defaultEmit) {
|
|
98
|
+
let clientName = "unknown";
|
|
99
|
+
try {
|
|
100
|
+
clientName = server?.getClientVersion?.()?.name ?? "unknown";
|
|
101
|
+
} catch {
|
|
102
|
+
/* name only — the session-established line below still fires */
|
|
103
|
+
}
|
|
104
|
+
emit(
|
|
105
|
+
`cloudgrid-mcp: session established session-id=${logSafe(sessionId)} ` +
|
|
106
|
+
`protocol=${logSafe(protocolVersion, 32)} client=${logSafe(clientName)}`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (clientName !== "cloudgrid-mcp-rehydrate") {
|
|
111
|
+
const caps = server?.getClientCapabilities?.();
|
|
112
|
+
const shape = caps && typeof caps === "object" ? keyShape(caps) : null;
|
|
113
|
+
emit(
|
|
114
|
+
`cloudgrid-mcp: client-capabilities client=${logSafe(clientName)} capabilities=${JSON.stringify(shape)}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* observation-only — never affects the session or the line above */
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/web.js
CHANGED
|
@@ -34,6 +34,12 @@ import { createSessionLogger } from "./session-logger.js";
|
|
|
34
34
|
import { createSink } from "./log-sink.js";
|
|
35
35
|
import { INSTRUCTIONS_WEB } from "./playbook.js";
|
|
36
36
|
import { createWebIdentity } from "./web-identity.js";
|
|
37
|
+
import {
|
|
38
|
+
logAuthChallenge,
|
|
39
|
+
logNoSession,
|
|
40
|
+
logRehydrateFailed,
|
|
41
|
+
logInitialize,
|
|
42
|
+
} from "./web-observability.js";
|
|
37
43
|
|
|
38
44
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)));
|
|
39
45
|
|
|
@@ -57,19 +63,14 @@ function bearerOf(req) {
|
|
|
57
63
|
return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// no
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
|
66
|
+
// Negotiate the MCP protocol version with the same rule the SDK's _oninitialize
|
|
67
|
+
// uses (a supported requested version wins, else LATEST). Used only to report the
|
|
68
|
+
// negotiated version on the session-established log line (#353) — the SDK exposes
|
|
69
|
+
// no getter for it post-init — and to build the synthetic rehydrate initialize.
|
|
70
|
+
function negotiateProtocol(requested) {
|
|
71
|
+
return typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
72
|
+
? requested
|
|
73
|
+
: LATEST_PROTOCOL_VERSION;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// A web session: identity lives in memory for the session's lifetime only. The
|
|
@@ -86,6 +87,7 @@ function makeWebContext(sessionId, identity) {
|
|
|
86
87
|
getCredentialsStatus: identity.getCredentialsStatus,
|
|
87
88
|
getActiveGrid: async () => null,
|
|
88
89
|
saveToken: identity.saveToken,
|
|
90
|
+
markTokenRevoked(jwt) { identity.markRevoked(jwt); },
|
|
89
91
|
savedLocationNote: () => "You are signed in for this session.",
|
|
90
92
|
trustedServer: TRUSTED_SERVER_SECRET
|
|
91
93
|
? { secret: TRUSTED_SERVER_SECRET, endUserId: sessionId }
|
|
@@ -139,7 +141,7 @@ function captureRequestIdentity(sessionId, jwt) {
|
|
|
139
141
|
// for a rehydrated (restart-evicted) session, the id the client keeps presenting.
|
|
140
142
|
// Identity derives ONLY from the credential passed here (`jwt`), never from
|
|
141
143
|
// anything remembered about the id: a session id is not proof of identity.
|
|
142
|
-
async function buildSession(sessionId, jwt) {
|
|
144
|
+
async function buildSession(sessionId, jwt, protocolVersion) {
|
|
143
145
|
const identity = createWebIdentity({ initialTransportToken: jwt });
|
|
144
146
|
const transport = new StreamableHTTPServerTransport({
|
|
145
147
|
sessionIdGenerator: () => sessionId,
|
|
@@ -181,28 +183,12 @@ async function buildSession(sessionId, jwt) {
|
|
|
181
183
|
} catch {
|
|
182
184
|
webCtx.state.client = null;
|
|
183
185
|
}
|
|
184
|
-
// #297:
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
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
|
-
}
|
|
186
|
+
// #353/#297: emit the session-established line UNCONDITIONALLY (id, protocol,
|
|
187
|
+
// client) and then the best-effort #297 capability key-shape line. The two
|
|
188
|
+
// are deliberately separate — see logInitialize: the fact that a session was
|
|
189
|
+
// created must not depend on the swallow-everything capability probe, whose
|
|
190
|
+
// silent non-firing on a good session sent the #329 diagnosis sideways.
|
|
191
|
+
logInitialize(server.server, sessionId, protocolVersion);
|
|
206
192
|
};
|
|
207
193
|
await server.connect(transport);
|
|
208
194
|
return { transport, identity };
|
|
@@ -224,11 +210,7 @@ async function primeSession(transport, req) {
|
|
|
224
210
|
if (!wst || typeof wst.handleRequest !== "function") {
|
|
225
211
|
throw new Error("cannot rehydrate session: transport internals unavailable");
|
|
226
212
|
}
|
|
227
|
-
const
|
|
228
|
-
const protocolVersion =
|
|
229
|
-
typeof proposed === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(proposed)
|
|
230
|
-
? proposed
|
|
231
|
-
: LATEST_PROTOCOL_VERSION;
|
|
213
|
+
const protocolVersion = negotiateProtocol(req.headers["mcp-protocol-version"]);
|
|
232
214
|
const initBody = {
|
|
233
215
|
jsonrpc: "2.0",
|
|
234
216
|
id: "cg-rehydrate-init",
|
|
@@ -256,6 +238,9 @@ app.post("/mcp", async (req, res) => {
|
|
|
256
238
|
const jwt = bearerOf(req);
|
|
257
239
|
|
|
258
240
|
if (REQUIRE_AUTH && !jwt) {
|
|
241
|
+
// #353: name WHY no usable Bearer was found — absent / not-bearer / empty-token
|
|
242
|
+
// — without revealing the token. This is the whole diagnostic value of the 401.
|
|
243
|
+
logAuthChallenge(req);
|
|
259
244
|
sendAuthChallenge(res);
|
|
260
245
|
return;
|
|
261
246
|
}
|
|
@@ -282,6 +267,8 @@ app.post("/mcp", async (req, res) => {
|
|
|
282
267
|
// A brand-new connection with no session id that is NOT an initialize is a real
|
|
283
268
|
// protocol error — there is nothing to recover.
|
|
284
269
|
if (!sessionId && !isInit) {
|
|
270
|
+
// #353: 400 branch 1 — a brand-new connection that is not an initialize.
|
|
271
|
+
logNoSession();
|
|
285
272
|
res.status(400).json({
|
|
286
273
|
jsonrpc: "2.0",
|
|
287
274
|
error: { code: -32000, message: "No valid session. Send an initialize request first." },
|
|
@@ -297,7 +284,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
297
284
|
// above), so an expired-but-present Bearer proceeds to the tool layer where
|
|
298
285
|
// the in-band needs_auth ask lives, rather than being challenged.
|
|
299
286
|
const newSessionId = randomUUID();
|
|
300
|
-
({ transport } = await buildSession(newSessionId, jwt));
|
|
287
|
+
({ transport } = await buildSession(newSessionId, jwt, negotiateProtocol(req.body?.params?.protocolVersion)));
|
|
301
288
|
await transport.handleRequest(req, res, req.body);
|
|
302
289
|
return;
|
|
303
290
|
}
|
|
@@ -317,7 +304,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
317
304
|
// stuck behind the same 401 (the #286 brick, on the rebuild path). Gating on
|
|
318
305
|
// expiry (hasUsableCredential) here is exactly what #288 got wrong and was pulled
|
|
319
306
|
// from production for.
|
|
320
|
-
({ transport } = await buildSession(sessionId, jwt));
|
|
307
|
+
({ transport } = await buildSession(sessionId, jwt, negotiateProtocol(req.headers["mcp-protocol-version"])));
|
|
321
308
|
// Rehydrate the evicted session. If priming fails (e.g. an SDK internal moved
|
|
322
309
|
// out from under us), tear the half-built session down and fall back to the
|
|
323
310
|
// original 400 rather than leaking a broken session or hanging the request —
|
|
@@ -325,6 +312,8 @@ app.post("/mcp", async (req, res) => {
|
|
|
325
312
|
try {
|
|
326
313
|
await primeSession(transport, req);
|
|
327
314
|
} catch {
|
|
315
|
+
// #353: 400 branch 2 — a stale-id rehydrate that could not be primed.
|
|
316
|
+
logRehydrateFailed(sessionId);
|
|
328
317
|
try { await transport.close(); } catch { /* best effort */ }
|
|
329
318
|
delete transports[sessionId];
|
|
330
319
|
delete sessionContexts[sessionId];
|