@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 CHANGED
@@ -15,11 +15,18 @@ drop, claim, and login tools.
15
15
 
16
16
  ## CLI compatibility
17
17
 
18
- MCP 0.5.2 is tested against CLI 0.10.1. The lazy-npx fallback pins
19
- `@cloudgrid-io/cli@~0.10.1` so a future CLI major with renamed verbs cannot
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.7.3",
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 org slug from ~/.cloudgrid/config.yaml, or null. A minimal
70
- // line parse — no YAML dependency for one field.
71
- export async function readActiveOrgSlug() {
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 = raw.match(/^\s*active_org_slug:\s*(\S+)\s*$/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
- readActiveOrgSlug,
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
- getActiveOrg: async () => await readActiveOrgSlug(),
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
- const ORG_PICKER_URI = "ui://cloudgrid/org-picker.html";
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 ORG_PICKER_HTML = readFileSync(new URL("./widgets/org-picker.html", import.meta.url), "utf-8");
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.5.2 is tested against CLI 0.10.1.
198
- const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.10.1";
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.10.1";
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
- const orgs = Array.isArray(data?.orgs) ? data.orgs : Array.isArray(data) ? data : [];
660
- return orgs.map((o) => ({
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 listOrgs = deps.fetchUserOrgs || fetchUserOrgs;
685
- const orgs = await listOrgs(token);
686
- const activeOrg = await ctx.getActiveOrg();
687
- const matched = suppliedGrid && orgs.find((o) => o.slug === 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 (orgs.length > 1) {
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 = orgs.map((o) => ({ ...o, is_active: o.slug === activeOrg }));
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": ORG_PICKER_URI } } : {}),
733
+ ...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
723
734
  },
724
735
  };
725
736
  }
726
- if (orgs.length === 1) {
727
- return { single: { ...orgs[0], is_active: orgs[0].slug === activeOrg } };
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
- if (orgSlug) hdrs["X-CloudGrid-Org"] = orgSlug;
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.getActiveOrg());
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
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
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.getActiveOrg();
1165
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
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.getActiveOrg());
1546
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
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.getActiveOrg();
1708
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
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.getActiveOrg());
1804
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
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", ORG_PICKER_URI, {
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: ORG_PICKER_URI,
2069
+ uri: GRID_PICKER_URI,
1888
2070
  mimeType: "text/html;profile=mcp-app",
1889
- text: ORG_PICKER_HTML,
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 orgs = await fetchUserOrgs(token);
2370
- if (orgs.length === 0) {
2371
- return okResult({ text: "No organizations found.", structured: { orgs: [] } });
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 activeOrg = await ctx.getActiveOrg();
2374
- const annotated = orgs.map((o) => ({
2556
+ const activeGrid = await ctx.getActiveGrid();
2557
+ const annotated = grids.map((o) => ({
2375
2558
  ...o,
2376
- is_active: o.slug === activeOrg,
2559
+ is_active: o.slug === activeGrid,
2377
2560
  }));
2378
- // Sort: active org first, then ready orgs, then not-ready orgs.
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 org");
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 orgs are fully set up yet. You can use an anonymous drop as a fallback, or wait until provisioning completes.");
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.getActiveOrg()) ?? null;
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
- if (org) args.push("--org", org);
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
- if (org) args.push("--org", org);
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
- // The CLI's pickup --help exposes `--org <slug>` (legacy naming) for the grid.
2674
- if (grid) args.push("--org", grid);
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 `org`, or the API returns
63
- // the list of orgs to choose from.
64
- getActiveOrg: async () => null,
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);