@cloudgrid-io/mcp 0.21.5 → 0.21.7
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 +11 -2
- package/src/tools/constants.js +12 -0
- package/src/tools/deploy.js +133 -21
- package/src/tools/register.js +70 -17
- package/src/web.js +37 -0
- package/src/widgets/grid-login/mcp-app.css +57 -0
- package/src/widgets/grid-login/mcp-app.html +26 -0
- package/src/widgets/grid-login/mcp-app.js +131 -0
- package/src/widgets/grid-login.html +150 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.7",
|
|
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": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"start:web": "node src/web.js",
|
|
18
18
|
"smoke": "node test/smoke.mjs",
|
|
19
19
|
"test:plug-wire": "node test/plug-wire.test.mjs",
|
|
20
|
+
"test:plug-grid-derive": "node test/plug-grid-derive.test.mjs",
|
|
20
21
|
"test:session-log": "node test/session-logger.test.mjs",
|
|
21
22
|
"test:error-reporting": "node test/error-reporting.test.mjs",
|
|
22
23
|
"test:source": "node test/source-fetch.test.mjs",
|
|
@@ -66,15 +67,23 @@
|
|
|
66
67
|
"test:pull-output-schema": "node test/pull-output-schema.test.mjs",
|
|
67
68
|
"test:web-identity": "node --test test/web-identity.test.mjs",
|
|
68
69
|
"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"
|
|
70
|
+
"test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
|
|
71
|
+
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
|
|
72
|
+
"test:internal-refs": "node --test test/no-internal-refs.test.mjs",
|
|
73
|
+
"test:login-app": "node test/login-app.test.mjs",
|
|
74
|
+
"build:login-widget": "node scripts/build-login-widget.mjs"
|
|
70
75
|
},
|
|
71
76
|
"dependencies": {
|
|
77
|
+
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
72
78
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
73
79
|
"express": "^4.22.2",
|
|
74
80
|
"fflate": "^0.8.3",
|
|
75
81
|
"undici": "^8.10.0",
|
|
76
82
|
"zod": "^3.23.0"
|
|
77
83
|
},
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"esbuild": "^0.25.0"
|
|
86
|
+
},
|
|
78
87
|
"license": "Apache-2.0",
|
|
79
88
|
"homepage": "https://github.com/cloudgrid-io/mcp",
|
|
80
89
|
"repository": {
|
package/src/tools/constants.js
CHANGED
|
@@ -60,6 +60,18 @@ export const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
|
|
|
60
60
|
export const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
|
|
61
61
|
export const LIVE_RESULT_HTML = readFileSync(new URL("../widgets/live-result.html", import.meta.url), "utf-8");
|
|
62
62
|
export const GRID_PICKER_HTML = readFileSync(new URL("../widgets/org-picker.html", import.meta.url), "utf-8");
|
|
63
|
+
|
|
64
|
+
// ── grid_login MCP App (SEP-1865, Claude web) ────────────────────────────────
|
|
65
|
+
// A SECOND, independent UI mechanism from the ChatGPT Apps-SDK widgets above.
|
|
66
|
+
// It is NOT gated behind MCP_APPS_WIDGETS: that flag controls only the
|
|
67
|
+
// openai/outputTemplate key ChatGPT reads, and it must stay exactly as it is.
|
|
68
|
+
// The login card binds via _meta.ui.resourceUri (the SEP-1865 key Claude reads),
|
|
69
|
+
// which ChatGPT ignores — so shipping it does not touch the ChatGPT path. A
|
|
70
|
+
// client without the UI extension ignores _meta.ui and still gets the text-first
|
|
71
|
+
// sign-in URL. The HTML is the self-contained bundle from
|
|
72
|
+
// scripts/build-login-widget.mjs (App class + card inlined; deny-by-default CSP).
|
|
73
|
+
export const GRID_LOGIN_APP_URI = "ui://grid-login/mcp-app.html";
|
|
74
|
+
export const GRID_LOGIN_APP_HTML = readFileSync(new URL("../widgets/grid-login.html", import.meta.url), "utf-8");
|
|
63
75
|
export const WIDGET_CSP = {
|
|
64
76
|
connectDomains: ["https://*.cloudgrid.io"],
|
|
65
77
|
resourceDomains: ["https://*.cloudgrid.io"],
|
package/src/tools/deploy.js
CHANGED
|
@@ -848,10 +848,8 @@ export async function runPull(ctx, { claim_token, claim_url, entity_id, grid } =
|
|
|
848
848
|
let msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
849
849
|
// The API's "Set the X-CloudGrid-Grid header" error is unactionable for an
|
|
850
850
|
// MCP client (no client can set HTTP headers on a tool call). Rewrite it to
|
|
851
|
-
// name the tool parameter the caller CAN set (#247).
|
|
852
|
-
if (res.status === 400
|
|
853
|
-
msg = "You belong to more than one grid. Pass the `grid` parameter to specify which grid to use.";
|
|
854
|
-
}
|
|
851
|
+
// name the tool parameter the caller CAN set (#247; class-level since #296).
|
|
852
|
+
if (res.status === 400) msg = rewriteGridHeaderError(msg);
|
|
855
853
|
throw new Error(`Pull failed (HTTP ${res.status}): ${msg}`);
|
|
856
854
|
}
|
|
857
855
|
|
|
@@ -968,7 +966,10 @@ export async function runCollab(ctx, { entity_id, grid } = {}) {
|
|
|
968
966
|
if (res.status === 400 && code === "NOT_A_RUNTIME") {
|
|
969
967
|
throw new Error(data?.error?.message || `'${target}' is an Inspiration, not an app or agent — it has no collaborators. Fork it with grid_pickup instead.`);
|
|
970
968
|
}
|
|
971
|
-
|
|
969
|
+
let msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
970
|
+
// Never surface the raw "Set the X-CloudGrid-Grid header" text — name the
|
|
971
|
+
// `grid` parameter instead (#296 class-level fix; see rewriteGridHeaderError).
|
|
972
|
+
if (res.status === 400) msg = rewriteGridHeaderError(msg);
|
|
972
973
|
throw new Error(`Collab failed (HTTP ${res.status}): ${msg}`);
|
|
973
974
|
}
|
|
974
975
|
|
|
@@ -1442,10 +1443,38 @@ export const REPORT_OFFER =
|
|
|
1442
1443
|
"then call grid_report with the error + the failed request context. " +
|
|
1443
1444
|
"Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
|
|
1444
1445
|
|
|
1446
|
+
// The API's "Set the X-CloudGrid-Grid header" 400 (code GRID_HEADER_REQUIRED) is
|
|
1447
|
+
// STRUCTURALLY unactionable for an MCP client: no client can set an HTTP header
|
|
1448
|
+
// on a tool call, so a model reading it starts guessing (observed live, #296).
|
|
1449
|
+
// Rewrite it to name the tool PARAMETER the caller CAN set. `param` is the grid-
|
|
1450
|
+
// carrying parameter on the calling tool (`grid` on plug/pull/collab/visibility;
|
|
1451
|
+
// `into_org_slug` on pickup). First added inline on the pull path for #247; this
|
|
1452
|
+
// is the class-level version applied at every authed write path.
|
|
1453
|
+
//
|
|
1454
|
+
// A message is a grid-header error when the code says so OR the text names the
|
|
1455
|
+
// header — matching both survives an API message reword. Anything else passes
|
|
1456
|
+
// through unchanged. The rewritten text never contains the words "header" or
|
|
1457
|
+
// "X-CloudGrid-Grid", nor the internal code.
|
|
1458
|
+
export function isGridHeaderError(code, msg) {
|
|
1459
|
+
return code === "GRID_HEADER_REQUIRED" || /X-CloudGrid-Grid/i.test(msg || "");
|
|
1460
|
+
}
|
|
1461
|
+
export function rewriteGridHeaderError(msg, param = "grid") {
|
|
1462
|
+
if (!isGridHeaderError(null, msg)) return msg;
|
|
1463
|
+
return `You belong to more than one grid. Pass the \`${param}\` parameter to specify which grid to use.`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1445
1466
|
// Map friendly plug error statuses to actionable messages (spec v2 §3.3).
|
|
1446
1467
|
// Appends errorGuidance() for known codes; unknown codes pass through as the
|
|
1447
1468
|
// bare `base` line, unchanged.
|
|
1448
1469
|
function plugErrorMessage(status, code, msg, ctxFlags = {}) {
|
|
1470
|
+
// A GRID_HEADER_REQUIRED (or any 400 naming the header) is unactionable —
|
|
1471
|
+
// rewrite it to name the `grid` PARAMETER and DROP the internal code from the
|
|
1472
|
+
// prefix (the code string itself contains "HEADER"). Class-level fix (#296);
|
|
1473
|
+
// mirrors the pull-path rewrite (#247). Part A derives the grid on an edit so
|
|
1474
|
+
// this normally never fires there; it still guards multi-grid CREATES.
|
|
1475
|
+
if (status === 400 && isGridHeaderError(code, msg)) {
|
|
1476
|
+
return `Plug failed (HTTP 400): ${rewriteGridHeaderError(msg)}`;
|
|
1477
|
+
}
|
|
1449
1478
|
const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
|
|
1450
1479
|
const guidance = errorGuidance({ status, code, ...ctxFlags });
|
|
1451
1480
|
return guidance ? `${base} — ${guidance}` : base;
|
|
@@ -1528,6 +1557,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1528
1557
|
target_entity_id,
|
|
1529
1558
|
grid,
|
|
1530
1559
|
slug,
|
|
1560
|
+
url: targetUrl,
|
|
1531
1561
|
hints,
|
|
1532
1562
|
anon,
|
|
1533
1563
|
owner_token,
|
|
@@ -1647,16 +1677,30 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1647
1677
|
);
|
|
1648
1678
|
}
|
|
1649
1679
|
|
|
1650
|
-
//
|
|
1651
|
-
// explicit target_entity_id was given
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1655
|
-
//
|
|
1680
|
+
// Re-plug handle resolution (the pickup contract's `replug_handle`): when no
|
|
1681
|
+
// explicit target_entity_id was given, resolve one from an alternative target
|
|
1682
|
+
// the caller DID name — a grid+slug pair, or a CloudGrid `url` — so an edge
|
|
1683
|
+
// identified only by its public URL re-plugs the SAME entity in place instead
|
|
1684
|
+
// of minting a new one. A target that does NOT resolve to an existing entity →
|
|
1685
|
+
// targetEntityId stays empty → this is a CREATE (no false-positive re-plug).
|
|
1686
|
+
// target_entity_id remains the primary/documented handle. Best-effort resolve
|
|
1687
|
+
// (pickup contract); never fetches the public URL.
|
|
1688
|
+
//
|
|
1689
|
+
// #296: a `url` is only trusted as a re-plug target when its host is a real
|
|
1690
|
+
// CloudGrid grid apex (gridFromCloudgridUrl != null) — a foreign or malformed
|
|
1691
|
+
// host is NOT sent to the resolver, so an untrusted URL can never redirect a
|
|
1692
|
+
// write to another entity. `pickupData` is reused below to header the write
|
|
1693
|
+
// without a second round-trip.
|
|
1656
1694
|
let targetEntityId = target_entity_id;
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1695
|
+
const urlGrid = gridFromCloudgridUrl(targetUrl);
|
|
1696
|
+
let pickupData = null;
|
|
1697
|
+
if (typeof targetEntityId !== "string" || targetEntityId.length === 0) {
|
|
1698
|
+
if (grid && slug) {
|
|
1699
|
+
pickupData = await resolveEntityViaPickup(ctx, { target: slug, grid });
|
|
1700
|
+
} else if (urlGrid) {
|
|
1701
|
+
pickupData = await resolveEntityViaPickup(ctx, { url: targetUrl, grid: urlGrid });
|
|
1702
|
+
}
|
|
1703
|
+
if (pickupData?.entity_id) targetEntityId = pickupData.entity_id;
|
|
1660
1704
|
}
|
|
1661
1705
|
|
|
1662
1706
|
const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
|
|
@@ -1742,7 +1786,27 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1742
1786
|
// the caller's membership from this header and requires it to MATCH the
|
|
1743
1787
|
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1744
1788
|
// active grid.
|
|
1789
|
+
// #296: an EDIT's destination is NOT ambiguous — the entity already lives in
|
|
1790
|
+
// exactly one grid, and every way of naming that entity carries the grid.
|
|
1791
|
+
// Resolve it in a deliberate order, cheapest reliable source first, so the
|
|
1792
|
+
// write never depends on session state the API would otherwise reject for
|
|
1793
|
+
// (GRID_HEADER_REQUIRED):
|
|
1794
|
+
// 1. explicit `grid` param — the caller said so.
|
|
1795
|
+
// 2. active grid — session state, no network.
|
|
1796
|
+
// 3. the URL host — FREE: the grid is the subdomain of a
|
|
1797
|
+
// CloudGrid `url` the user pasted; read it before any round-trip. A
|
|
1798
|
+
// foreign/malformed host yields null (gridFromCloudgridUrl) and we fall
|
|
1799
|
+
// through — never a silent redirect.
|
|
1800
|
+
// 4. the target entity itself — a pickup round-trip, only if 1–3 all
|
|
1801
|
+
// came up empty. Reuse a pickup already done above to resolve the id.
|
|
1745
1802
|
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1803
|
+
if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
|
|
1804
|
+
if (!orgSlug && isEdit) {
|
|
1805
|
+
if (!pickupData && targetEntityId) {
|
|
1806
|
+
pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
|
|
1807
|
+
}
|
|
1808
|
+
if (pickupData?.grid) orgSlug = pickupData.grid;
|
|
1809
|
+
}
|
|
1746
1810
|
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1747
1811
|
if (orgSlug) {
|
|
1748
1812
|
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
@@ -2134,7 +2198,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2134
2198
|
return { text: lines.join("\n"), structured };
|
|
2135
2199
|
}
|
|
2136
2200
|
|
|
2137
|
-
async function authedApiCall(ctx, { method, pathName, body, verb }) {
|
|
2201
|
+
async function authedApiCall(ctx, { method, pathName, body, verb, gridParam = "grid" }) {
|
|
2138
2202
|
const token = await ctx.getToken();
|
|
2139
2203
|
if (!token) {
|
|
2140
2204
|
throw new Error(`${verb} requires sign-in. Run grid_login first.`);
|
|
@@ -2167,7 +2231,13 @@ async function authedApiCall(ctx, { method, pathName, body, verb }) {
|
|
|
2167
2231
|
if (!res.ok) {
|
|
2168
2232
|
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
2169
2233
|
const codeStr = data?.error?.code || null;
|
|
2170
|
-
|
|
2234
|
+
// A GRID_HEADER_REQUIRED is unactionable — name the grid-carrying parameter
|
|
2235
|
+
// (into_org_slug for pickup) and drop the internal code from the prefix (it
|
|
2236
|
+
// contains "HEADER"). #296 class-level fix; see rewriteGridHeaderError.
|
|
2237
|
+
const errText = isGridHeaderError(codeStr, msg)
|
|
2238
|
+
? `${verb} failed (HTTP ${res.status}): ${rewriteGridHeaderError(msg, gridParam)}`
|
|
2239
|
+
: `${verb} failed (HTTP ${res.status}${codeStr ? ` ${codeStr}` : ""}): ${msg}`;
|
|
2240
|
+
const err = new Error(errText);
|
|
2171
2241
|
// Expose the structured status/code so callers can branch (e.g. runPickup's
|
|
2172
2242
|
// NOT_A_RUNTIME → inspiration-route fallback). Additive: existing callers
|
|
2173
2243
|
// only read `.message`.
|
|
@@ -2194,6 +2264,9 @@ export async function runPickup(ctx, { id, into_org_slug, name, source_version_i
|
|
|
2194
2264
|
...(source_version_id ? { source_version_id } : {}),
|
|
2195
2265
|
},
|
|
2196
2266
|
verb: "Pickup",
|
|
2267
|
+
// A fork's destination is chosen by the caller via into_org_slug, so a
|
|
2268
|
+
// GRID_HEADER_REQUIRED must point there, not at a non-existent `grid` param.
|
|
2269
|
+
gridParam: "into_org_slug",
|
|
2197
2270
|
});
|
|
2198
2271
|
const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
|
|
2199
2272
|
const lines = [
|
|
@@ -2430,8 +2503,11 @@ export async function runVisibility(ctx, { target, visibility, inside, outside,
|
|
|
2430
2503
|
if (!canFallback) break;
|
|
2431
2504
|
}
|
|
2432
2505
|
|
|
2433
|
-
|
|
2506
|
+
let msg = last?.data?.error?.message || last?.raw || `HTTP ${last?.res?.status}`;
|
|
2434
2507
|
const hint = last?.data?.error?.details?.[0]?.hint;
|
|
2508
|
+
// Never surface the raw header text — name the `grid` parameter (grid_visibility
|
|
2509
|
+
// accepts one via `org`, exposed to callers as the grid). #296 class-level fix.
|
|
2510
|
+
if (last?.res?.status === 400) msg = rewriteGridHeaderError(msg);
|
|
2435
2511
|
throw new Error(`Visibility change failed (HTTP ${last?.res?.status}): ${msg}${hint ? ` ${hint}` : ""}`);
|
|
2436
2512
|
}
|
|
2437
2513
|
|
|
@@ -2636,6 +2712,42 @@ function isCloudgridHost(hostname) {
|
|
|
2636
2712
|
return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
|
|
2637
2713
|
}
|
|
2638
2714
|
|
|
2715
|
+
// Derive the home grid slug from a CloudGrid URL (or a bare host). The grid is
|
|
2716
|
+
// the subdomain: `<grid>.cloudgrid.io/<slug>` (inspiration/path form) and
|
|
2717
|
+
// `<slug>--<grid>.cloudgrid.io` (runtime/flat form — grid is the label after
|
|
2718
|
+
// `--`), matching composePlugUrl's two serving shapes.
|
|
2719
|
+
//
|
|
2720
|
+
// SECURITY (#296): this derives a WRITE DESTINATION from user-supplied input, so
|
|
2721
|
+
// it is deliberately strict — it reuses `isCloudgridHost` (the single host check,
|
|
2722
|
+
// SSRF-shaped) rather than string-matching, and returns null for anything that is
|
|
2723
|
+
// not a trusted grid apex: a foreign or lookalike host, a malformed URL, the bare
|
|
2724
|
+
// apex `cloudgrid.io`, a deeper host (`a.b.cloudgrid.io`), or the anonymous
|
|
2725
|
+
// `guest` apex (never an authed write destination). A null result means "I could
|
|
2726
|
+
// not name the grid" → the caller falls through to today's behaviour and NEVER
|
|
2727
|
+
// silently redirects the write to a grid the user did not mean.
|
|
2728
|
+
export function gridFromCloudgridUrl(input) {
|
|
2729
|
+
const raw = String(input ?? "").trim();
|
|
2730
|
+
if (!raw) return null;
|
|
2731
|
+
let parsed;
|
|
2732
|
+
try {
|
|
2733
|
+
// Accept a bare host (`shoobi.cloudgrid.io/x`) by supplying a scheme; a
|
|
2734
|
+
// string that is already a URL keeps its own scheme.
|
|
2735
|
+
parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
2736
|
+
} catch {
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
if (!isCloudgridHost(parsed.hostname)) return null;
|
|
2740
|
+
const host = parsed.hostname.toLowerCase();
|
|
2741
|
+
const labels = host.split(".");
|
|
2742
|
+
// Exactly `<sub>.cloudgrid.io`. The apex (`cloudgrid.io`, 2 labels) and any
|
|
2743
|
+
// deeper host (`a.b.cloudgrid.io`, 4+) name no single grid apex we trust.
|
|
2744
|
+
if (labels.length !== 3) return null;
|
|
2745
|
+
const sub = labels[0];
|
|
2746
|
+
const grid = sub.includes("--") ? sub.split("--").pop() : sub;
|
|
2747
|
+
if (!grid || grid === GUEST_ORG_SLUG) return null;
|
|
2748
|
+
return grid;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2639
2751
|
// Shape an HTML string into the grid_get_app_source result (shared by the API-read
|
|
2640
2752
|
// and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
|
|
2641
2753
|
// optional edition metadata resolved from the pickup contract (kind, single_html,
|
|
@@ -2785,10 +2897,10 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
|
|
|
2785
2897
|
const resolvedUrl = parsed.toString();
|
|
2786
2898
|
let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
|
|
2787
2899
|
|
|
2788
|
-
// Derive the grid/slug hint from the
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2900
|
+
// Derive the grid/slug hint from the URL. gridFromCloudgridUrl is the single
|
|
2901
|
+
// host→grid parser (it also handles the flat `<slug>--<grid>` form the old
|
|
2902
|
+
// inline check missed); the slug is the first path segment.
|
|
2903
|
+
const gridHint = grid ?? gridFromCloudgridUrl(resolvedUrl);
|
|
2792
2904
|
const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
|
|
2793
2905
|
|
|
2794
2906
|
// ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
|
package/src/tools/register.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
6
7
|
import { newLoginCode, buildLoginUrl, pollStatusOnce, checkApiConnectivity, decodeJwt as decodeJwtAuth } from "../auth.js";
|
|
7
8
|
import { PLAYBOOK, fetchCorpus, listWorkflows } from "../playbook.js";
|
|
8
9
|
import {
|
|
@@ -12,6 +13,8 @@ import {
|
|
|
12
13
|
LIVE_RESULT_HTML,
|
|
13
14
|
GRID_PICKER_HTML,
|
|
14
15
|
WIDGET_CSP,
|
|
16
|
+
GRID_LOGIN_APP_URI,
|
|
17
|
+
GRID_LOGIN_APP_HTML,
|
|
15
18
|
} from "./constants.js";
|
|
16
19
|
import { fail, okResult } from "./util.js";
|
|
17
20
|
import { cliTool, tryOpenBrowser } from "./cli.js";
|
|
@@ -47,6 +50,33 @@ export function buildCreateProjectArgs({ kind, name, type, needs, dir, grid, org
|
|
|
47
50
|
return args;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// ── Sign-in ask (#298) ──────────────────────────────────────────────────────
|
|
54
|
+
// Until a rendered login card is possible (blocked on #297's answer + the
|
|
55
|
+
// directory submission), this text IS the whole login experience on every
|
|
56
|
+
// client, so it is written to be RELAYED nearly verbatim. The user-facing part
|
|
57
|
+
// comes first — a short question, then the two evenly-weighted options (guest is
|
|
58
|
+
// a real choice, not a fallback). The model-directed steps (which tool to call,
|
|
59
|
+
// do not choose, wait) are quarantined in a trailing (assistant: …) line so the
|
|
60
|
+
// model does not read internal directions aloud. §23 voice: plain, no emoji, no
|
|
61
|
+
// exclamation; say "plug" and "grid". The sign-in URL itself lands on its own
|
|
62
|
+
// line later, from grid_login. No structural change — every caller still returns
|
|
63
|
+
// { needs_auth: true }.
|
|
64
|
+
const AUTH_ASK_SIGNED_OUT =
|
|
65
|
+
"How would you like to plug this?\n" +
|
|
66
|
+
" - Sign in to your grid — it stays in your CloudGrid account.\n" +
|
|
67
|
+
" - As a guest — live now at a link that expires in 7 days unless you claim it.\n\n" +
|
|
68
|
+
"(assistant: offer both options and let the user choose — do not choose for them. " +
|
|
69
|
+
"To sign in, call grid_login; as a guest, re-call grid_plug with anon: true. " +
|
|
70
|
+
"Stop and wait for their answer.)";
|
|
71
|
+
const authAskSignedIn = (who) =>
|
|
72
|
+
`You are signed in${who}.\n\n` +
|
|
73
|
+
"How would you like to plug this?\n" +
|
|
74
|
+
" - To your grid — it stays in your CloudGrid account.\n" +
|
|
75
|
+
" - As a guest — live now at a link that expires in 7 days unless you claim it.\n\n" +
|
|
76
|
+
"(assistant: offer both options and let the user choose — do not choose for them. " +
|
|
77
|
+
"For their grid, re-call grid_plug; as a guest, re-call grid_plug with anon: true. " +
|
|
78
|
+
"Stop and wait for their answer.)";
|
|
79
|
+
|
|
50
80
|
// ── Registration ───────────────────────────────────────────────────────────────
|
|
51
81
|
// Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
|
|
52
82
|
// tools are included (they need a local machine).
|
|
@@ -117,6 +147,23 @@ export function registerTools(server, ctx) {
|
|
|
117
147
|
_meta: { ui: { csp: WIDGET_CSP } },
|
|
118
148
|
}],
|
|
119
149
|
}));
|
|
150
|
+
|
|
151
|
+
// grid_login MCP App (SEP-1865). Registered via the official ext-apps
|
|
152
|
+
// helper, which defaults the MIME type to RESOURCE_MIME_TYPE
|
|
153
|
+
// (text/html;profile=mcp-app) — the type Claude web looks for. NO csp
|
|
154
|
+
// domains: the card makes no network requests of its own (it only asks the
|
|
155
|
+
// host to open the sign-in URL and calls grid_login_status back over the
|
|
156
|
+
// bridge), so deny-by-default is exactly right. Independent of
|
|
157
|
+
// APPS_WIDGETS_ENABLED by design (see constants.js).
|
|
158
|
+
registerAppResource(server, "cloudgrid-grid-login", GRID_LOGIN_APP_URI, {
|
|
159
|
+
description: "CloudGrid sign-in card — opens the sign-in URL and confirms when you return.",
|
|
160
|
+
}, async () => ({
|
|
161
|
+
contents: [{
|
|
162
|
+
uri: GRID_LOGIN_APP_URI,
|
|
163
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
164
|
+
text: GRID_LOGIN_APP_HTML,
|
|
165
|
+
}],
|
|
166
|
+
}));
|
|
120
167
|
}
|
|
121
168
|
|
|
122
169
|
// ── Direct-API tools (both editions) ──────────────────────────────────────
|
|
@@ -353,6 +400,13 @@ export function registerTools(server, ctx) {
|
|
|
353
400
|
"raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
|
|
354
401
|
"entity is treated as a CREATE (never a false-positive re-plug).",
|
|
355
402
|
),
|
|
403
|
+
url: z.string().optional().describe(
|
|
404
|
+
"Alternative RE-PLUG handle: the entity's public CloudGrid URL (e.g. https://<grid>.cloudgrid.io/<slug>) " +
|
|
405
|
+
"— use it to edit in place when you have the URL but no target_entity_id (the common \"change the color of " +
|
|
406
|
+
"this page\" flow after grid_get_app_source). The grid is read from the host and the entity is resolved via " +
|
|
407
|
+
"the pickup contract, so no `grid` param is needed. target_entity_id and grid+slug take precedence. A " +
|
|
408
|
+
"non-CloudGrid host is ignored (never redirects the write); a URL that does not resolve is treated as a CREATE.",
|
|
409
|
+
),
|
|
356
410
|
hints: z.object({
|
|
357
411
|
kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
|
|
358
412
|
yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
|
|
@@ -515,31 +569,19 @@ export function registerTools(server, ctx) {
|
|
|
515
569
|
const claims = decodeJwtAuth(token);
|
|
516
570
|
const who = claims.email ? ` as ${claims.email}` : "";
|
|
517
571
|
return okResult({
|
|
518
|
-
text:
|
|
519
|
-
`You are signed in${who}. The user has not explicitly chosen anonymous publishing. Ask the user which they want — do not choose for them:\n` +
|
|
520
|
-
` 1. Publish to your grid (re-call grid_plug without anon: true).\n` +
|
|
521
|
-
` 2. Publish anonymously — a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n` +
|
|
522
|
-
`Relay this question to the user and STOP until they answer.`,
|
|
572
|
+
text: authAskSignedIn(who),
|
|
523
573
|
structured: { needs_auth: true },
|
|
524
574
|
});
|
|
525
575
|
}
|
|
526
576
|
return okResult({
|
|
527
|
-
text:
|
|
528
|
-
"This is a new publish and the user is not signed in. Ask the user which they want — do not choose for them:\n" +
|
|
529
|
-
" 1. Sign in and plug it to their grid — run grid_login (opens the browser), then re-call grid_plug.\n" +
|
|
530
|
-
" 2. Plug it as a guest — live immediately at a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n" +
|
|
531
|
-
"Relay this question to the user and STOP until they answer.",
|
|
577
|
+
text: AUTH_ASK_SIGNED_OUT,
|
|
532
578
|
structured: { needs_auth: true },
|
|
533
579
|
});
|
|
534
580
|
}
|
|
535
581
|
if (!token && input?.anon !== true) {
|
|
536
582
|
if (ctx.state) ctx.state.authChoiceOffered = true;
|
|
537
583
|
return okResult({
|
|
538
|
-
text:
|
|
539
|
-
"This is a new publish and the user is not signed in. Ask the user which they want — do not choose for them:\n" +
|
|
540
|
-
" 1. Sign in and plug it to their grid — run grid_login (opens the browser), then re-call grid_plug.\n" +
|
|
541
|
-
" 2. Plug it as a guest — live immediately at a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n" +
|
|
542
|
-
"Relay this question to the user and STOP until they answer.",
|
|
584
|
+
text: AUTH_ASK_SIGNED_OUT,
|
|
543
585
|
structured: { needs_auth: true },
|
|
544
586
|
});
|
|
545
587
|
}
|
|
@@ -647,6 +689,14 @@ export function registerTools(server, ctx) {
|
|
|
647
689
|
login_url: z.string().describe("URL to open in a browser to complete sign-in."),
|
|
648
690
|
},
|
|
649
691
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
692
|
+
// Bind the SEP-1865 sign-in card on the web edition (Claude web). This is
|
|
693
|
+
// the SEP-1865 key (_meta.ui.resourceUri); ChatGPT reads openai/outputTemplate
|
|
694
|
+
// and ignores it, and a client without the UI extension ignores it too and
|
|
695
|
+
// gets the text-first result below. NOT gated behind MCP_APPS_WIDGETS — a
|
|
696
|
+
// second, independent mechanism (see constants.js). The result shape is
|
|
697
|
+
// unchanged: content[0] stays the sign-in URL text so the link is never
|
|
698
|
+
// unreachable if the card does not render.
|
|
699
|
+
...(ctx.edition === "web" ? { _meta: { ui: { resourceUri: GRID_LOGIN_APP_URI } } } : {}),
|
|
650
700
|
},
|
|
651
701
|
async () => {
|
|
652
702
|
try {
|
|
@@ -660,8 +710,11 @@ export function registerTools(server, ctx) {
|
|
|
660
710
|
if (ctx.canOpenBrowser) tryOpenBrowser(url);
|
|
661
711
|
return {
|
|
662
712
|
content: [{ type: "text", text:
|
|
663
|
-
|
|
664
|
-
|
|
713
|
+
// #298: the sign-in link on its own line, unadorned, so every client
|
|
714
|
+
// linkifies it and the eye lands on it; the model-directed follow-up is
|
|
715
|
+
// quarantined in the trailing (assistant: …) line.
|
|
716
|
+
`Open this link to sign in, then finish in your browser:\n\n${url}\n\n` +
|
|
717
|
+
`(assistant: after they complete it, call grid_login_status to finish.)`,
|
|
665
718
|
}],
|
|
666
719
|
structuredContent: { login_url: url },
|
|
667
720
|
};
|
package/src/web.js
CHANGED
|
@@ -57,6 +57,21 @@ function bearerOf(req) {
|
|
|
57
57
|
return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Reduce an object to its KEY SHAPE — key names mapped to the TYPE of each value,
|
|
61
|
+
// recursively — never the values themselves. Used to log the client's advertised
|
|
62
|
+
// MCP capabilities at initialize (#297) while guaranteeing BY CONSTRUCTION that
|
|
63
|
+
// no value ever leaves the process: only key names and JS type names are emitted,
|
|
64
|
+
// so no token, header, tool argument, or user content can appear even if the
|
|
65
|
+
// object carried one. Depth- and breadth-capped so a malformed or huge object
|
|
66
|
+
// cannot blow the stack or flood the log.
|
|
67
|
+
function keyShape(value, depth = 0) {
|
|
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;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
// A web session: identity lives in memory for the session's lifetime only. The
|
|
61
76
|
// session id doubles as the stable, opaque end-user id for the trusted-server cap.
|
|
62
77
|
// The identity carries the request's transport token; auth is enforced in-band by
|
|
@@ -166,6 +181,28 @@ async function buildSession(sessionId, jwt) {
|
|
|
166
181
|
} catch {
|
|
167
182
|
webCtx.state.client = null;
|
|
168
183
|
}
|
|
184
|
+
// #297: record the client's advertised MCP capabilities once per session, so
|
|
185
|
+
// we can settle whether Claude web ever advertises a UI extension (e.g.
|
|
186
|
+
// io.modelcontextprotocol/ui) — the plumbing a rendered login card would
|
|
187
|
+
// need — instead of guessing while the directory submission sits unanswered.
|
|
188
|
+
// Observation-only: only the capability KEY SHAPE (names + value types, never
|
|
189
|
+
// values — see keyShape) is logged, so no credential or user content can
|
|
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
|
+
}
|
|
169
206
|
};
|
|
170
207
|
await server.connect(transport);
|
|
171
208
|
return { transport, identity };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
:root { color-scheme: light dark; }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
body {
|
|
4
|
+
margin: 0;
|
|
5
|
+
font-family: var(--font-sans, Inter, system-ui, -apple-system, sans-serif);
|
|
6
|
+
background: transparent;
|
|
7
|
+
color: light-dark(#0d0d0f, #fafafa);
|
|
8
|
+
}
|
|
9
|
+
#card {
|
|
10
|
+
max-width: 420px;
|
|
11
|
+
margin: 0 auto;
|
|
12
|
+
padding: 1.5rem;
|
|
13
|
+
}
|
|
14
|
+
h1 {
|
|
15
|
+
font-size: 1.15rem;
|
|
16
|
+
margin: 0 0 0.5rem;
|
|
17
|
+
}
|
|
18
|
+
.lede {
|
|
19
|
+
margin: 0 0 1rem;
|
|
20
|
+
font-size: 0.9rem;
|
|
21
|
+
line-height: 1.45;
|
|
22
|
+
opacity: 0.85;
|
|
23
|
+
}
|
|
24
|
+
.status {
|
|
25
|
+
margin: 0 0 1rem;
|
|
26
|
+
font-size: 0.85rem;
|
|
27
|
+
opacity: 0.7;
|
|
28
|
+
min-height: 1.2em;
|
|
29
|
+
}
|
|
30
|
+
.actions {
|
|
31
|
+
display: flex;
|
|
32
|
+
gap: 0.5rem;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
}
|
|
35
|
+
button {
|
|
36
|
+
font: inherit;
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
padding: 0.6rem 1.1rem;
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
border: 1px solid transparent;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
background: light-dark(#0d0d0f, #fafafa);
|
|
43
|
+
color: light-dark(#fafafa, #0d0d0f);
|
|
44
|
+
}
|
|
45
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
46
|
+
button.secondary {
|
|
47
|
+
background: transparent;
|
|
48
|
+
color: inherit;
|
|
49
|
+
border-color: light-dark(rgba(13,13,15,0.25), rgba(250,250,250,0.3));
|
|
50
|
+
}
|
|
51
|
+
.fallback {
|
|
52
|
+
margin: 1rem 0 0;
|
|
53
|
+
font-size: 0.8rem;
|
|
54
|
+
opacity: 0.7;
|
|
55
|
+
word-break: break-all;
|
|
56
|
+
}
|
|
57
|
+
.fallback a { color: inherit; }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>Sign in to CloudGrid</title>
|
|
8
|
+
<style>/* __CSS__ */</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main id="card">
|
|
12
|
+
<h1>Sign in to CloudGrid</h1>
|
|
13
|
+
<p class="lede">CloudGrid runs your apps with the infrastructure they need. Sign in to plug to your grid.</p>
|
|
14
|
+
<p class="status" id="status">Preparing the sign-in link.</p>
|
|
15
|
+
<div class="actions">
|
|
16
|
+
<button id="signin" type="button" disabled>Open sign-in</button>
|
|
17
|
+
<button id="check" type="button" class="secondary" hidden>Check sign-in</button>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="fallback" id="fallback" hidden>
|
|
20
|
+
Or open this link yourself:
|
|
21
|
+
<a id="fallback-url" href="#" target="_blank" rel="noopener noreferrer"></a>
|
|
22
|
+
</p>
|
|
23
|
+
</main>
|
|
24
|
+
<script type="module">/* __JS__ */</script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|