@cloudgrid-io/mcp 0.7.3 → 0.8.1
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 +9 -2
- package/package.json +2 -1
- package/src/auth.js +8 -4
- package/src/index.js +2 -2
- package/src/tools.js +286 -44
- package/src/web.js +3 -3
package/README.md
CHANGED
|
@@ -15,11 +15,18 @@ drop, claim, and login tools.
|
|
|
15
15
|
|
|
16
16
|
## CLI compatibility
|
|
17
17
|
|
|
18
|
-
MCP 0.
|
|
19
|
-
`@cloudgrid-io/cli@~0.
|
|
18
|
+
MCP 0.8.0 is tested against CLI 0.12. The lazy-npx fallback pins
|
|
19
|
+
`@cloudgrid-io/cli@~0.12` so a future CLI major with renamed verbs cannot
|
|
20
20
|
silently break a released MCP. A CI drift guard (`npm run test:drift-guard`)
|
|
21
21
|
asserts every wrapped verb exists in the CLI help.
|
|
22
22
|
|
|
23
|
+
### `GRID_AUTH_STALE` is out of scope
|
|
24
|
+
|
|
25
|
+
Strict-OIDC (org-owned) grids can return `GRID_AUTH_STALE` when the caller's
|
|
26
|
+
session predates an SSO policy change. The MCP does not target such grids and
|
|
27
|
+
does not handle this code: if a `GRID_AUTH_STALE` response is ever seen, point
|
|
28
|
+
the user at Console SSO to re-authenticate — the MCP will not silently retry.
|
|
29
|
+
|
|
23
30
|
## Prerequisite
|
|
24
31
|
|
|
25
32
|
Install and log in to the CLI:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
|
|
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:error-reporting": "node test/error-reporting.test.mjs",
|
|
20
21
|
"test:self-healing": "node test/self-healing.test.mjs",
|
|
21
22
|
"test:grid-picker": "node test/grid-picker.test.mjs",
|
|
22
23
|
"test:drift-guard": "node test/drift-guard.mjs",
|
package/src/auth.js
CHANGED
|
@@ -66,12 +66,16 @@ export async function readCredentials() {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// The CLI's active
|
|
70
|
-
// line parse — no YAML dependency for one field.
|
|
71
|
-
|
|
69
|
+
// The CLI's active grid slug from ~/.cloudgrid/config.yaml, or null. A minimal
|
|
70
|
+
// line parse — no YAML dependency for one field. Accepts the grid-native
|
|
71
|
+
// `active_grid_slug` key and falls back to the legacy `active_org_slug` (CLI 0.12
|
|
72
|
+
// still writes the org key; this is forward-compatible with a future rename).
|
|
73
|
+
export async function readActiveGridSlug() {
|
|
72
74
|
try {
|
|
73
75
|
const raw = await readFile(join(cloudgridHome(), "config.yaml"), "utf8");
|
|
74
|
-
const m =
|
|
76
|
+
const m =
|
|
77
|
+
raw.match(/^\s*active_grid_slug:\s*(\S+)\s*$/m) ||
|
|
78
|
+
raw.match(/^\s*active_org_slug:\s*(\S+)\s*$/m);
|
|
75
79
|
return m ? m[1] : null;
|
|
76
80
|
} catch {
|
|
77
81
|
return null;
|
package/src/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
11
11
|
import { registerTools } from "./tools.js";
|
|
12
12
|
import {
|
|
13
13
|
readCredentials,
|
|
14
|
-
|
|
14
|
+
readActiveGridSlug,
|
|
15
15
|
writeCredentials,
|
|
16
16
|
credentialsPath,
|
|
17
17
|
} from "./auth.js";
|
|
@@ -23,7 +23,7 @@ const ctx = {
|
|
|
23
23
|
state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
|
|
24
24
|
canOpenBrowser: true,
|
|
25
25
|
getToken: async () => (await readCredentials())?.jwt ?? null,
|
|
26
|
-
|
|
26
|
+
getActiveGrid: async () => await readActiveGridSlug(),
|
|
27
27
|
saveToken: async (jwt) => await writeCredentials(jwt),
|
|
28
28
|
savedLocationNote: () => `Credentials saved to ${credentialsPath()}.`,
|
|
29
29
|
};
|
package/src/tools.js
CHANGED
|
@@ -30,9 +30,11 @@ const CONSOLE_URL = "https://console.cloudgrid.io/";
|
|
|
30
30
|
|
|
31
31
|
// ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
|
|
32
32
|
const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
|
|
33
|
-
|
|
33
|
+
// URI/resource-name/filename stay `org-picker` — that's the stable contract the
|
|
34
|
+
// web card is registered under; only the JS identifier moves toward grid.
|
|
35
|
+
const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
|
|
34
36
|
const LIVE_RESULT_HTML = readFileSync(new URL("./widgets/live-result.html", import.meta.url), "utf-8");
|
|
35
|
-
const
|
|
37
|
+
const GRID_PICKER_HTML = readFileSync(new URL("./widgets/org-picker.html", import.meta.url), "utf-8");
|
|
36
38
|
const WIDGET_CSP = {
|
|
37
39
|
connectDomains: ["https://*.cloudgrid.io"],
|
|
38
40
|
resourceDomains: ["https://*.cloudgrid.io"],
|
|
@@ -67,6 +69,7 @@ Operating rules:
|
|
|
67
69
|
5. Minimize questions. Use sensible defaults and build; don't front-load setup questions.
|
|
68
70
|
6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
|
|
69
71
|
7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
|
|
72
|
+
8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call gridctl_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
|
|
70
73
|
|
|
71
74
|
Deploy is edition-dependent: on the hosted MCP call the drop tool with the HTML; on local MCP / CLI write the file and run the plug tool. An HTML page deploys synchronously, so you get a URL right away.`;
|
|
72
75
|
|
|
@@ -194,11 +197,11 @@ function resolveCwd(cwd) {
|
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
// Pin the CLI version for the lazy npx fallback so behaviour is reproducible.
|
|
197
|
-
// MCP 0.
|
|
198
|
-
const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.
|
|
200
|
+
// MCP 0.8.0 is tested against CLI 0.12.
|
|
201
|
+
const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.12";
|
|
199
202
|
|
|
200
203
|
// Minimum CLI version that supports the verbs and flags the MCP passes.
|
|
201
|
-
const MIN_CLI_VERSION = "0.
|
|
204
|
+
const MIN_CLI_VERSION = "0.12.0";
|
|
202
205
|
|
|
203
206
|
// Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
|
|
204
207
|
// The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
|
|
@@ -656,8 +659,16 @@ async function fetchUserOrgs(token) {
|
|
|
656
659
|
});
|
|
657
660
|
if (!res.ok) return [];
|
|
658
661
|
const data = await res.json();
|
|
659
|
-
|
|
660
|
-
|
|
662
|
+
// 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
|
|
663
|
+
// `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
|
|
664
|
+
const grids = Array.isArray(data?.grids)
|
|
665
|
+
? data.grids
|
|
666
|
+
: Array.isArray(data?.orgs)
|
|
667
|
+
? data.orgs
|
|
668
|
+
: Array.isArray(data)
|
|
669
|
+
? data
|
|
670
|
+
: [];
|
|
671
|
+
return grids.map((o) => ({
|
|
661
672
|
slug: o.slug ?? "",
|
|
662
673
|
name: o.name ?? o.slug ?? "",
|
|
663
674
|
role: o.role ?? "member",
|
|
@@ -681,19 +692,19 @@ async function fetchUserOrgs(token) {
|
|
|
681
692
|
// org-picker web widget reads, so the web card keeps working. Stateless — no
|
|
682
693
|
// dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
|
|
683
694
|
export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
const matched = suppliedGrid &&
|
|
695
|
+
const listGrids = deps.fetchUserOrgs || fetchUserOrgs;
|
|
696
|
+
const grids = await listGrids(token);
|
|
697
|
+
const activeGrid = await ctx.getActiveGrid();
|
|
698
|
+
const matched = suppliedGrid && grids.find((o) => o.slug === suppliedGrid);
|
|
688
699
|
if (matched) {
|
|
689
700
|
// Supplied grid matches — proceed. The agent should already have checked
|
|
690
701
|
// render_ready and warned the user; we don't block here.
|
|
691
702
|
return { proceed: true, grid: suppliedGrid };
|
|
692
703
|
}
|
|
693
|
-
if (
|
|
704
|
+
if (grids.length > 1) {
|
|
694
705
|
// No valid grid supplied and multiple grids — ask once. Mark the active
|
|
695
706
|
// grid so the agent can offer it as the default.
|
|
696
|
-
const annotated =
|
|
707
|
+
const annotated = grids.map((o) => ({ ...o, is_active: o.slug === activeGrid }));
|
|
697
708
|
// Sort: active grid first, then ready grids, then not-ready grids.
|
|
698
709
|
annotated.sort((a, b) => {
|
|
699
710
|
if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
|
|
@@ -719,12 +730,12 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
|
|
|
719
730
|
// `needs_grid` is the new field; `needs_org`/`orgs` are kept as aliases
|
|
720
731
|
// so the existing org-picker.html web widget (reads data.orgs) still works.
|
|
721
732
|
structured: { needs_grid: true, needs_org: true, grids: annotated, orgs: annotated },
|
|
722
|
-
...(edition === "web" ? { meta: { "openai/outputTemplate":
|
|
733
|
+
...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
|
|
723
734
|
},
|
|
724
735
|
};
|
|
725
736
|
}
|
|
726
|
-
if (
|
|
727
|
-
return { single: { ...
|
|
737
|
+
if (grids.length === 1) {
|
|
738
|
+
return { single: { ...grids[0], is_active: grids[0].slug === activeGrid } };
|
|
728
739
|
}
|
|
729
740
|
return { proceed: true };
|
|
730
741
|
}
|
|
@@ -737,7 +748,12 @@ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
|
|
|
737
748
|
if (!token || !entityId) return false;
|
|
738
749
|
try {
|
|
739
750
|
const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
740
|
-
|
|
751
|
+
// Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
|
|
752
|
+
// during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
|
|
753
|
+
if (orgSlug) {
|
|
754
|
+
hdrs["X-CloudGrid-Grid"] = orgSlug;
|
|
755
|
+
hdrs["X-CloudGrid-Org"] = orgSlug;
|
|
756
|
+
}
|
|
741
757
|
const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
|
|
742
758
|
method: "PATCH",
|
|
743
759
|
headers: hdrs,
|
|
@@ -843,7 +859,7 @@ export async function runDrop(
|
|
|
843
859
|
if (anonymous !== true) {
|
|
844
860
|
authToken = await ctx.getToken();
|
|
845
861
|
if (authToken) {
|
|
846
|
-
orgSlug = targetGrid || (await ctx.
|
|
862
|
+
orgSlug = targetGrid || (await ctx.getActiveGrid());
|
|
847
863
|
}
|
|
848
864
|
}
|
|
849
865
|
|
|
@@ -897,7 +913,11 @@ export async function runDrop(
|
|
|
897
913
|
const headers = {};
|
|
898
914
|
if (!isAnonymousWire) {
|
|
899
915
|
headers["Authorization"] = `Bearer ${authToken}`;
|
|
900
|
-
|
|
916
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
917
|
+
if (orgSlug) {
|
|
918
|
+
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
919
|
+
headers["X-CloudGrid-Org"] = orgSlug;
|
|
920
|
+
}
|
|
901
921
|
}
|
|
902
922
|
|
|
903
923
|
// Hosted server: attach the trusted-server credential on EVERY anonymous call
|
|
@@ -1113,6 +1133,128 @@ export async function runDrop(
|
|
|
1113
1133
|
};
|
|
1114
1134
|
}
|
|
1115
1135
|
|
|
1136
|
+
// ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
|
|
1137
|
+
// Key names that look like they carry a secret. Mirrors the server's
|
|
1138
|
+
// SECRET_KEY_PATTERNS (packages/api/src/routes/errors.ts) so the MCP scrubs the
|
|
1139
|
+
// same shapes client-side — defense-in-depth on top of the server redaction.
|
|
1140
|
+
const REPORT_SECRET_KEY_PATTERNS = [
|
|
1141
|
+
/password/i,
|
|
1142
|
+
/secret/i,
|
|
1143
|
+
/token/i,
|
|
1144
|
+
/api[_-]?key/i,
|
|
1145
|
+
/auth/i,
|
|
1146
|
+
/credential/i,
|
|
1147
|
+
/private[_-]?key/i,
|
|
1148
|
+
/access[_-]?key/i,
|
|
1149
|
+
];
|
|
1150
|
+
|
|
1151
|
+
// Light client-side scrub of obviously secret-looking values in the report
|
|
1152
|
+
// context. Redacts values under secret-looking KEYS; leaves everything else
|
|
1153
|
+
// intact (the server does the authoritative redaction). Bounded depth so a
|
|
1154
|
+
// pathological object can't loop.
|
|
1155
|
+
export function scrubReportContext(obj, depth = 0) {
|
|
1156
|
+
if (depth > 5 || obj === null || typeof obj !== "object") return obj;
|
|
1157
|
+
if (Array.isArray(obj)) return obj.map((item) => scrubReportContext(item, depth + 1));
|
|
1158
|
+
const out = {};
|
|
1159
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1160
|
+
const isSecret = REPORT_SECRET_KEY_PATTERNS.some((re) => re.test(key));
|
|
1161
|
+
out[key] = isSecret ? "[REDACTED]" : scrubReportContext(value, depth + 1);
|
|
1162
|
+
}
|
|
1163
|
+
return out;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Send a consent-gated bug report to the CloudGrid team. The agent calls this
|
|
1167
|
+
// ONLY after the user explicitly agrees (see errorGuidance + the PLAYBOOK rule).
|
|
1168
|
+
// Posts to POST /api/v2/errors/feedback with {app:"mcp", message, context,
|
|
1169
|
+
// node_version, platform}. Authed → Bearer; anon+web → the trusted-server
|
|
1170
|
+
// headers (works once Gilad opens the endpoint; until then a 401 degrades to a
|
|
1171
|
+
// friendly "sign in to report"). Never throws — always returns friendly text.
|
|
1172
|
+
export async function runReport(ctx, { message, context, include_conversation } = {}) {
|
|
1173
|
+
const summary = typeof message === "string" ? message.trim() : "";
|
|
1174
|
+
if (!summary) {
|
|
1175
|
+
return okResult({
|
|
1176
|
+
text: "Nothing to report — provide a short `message` describing what failed.",
|
|
1177
|
+
structured: { status: "skipped" },
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Belt-and-suspenders scrub before the value ever leaves the process.
|
|
1182
|
+
const safeContext =
|
|
1183
|
+
context && typeof context === "object" ? scrubReportContext(context) : undefined;
|
|
1184
|
+
// The full conversation is NEVER included unless the agent explicitly set the
|
|
1185
|
+
// flag (which the PLAYBOOK gates on the user's explicit yes). This tool only
|
|
1186
|
+
// records the flag alongside the report so intent is auditable — it does not
|
|
1187
|
+
// itself have the transcript.
|
|
1188
|
+
const body = {
|
|
1189
|
+
app: "mcp",
|
|
1190
|
+
message: summary.slice(0, 5000),
|
|
1191
|
+
context: safeContext,
|
|
1192
|
+
node_version: process.version,
|
|
1193
|
+
platform: process.platform,
|
|
1194
|
+
...(include_conversation === true ? { include_conversation: true } : {}),
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
// Auth: signed-in → Bearer; anon + web edition → trusted-server headers.
|
|
1198
|
+
const headers = { "content-type": "application/json" };
|
|
1199
|
+
let token = null;
|
|
1200
|
+
try {
|
|
1201
|
+
token = await ctx.getToken();
|
|
1202
|
+
} catch {
|
|
1203
|
+
token = null;
|
|
1204
|
+
}
|
|
1205
|
+
let usedTrustedServer = false;
|
|
1206
|
+
if (token) {
|
|
1207
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
1208
|
+
} else if (ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
1209
|
+
// Web edition anon path — works ONCE the endpoint accepts the trusted-server
|
|
1210
|
+
// credential (Gilad-side change). Until then the server 401s and we degrade.
|
|
1211
|
+
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
1212
|
+
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
1213
|
+
usedTrustedServer = true;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let res;
|
|
1217
|
+
try {
|
|
1218
|
+
res = await fetch(`${API_BASE}/api/v2/errors/feedback`, {
|
|
1219
|
+
method: "POST",
|
|
1220
|
+
headers,
|
|
1221
|
+
body: JSON.stringify(body),
|
|
1222
|
+
});
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
return okResult({
|
|
1225
|
+
text: `Couldn't reach the CloudGrid team right now (${err.message}). Nothing was sent — you can try again later.`,
|
|
1226
|
+
structured: { status: "error" },
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (res.status === 201 || res.ok) {
|
|
1231
|
+
return okResult({
|
|
1232
|
+
text: "Reported to the CloudGrid team — thank you.",
|
|
1233
|
+
structured: { status: "recorded" },
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
if (res.status === 429) {
|
|
1237
|
+
return okResult({
|
|
1238
|
+
text: "Already reported a lot recently; try again later.",
|
|
1239
|
+
structured: { status: "rate_limited" },
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
if (res.status === 401) {
|
|
1243
|
+
// Anon reporting isn't accepted yet (needs the Gilad-side endpoint change);
|
|
1244
|
+
// degrade gracefully rather than erroring.
|
|
1245
|
+
return okResult({
|
|
1246
|
+
text: usedTrustedServer || !token
|
|
1247
|
+
? "Sign in to send a report to the CloudGrid team (gridctl_login), then try again."
|
|
1248
|
+
: "That didn't authorize a report. Sign in again (gridctl_login) and retry.",
|
|
1249
|
+
structured: { status: "unauthorized" },
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
return okResult({
|
|
1253
|
+
text: "Couldn't send the report to the CloudGrid team right now. Nothing else was sent — you can try again later.",
|
|
1254
|
+
structured: { status: "error" },
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1116
1258
|
async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
1117
1259
|
const token = await ctx.getToken();
|
|
1118
1260
|
if (!token) {
|
|
@@ -1161,8 +1303,12 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
|
1161
1303
|
// The claim re-homes the inspiration into the caller's grid; the platform
|
|
1162
1304
|
// resolves the destination from the active-org context, so send the org
|
|
1163
1305
|
// header (the same header every other authed write uses).
|
|
1164
|
-
const orgSlug = await ctx.
|
|
1165
|
-
|
|
1306
|
+
const orgSlug = await ctx.getActiveGrid();
|
|
1307
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1308
|
+
if (orgSlug) {
|
|
1309
|
+
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1310
|
+
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1311
|
+
}
|
|
1166
1312
|
// Replay the anon-session cookie so a cookie-class caller can claim what it
|
|
1167
1313
|
// dropped, even without a claim token.
|
|
1168
1314
|
if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
|
|
@@ -1371,9 +1517,33 @@ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn
|
|
|
1371
1517
|
if (status === 403) {
|
|
1372
1518
|
return "You lack the role to plug this target. To re-plug someone else's entity, pick it up first (gridctl_pickup / gridctl_claim).";
|
|
1373
1519
|
}
|
|
1520
|
+
// ── Consent-gated report offer (Task 34) ──────────────────────────────────
|
|
1521
|
+
// GENUINE bugs only: a build/deploy failure, any 5xx, INTERNAL_ERROR, or an
|
|
1522
|
+
// unknown/unmapped error. Everything above (429 rate-limit, needs_grid picker,
|
|
1523
|
+
// 401 sign-in prompts, 409 EDIT_REJECTED, 403) is an EXPECTED condition, not a
|
|
1524
|
+
// bug — those returned already and never reach here, so they never get the
|
|
1525
|
+
// offer. The offer tells the agent to ask permission first and never send the
|
|
1526
|
+
// full conversation without an explicit yes.
|
|
1527
|
+
const isServerError = typeof status === "number" && status >= 500;
|
|
1528
|
+
const isInternalError = code === "INTERNAL_ERROR";
|
|
1529
|
+
const isBuildFailure = code === "BUILD_FAILED" || code === "DEPLOY_FAILED";
|
|
1530
|
+
if (isServerError || isInternalError || isBuildFailure) {
|
|
1531
|
+
return REPORT_OFFER;
|
|
1532
|
+
}
|
|
1533
|
+
// A 4xx with an unknown/unmapped code is a client-side condition (validation,
|
|
1534
|
+
// bad input), not a server bug — pass through unchanged so callers don't
|
|
1535
|
+
// rewrite it or wrongly offer to report it.
|
|
1374
1536
|
return null;
|
|
1375
1537
|
}
|
|
1376
1538
|
|
|
1539
|
+
// The consent-gated report affordance appended to genuine-bug guidance. It
|
|
1540
|
+
// instructs the agent to get explicit permission before calling gridctl_report,
|
|
1541
|
+
// and to never send the whole conversation without an explicit yes.
|
|
1542
|
+
export const REPORT_OFFER =
|
|
1543
|
+
"If this looks like a CloudGrid bug, ASK the user for permission to report it to the CloudGrid team, " +
|
|
1544
|
+
"then call gridctl_report with the error + the failed request context. " +
|
|
1545
|
+
"Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
|
|
1546
|
+
|
|
1377
1547
|
// Map friendly plug error statuses to actionable messages (spec v2 §3.3).
|
|
1378
1548
|
// Appends errorGuidance() for known codes; unknown codes pass through as the
|
|
1379
1549
|
// bare `base` line, unchanged.
|
|
@@ -1542,8 +1712,12 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1542
1712
|
// the caller's membership from this header and requires it to MATCH the
|
|
1543
1713
|
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1544
1714
|
// active grid.
|
|
1545
|
-
const orgSlug = grid || (await ctx.
|
|
1546
|
-
|
|
1715
|
+
const orgSlug = grid || (await ctx.getActiveGrid());
|
|
1716
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1717
|
+
if (orgSlug) {
|
|
1718
|
+
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1719
|
+
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1720
|
+
}
|
|
1547
1721
|
}
|
|
1548
1722
|
if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
1549
1723
|
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
@@ -1704,8 +1878,12 @@ async function authedApiCall(ctx, { method, pathName, body, verb }) {
|
|
|
1704
1878
|
throw new Error(`${verb} requires sign-in. Run gridctl_login first.`);
|
|
1705
1879
|
}
|
|
1706
1880
|
const headers = { Authorization: `Bearer ${token}` };
|
|
1707
|
-
const orgSlug = await ctx.
|
|
1708
|
-
|
|
1881
|
+
const orgSlug = await ctx.getActiveGrid();
|
|
1882
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1883
|
+
if (orgSlug) {
|
|
1884
|
+
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1885
|
+
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1886
|
+
}
|
|
1709
1887
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
1710
1888
|
let res;
|
|
1711
1889
|
try {
|
|
@@ -1800,8 +1978,12 @@ async function runVisibility(ctx, { target, visibility, org }) {
|
|
|
1800
1978
|
throw new Error("No target. Pass the entity id, or drop something first in this session.");
|
|
1801
1979
|
}
|
|
1802
1980
|
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
1803
|
-
const orgSlug = org || (await ctx.
|
|
1804
|
-
|
|
1981
|
+
const orgSlug = org || (await ctx.getActiveGrid());
|
|
1982
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1983
|
+
if (orgSlug) {
|
|
1984
|
+
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1985
|
+
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1986
|
+
}
|
|
1805
1987
|
let res;
|
|
1806
1988
|
try {
|
|
1807
1989
|
res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(id)}`, {
|
|
@@ -1879,14 +2061,14 @@ export function registerTools(server, ctx) {
|
|
|
1879
2061
|
}],
|
|
1880
2062
|
}));
|
|
1881
2063
|
|
|
1882
|
-
server.registerResource("cloudgrid-org-picker",
|
|
2064
|
+
server.registerResource("cloudgrid-org-picker", GRID_PICKER_URI, {
|
|
1883
2065
|
description: "Org picker card — lets the user choose which organization to publish into.",
|
|
1884
2066
|
mimeType: "text/html;profile=mcp-app",
|
|
1885
2067
|
}, async () => ({
|
|
1886
2068
|
contents: [{
|
|
1887
|
-
uri:
|
|
2069
|
+
uri: GRID_PICKER_URI,
|
|
1888
2070
|
mimeType: "text/html;profile=mcp-app",
|
|
1889
|
-
text:
|
|
2071
|
+
text: GRID_PICKER_HTML,
|
|
1890
2072
|
_meta: { ui: { csp: WIDGET_CSP } },
|
|
1891
2073
|
}],
|
|
1892
2074
|
}));
|
|
@@ -2366,16 +2548,17 @@ export function registerTools(server, ctx) {
|
|
|
2366
2548
|
if (!token) {
|
|
2367
2549
|
return fail("You are not signed in. Run gridctl_login first.");
|
|
2368
2550
|
}
|
|
2369
|
-
const
|
|
2370
|
-
if (
|
|
2371
|
-
|
|
2551
|
+
const grids = await fetchUserOrgs(token);
|
|
2552
|
+
if (grids.length === 0) {
|
|
2553
|
+
// Structured output stays `orgs` (its declared schema); user text says grid.
|
|
2554
|
+
return okResult({ text: "No grids found.", structured: { orgs: [] } });
|
|
2372
2555
|
}
|
|
2373
|
-
const
|
|
2374
|
-
const annotated =
|
|
2556
|
+
const activeGrid = await ctx.getActiveGrid();
|
|
2557
|
+
const annotated = grids.map((o) => ({
|
|
2375
2558
|
...o,
|
|
2376
|
-
is_active: o.slug ===
|
|
2559
|
+
is_active: o.slug === activeGrid,
|
|
2377
2560
|
}));
|
|
2378
|
-
// Sort: active
|
|
2561
|
+
// Sort: active grid first, then ready grids, then not-ready grids.
|
|
2379
2562
|
annotated.sort((a, b) => {
|
|
2380
2563
|
if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
|
|
2381
2564
|
if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
|
|
@@ -2383,15 +2566,16 @@ export function registerTools(server, ctx) {
|
|
|
2383
2566
|
});
|
|
2384
2567
|
const lines = annotated.map((o) => {
|
|
2385
2568
|
const tags = [];
|
|
2386
|
-
if (o.is_active) tags.push("your active
|
|
2569
|
+
if (o.is_active) tags.push("your active grid");
|
|
2387
2570
|
if (!o.render_ready) tags.push("not set up yet");
|
|
2388
2571
|
const suffix = tags.length ? ` (${tags.join(", ")})` : "";
|
|
2389
2572
|
return `${o.slug} — ${o.name} (${o.role})${suffix}`;
|
|
2390
2573
|
});
|
|
2391
2574
|
const readyCount = annotated.filter((o) => o.render_ready).length;
|
|
2392
2575
|
if (readyCount === 0 && annotated.length > 0) {
|
|
2393
|
-
lines.push("\nNone of your
|
|
2576
|
+
lines.push("\nNone of your grids are fully set up yet. You can use an anonymous drop as a fallback, or wait until provisioning completes.");
|
|
2394
2577
|
}
|
|
2578
|
+
// Structured output stays `orgs` (its declared schema); user text says grid.
|
|
2395
2579
|
return okResult({ text: lines.join("\n"), structured: { orgs: annotated } });
|
|
2396
2580
|
},
|
|
2397
2581
|
);
|
|
@@ -2439,7 +2623,7 @@ export function registerTools(server, ctx) {
|
|
|
2439
2623
|
signedIn = false;
|
|
2440
2624
|
}
|
|
2441
2625
|
try {
|
|
2442
|
-
activeGrid = (await ctx.
|
|
2626
|
+
activeGrid = (await ctx.getActiveGrid()) ?? null;
|
|
2443
2627
|
} catch {
|
|
2444
2628
|
activeGrid = null;
|
|
2445
2629
|
}
|
|
@@ -2490,6 +2674,62 @@ export function registerTools(server, ctx) {
|
|
|
2490
2674
|
},
|
|
2491
2675
|
);
|
|
2492
2676
|
|
|
2677
|
+
// ── Consent-gated error reporting (Task 34) ───────────────────────────────
|
|
2678
|
+
// Both editions. The agent calls this ONLY after the user explicitly agrees
|
|
2679
|
+
// to report a genuine failure (the errorGuidance offer + the PLAYBOOK rule
|
|
2680
|
+
// gate on consent). Posts the error + failed-request context to the CloudGrid
|
|
2681
|
+
// team; the full conversation is never sent unless include_conversation is
|
|
2682
|
+
// explicitly set true (which the agent only does on an explicit yes).
|
|
2683
|
+
reg(
|
|
2684
|
+
"gridctl_report",
|
|
2685
|
+
{
|
|
2686
|
+
description:
|
|
2687
|
+
"Report a genuine CloudGrid failure to the CloudGrid team — ONLY with the user's explicit consent. When a build/deploy or platform call fails unexpectedly, ASK the user first; call this only after they say yes. Send a short `message` (what failed) plus `context` (the tool, inputs, grid, original request, error code/detail). By default it does NOT include the conversation — set include_conversation:true ONLY if the user explicitly agreed to send the chat. Obvious secrets in context are scrubbed before sending. Never sends anything the user didn't agree to.",
|
|
2688
|
+
inputSchema: {
|
|
2689
|
+
message: z
|
|
2690
|
+
.string()
|
|
2691
|
+
.describe("Short summary of what failed (required). Do not paste the whole conversation here."),
|
|
2692
|
+
context: z
|
|
2693
|
+
.object({
|
|
2694
|
+
tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. gridctl_drop."),
|
|
2695
|
+
inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
|
|
2696
|
+
grid: z.string().optional().describe("The grid/org slug involved, if any."),
|
|
2697
|
+
original_request: z.string().optional().describe("What the user asked for, in one line."),
|
|
2698
|
+
error_code: z.string().optional().describe("The server error code, e.g. INTERNAL_ERROR."),
|
|
2699
|
+
error_detail: z.string().optional().describe("The error message / detail surfaced to the agent."),
|
|
2700
|
+
})
|
|
2701
|
+
.partial()
|
|
2702
|
+
.optional()
|
|
2703
|
+
.describe("The failed-request context. Secret-looking values are scrubbed client-side and again server-side."),
|
|
2704
|
+
include_conversation: z
|
|
2705
|
+
.boolean()
|
|
2706
|
+
.optional()
|
|
2707
|
+
.describe("Default false. Set true ONLY if the user explicitly agreed to include the full conversation."),
|
|
2708
|
+
},
|
|
2709
|
+
outputSchema: {
|
|
2710
|
+
status: z
|
|
2711
|
+
.enum(["recorded", "rate_limited", "unauthorized", "error", "skipped"])
|
|
2712
|
+
.describe("Outcome of the report."),
|
|
2713
|
+
},
|
|
2714
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2715
|
+
},
|
|
2716
|
+
async (input) => {
|
|
2717
|
+
try {
|
|
2718
|
+
return await runReport(ctx, {
|
|
2719
|
+
message: input?.message,
|
|
2720
|
+
context: input?.context,
|
|
2721
|
+
include_conversation: input?.include_conversation === true,
|
|
2722
|
+
});
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
// Belt-and-suspenders: never throw noisily out of a report attempt.
|
|
2725
|
+
return okResult({
|
|
2726
|
+
text: "Couldn't send the report to the CloudGrid team right now. You can try again later.",
|
|
2727
|
+
structured: { status: "error" },
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
},
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2493
2733
|
if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
|
|
2494
2734
|
|
|
2495
2735
|
// ── CLI-wrapping tools (local edition only) ───────────────────────────────
|
|
@@ -2512,7 +2752,8 @@ export function registerTools(server, ctx) {
|
|
|
2512
2752
|
if (type) args.push("--type", type);
|
|
2513
2753
|
if (description) args.push("--description", description);
|
|
2514
2754
|
if (dir) args.push("--dir", dir);
|
|
2515
|
-
|
|
2755
|
+
// CLI 0.12 dropped `--org` in favour of `--grid` (same slug).
|
|
2756
|
+
if (org) args.push("--grid", org);
|
|
2516
2757
|
return args;
|
|
2517
2758
|
}, { cwdParam: true }),
|
|
2518
2759
|
);
|
|
@@ -2563,7 +2804,8 @@ export function registerTools(server, ctx) {
|
|
|
2563
2804
|
const args = ["feedback", "list"];
|
|
2564
2805
|
if (since) args.push("--since", since);
|
|
2565
2806
|
if (limit) args.push("--limit", String(limit));
|
|
2566
|
-
|
|
2807
|
+
// CLI 0.12 dropped `--org` in favour of `--grid` (same slug).
|
|
2808
|
+
if (org) args.push("--grid", org);
|
|
2567
2809
|
return args;
|
|
2568
2810
|
}),
|
|
2569
2811
|
);
|
|
@@ -2670,8 +2912,8 @@ export function registerTools(server, ctx) {
|
|
|
2670
2912
|
cliTool(({ name, target_dir, grid, version, force, no_bind }) => {
|
|
2671
2913
|
const args = ["pickup", name];
|
|
2672
2914
|
if (target_dir) args.push(target_dir);
|
|
2673
|
-
//
|
|
2674
|
-
if (grid) args.push("--
|
|
2915
|
+
// CLI 0.12's pickup exposes `--grid <slug>` (was legacy `--org`).
|
|
2916
|
+
if (grid) args.push("--grid", grid);
|
|
2675
2917
|
if (version) args.push("--version", version);
|
|
2676
2918
|
if (force) args.push("--force");
|
|
2677
2919
|
if (no_bind) args.push("--no-bind");
|
package/src/web.js
CHANGED
|
@@ -59,9 +59,9 @@ function makeWebContext(sessionId) {
|
|
|
59
59
|
canOpenBrowser: false,
|
|
60
60
|
// Transport OAuth wins; the in-tool login flow is the fallback.
|
|
61
61
|
getToken: async () => sessionAuth[sessionId] ?? sessionToken,
|
|
62
|
-
// No local config on a shared host. The user passes `
|
|
63
|
-
// the list of
|
|
64
|
-
|
|
62
|
+
// No local config on a shared host. The user passes `grid`, or the API returns
|
|
63
|
+
// the list of grids to choose from.
|
|
64
|
+
getActiveGrid: async () => null,
|
|
65
65
|
saveToken: async (jwt) => {
|
|
66
66
|
sessionToken = jwt;
|
|
67
67
|
return decodeJwt(jwt);
|