@cloudgrid-io/mcp 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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/index.js CHANGED
@@ -31,5 +31,17 @@ const ctx = {
31
31
  const server = new McpServer({ name: "cloudgrid-mcp", version });
32
32
  registerTools(server, ctx);
33
33
 
34
+ // Capture the calling agent's clientInfo (name+version) once the MCP handshake
35
+ // completes, so gridctl_report can attribute the report's origin (which agent).
36
+ // getClientVersion() is populated by the SDK from the initialize request's
37
+ // clientInfo. Never fatal — a missing client just falls back to "unknown".
38
+ server.server.oninitialized = () => {
39
+ try {
40
+ ctx.state.client = server.server.getClientVersion() ?? null;
41
+ } catch {
42
+ ctx.state.client = null;
43
+ }
44
+ };
45
+
34
46
  const transport = new StdioServerTransport();
35
47
  await server.connect(transport);
package/src/tools.js CHANGED
@@ -25,6 +25,16 @@ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid
25
25
  "",
26
26
  );
27
27
 
28
+ // This MCP server's version — mirrors the CLI's cli_version in a report's origin.
29
+ // Read once from package.json; never throw (a report must never fail on this).
30
+ export const MCP_VERSION = (() => {
31
+ try {
32
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
33
+ } catch {
34
+ return "unknown";
35
+ }
36
+ })();
37
+
28
38
  const ANON_HTML_MAX_BYTES = 2_000_000;
29
39
  const CONSOLE_URL = "https://console.cloudgrid.io/";
30
40
 
@@ -69,6 +79,7 @@ Operating rules:
69
79
  5. Minimize questions. Use sensible defaults and build; don't front-load setup questions.
70
80
  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.
71
81
  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.
82
+ 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.
72
83
 
73
84
  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.`;
74
85
 
@@ -1132,6 +1143,194 @@ export async function runDrop(
1132
1143
  };
1133
1144
  }
1134
1145
 
1146
+ // ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
1147
+ // Key names that look like they carry a secret. Mirrors the server's
1148
+ // SECRET_KEY_PATTERNS (packages/api/src/routes/errors.ts) so the MCP scrubs the
1149
+ // same shapes client-side — defense-in-depth on top of the server redaction.
1150
+ const REPORT_SECRET_KEY_PATTERNS = [
1151
+ /password/i,
1152
+ /secret/i,
1153
+ /token/i,
1154
+ /api[_-]?key/i,
1155
+ /auth/i,
1156
+ /credential/i,
1157
+ /private[_-]?key/i,
1158
+ /access[_-]?key/i,
1159
+ ];
1160
+
1161
+ // Light client-side scrub of obviously secret-looking values in the report
1162
+ // context. Redacts values under secret-looking KEYS; leaves everything else
1163
+ // intact (the server does the authoritative redaction). Bounded depth so a
1164
+ // pathological object can't loop.
1165
+ export function scrubReportContext(obj, depth = 0) {
1166
+ if (depth > 5 || obj === null || typeof obj !== "object") return obj;
1167
+ if (Array.isArray(obj)) return obj.map((item) => scrubReportContext(item, depth + 1));
1168
+ const out = {};
1169
+ for (const [key, value] of Object.entries(obj)) {
1170
+ const isSecret = REPORT_SECRET_KEY_PATTERNS.some((re) => re.test(key));
1171
+ out[key] = isSecret ? "[REDACTED]" : scrubReportContext(value, depth + 1);
1172
+ }
1173
+ return out;
1174
+ }
1175
+
1176
+ // Send a consent-gated bug report to the CloudGrid team. The agent calls this
1177
+ // ONLY after the user explicitly agrees (see errorGuidance + the PLAYBOOK rule).
1178
+ //
1179
+ // Matches the CLI reporter (packages/cli error-reporter.ts → client.reportError):
1180
+ // POST /api/v2/errors with the CLI payload shape
1181
+ // { type:'error', category, app, message, stack?, context, trace_id?,
1182
+ // failed_step?, http_status?, cli_version, node_version, platform }
1183
+ // so CLI + MCP reports land uniformly in the `errors` collection.
1184
+ //
1185
+ // Source attribution (Gilad's ask): every report says WHERE it came from —
1186
+ // source (mcp-stdio | mcp-hosted), client (the calling agent from MCP clientInfo),
1187
+ // platform, mcp_version. Sent BOTH as top-level fields AND mirrored in
1188
+ // `context.origin`. The POST /errors handler only persists known top-level keys
1189
+ // (it drops unknown ones), and `context` is stored + secret-stripped server-side,
1190
+ // so `context.origin` is the durable carrier — belt-and-suspenders.
1191
+ //
1192
+ // Auth: signed-in → Bearer; anon+web → the trusted-server headers (works once the
1193
+ // endpoint accepts the credential; until then a 401 degrades to "sign in to
1194
+ // report"). Honors CLOUDGRID_TELEMETRY=off (matches the CLI). Never throws.
1195
+ export async function runReport(
1196
+ ctx,
1197
+ { message, context, include_conversation, category, trace_id, failed_step, http_status } = {},
1198
+ ) {
1199
+ const summary = typeof message === "string" ? message.trim() : "";
1200
+ if (!summary) {
1201
+ return okResult({
1202
+ text: "Nothing to report — provide a short `message` describing what failed.",
1203
+ structured: { status: "skipped" },
1204
+ });
1205
+ }
1206
+
1207
+ // Privacy escape hatch — no telemetry when explicitly disabled (matches the CLI
1208
+ // reporter's CLOUDGRID_TELEMETRY=off). Consent still gates the call regardless;
1209
+ // this is the belt-and-suspenders global opt-out. Nothing leaves the process.
1210
+ if (process.env.CLOUDGRID_TELEMETRY === "off") {
1211
+ return okResult({
1212
+ text: "Error reporting is disabled (CLOUDGRID_TELEMETRY=off) — nothing was sent.",
1213
+ structured: { status: "disabled" },
1214
+ });
1215
+ }
1216
+
1217
+ // ── Source attribution ──────────────────────────────────────────────────────
1218
+ // source: mcp-stdio (local edition) | mcp-hosted (web edition).
1219
+ const source = ctx.edition === "web" ? "mcp-hosted" : "mcp-stdio";
1220
+ // client: the calling agent captured from the MCP clientInfo at initialize.
1221
+ // Falls back to "unknown" — a report must never fail on missing client info.
1222
+ const ci = ctx.state?.client;
1223
+ const client =
1224
+ ci && ci.name
1225
+ ? ci.version
1226
+ ? `${ci.name} ${ci.version}`
1227
+ : String(ci.name)
1228
+ : "unknown";
1229
+ const platform = `${process.platform} ${process.arch}`;
1230
+
1231
+ // Belt-and-suspenders scrub before the value ever leaves the process. The
1232
+ // origin block is authored by us (not user/agent input), so it is appended
1233
+ // AFTER the scrub — it carries no secrets and must survive verbatim.
1234
+ const scrubbed =
1235
+ context && typeof context === "object" ? scrubReportContext(context) : {};
1236
+ const safeContext = {
1237
+ ...scrubbed,
1238
+ origin: {
1239
+ source,
1240
+ client,
1241
+ platform,
1242
+ mcp_version: MCP_VERSION,
1243
+ },
1244
+ };
1245
+
1246
+ // The full conversation is NEVER included unless the agent explicitly set the
1247
+ // flag (which the PLAYBOOK gates on the user's explicit yes). This tool only
1248
+ // records the flag alongside the report so intent is auditable — it does not
1249
+ // itself have the transcript.
1250
+ const body = {
1251
+ type: "error",
1252
+ // category: default "mcp" (or the failing tool name the agent passes).
1253
+ category: typeof category === "string" && category.trim() ? category.trim() : "mcp",
1254
+ app: "mcp",
1255
+ message: summary.slice(0, 5000),
1256
+ context: safeContext,
1257
+ // Diagnostic pivots (match the CLI) — only when the agent forwards them.
1258
+ ...(typeof trace_id === "string" && trace_id ? { trace_id } : {}),
1259
+ ...(typeof failed_step === "string" && failed_step ? { failed_step } : {}),
1260
+ ...(typeof http_status === "number" && Number.isFinite(http_status) ? { http_status } : {}),
1261
+ // Attribution, ALSO top-level (persisted once the handler accepts these keys;
1262
+ // context.origin is the fallback until then).
1263
+ source,
1264
+ client,
1265
+ platform,
1266
+ // cli_version stays null for MCP-originated reports; mcp_version carries our
1267
+ // version in context.origin (the CLI-analog lives there).
1268
+ cli_version: null,
1269
+ node_version: process.version,
1270
+ ...(include_conversation === true ? { include_conversation: true } : {}),
1271
+ };
1272
+
1273
+ // Auth: signed-in → Bearer; anon + web edition → trusted-server headers.
1274
+ const headers = { "content-type": "application/json" };
1275
+ let token = null;
1276
+ try {
1277
+ token = await ctx.getToken();
1278
+ } catch {
1279
+ token = null;
1280
+ }
1281
+ let usedTrustedServer = false;
1282
+ if (token) {
1283
+ headers["Authorization"] = `Bearer ${token}`;
1284
+ } else if (ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1285
+ // Web edition anon path — works ONCE the endpoint accepts the trusted-server
1286
+ // credential (Gilad-side change). Until then the server 401s and we degrade.
1287
+ headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1288
+ headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1289
+ usedTrustedServer = true;
1290
+ }
1291
+
1292
+ let res;
1293
+ try {
1294
+ res = await fetch(`${API_BASE}/api/v2/errors`, {
1295
+ method: "POST",
1296
+ headers,
1297
+ body: JSON.stringify(body),
1298
+ });
1299
+ } catch (err) {
1300
+ return okResult({
1301
+ text: `Couldn't reach the CloudGrid team right now (${err.message}). Nothing was sent — you can try again later.`,
1302
+ structured: { status: "error" },
1303
+ });
1304
+ }
1305
+
1306
+ if (res.status === 201 || res.ok) {
1307
+ return okResult({
1308
+ text: "Reported to the CloudGrid team — thank you.",
1309
+ structured: { status: "recorded" },
1310
+ });
1311
+ }
1312
+ if (res.status === 429) {
1313
+ return okResult({
1314
+ text: "Already reported a lot recently; try again later.",
1315
+ structured: { status: "rate_limited" },
1316
+ });
1317
+ }
1318
+ if (res.status === 401) {
1319
+ // Anon reporting isn't accepted yet (needs the Gilad-side endpoint change);
1320
+ // degrade gracefully rather than erroring.
1321
+ return okResult({
1322
+ text: usedTrustedServer || !token
1323
+ ? "Sign in to send a report to the CloudGrid team (gridctl_login), then try again."
1324
+ : "That didn't authorize a report. Sign in again (gridctl_login) and retry.",
1325
+ structured: { status: "unauthorized" },
1326
+ });
1327
+ }
1328
+ return okResult({
1329
+ text: "Couldn't send the report to the CloudGrid team right now. Nothing else was sent — you can try again later.",
1330
+ structured: { status: "error" },
1331
+ });
1332
+ }
1333
+
1135
1334
  async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
1136
1335
  const token = await ctx.getToken();
1137
1336
  if (!token) {
@@ -1394,9 +1593,33 @@ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn
1394
1593
  if (status === 403) {
1395
1594
  return "You lack the role to plug this target. To re-plug someone else's entity, pick it up first (gridctl_pickup / gridctl_claim).";
1396
1595
  }
1596
+ // ── Consent-gated report offer (Task 34) ──────────────────────────────────
1597
+ // GENUINE bugs only: a build/deploy failure, any 5xx, INTERNAL_ERROR, or an
1598
+ // unknown/unmapped error. Everything above (429 rate-limit, needs_grid picker,
1599
+ // 401 sign-in prompts, 409 EDIT_REJECTED, 403) is an EXPECTED condition, not a
1600
+ // bug — those returned already and never reach here, so they never get the
1601
+ // offer. The offer tells the agent to ask permission first and never send the
1602
+ // full conversation without an explicit yes.
1603
+ const isServerError = typeof status === "number" && status >= 500;
1604
+ const isInternalError = code === "INTERNAL_ERROR";
1605
+ const isBuildFailure = code === "BUILD_FAILED" || code === "DEPLOY_FAILED";
1606
+ if (isServerError || isInternalError || isBuildFailure) {
1607
+ return REPORT_OFFER;
1608
+ }
1609
+ // A 4xx with an unknown/unmapped code is a client-side condition (validation,
1610
+ // bad input), not a server bug — pass through unchanged so callers don't
1611
+ // rewrite it or wrongly offer to report it.
1397
1612
  return null;
1398
1613
  }
1399
1614
 
1615
+ // The consent-gated report affordance appended to genuine-bug guidance. It
1616
+ // instructs the agent to get explicit permission before calling gridctl_report,
1617
+ // and to never send the whole conversation without an explicit yes.
1618
+ export const REPORT_OFFER =
1619
+ "If this looks like a CloudGrid bug, ASK the user for permission to report it to the CloudGrid team, " +
1620
+ "then call gridctl_report with the error + the failed request context. " +
1621
+ "Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
1622
+
1400
1623
  // Map friendly plug error statuses to actionable messages (spec v2 §3.3).
1401
1624
  // Appends errorGuidance() for known codes; unknown codes pass through as the
1402
1625
  // bare `base` line, unchanged.
@@ -2527,6 +2750,82 @@ export function registerTools(server, ctx) {
2527
2750
  },
2528
2751
  );
2529
2752
 
2753
+ // ── Consent-gated error reporting (Task 34) ───────────────────────────────
2754
+ // Both editions. The agent calls this ONLY after the user explicitly agrees
2755
+ // to report a genuine failure (the errorGuidance offer + the PLAYBOOK rule
2756
+ // gate on consent). Posts the error + failed-request context to the CloudGrid
2757
+ // team; the full conversation is never sent unless include_conversation is
2758
+ // explicitly set true (which the agent only does on an explicit yes).
2759
+ reg(
2760
+ "gridctl_report",
2761
+ {
2762
+ description:
2763
+ "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.",
2764
+ inputSchema: {
2765
+ message: z
2766
+ .string()
2767
+ .describe("Short summary of what failed (required). Do not paste the whole conversation here."),
2768
+ context: z
2769
+ .object({
2770
+ tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. gridctl_drop."),
2771
+ inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
2772
+ grid: z.string().optional().describe("The grid/org slug involved, if any."),
2773
+ original_request: z.string().optional().describe("What the user asked for, in one line."),
2774
+ error_code: z.string().optional().describe("The server error code, e.g. INTERNAL_ERROR."),
2775
+ error_detail: z.string().optional().describe("The error message / detail surfaced to the agent."),
2776
+ })
2777
+ .partial()
2778
+ .optional()
2779
+ .describe("The failed-request context. Secret-looking values are scrubbed client-side and again server-side."),
2780
+ include_conversation: z
2781
+ .boolean()
2782
+ .optional()
2783
+ .describe("Default false. Set true ONLY if the user explicitly agreed to include the full conversation."),
2784
+ category: z
2785
+ .string()
2786
+ .optional()
2787
+ .describe("Optional category, e.g. the failing tool name (\"deploy\"). Defaults to \"mcp\"."),
2788
+ trace_id: z
2789
+ .string()
2790
+ .optional()
2791
+ .describe("The server's trace/deploy id from the failed response, if any (helps support pivot to the trace)."),
2792
+ failed_step: z
2793
+ .string()
2794
+ .optional()
2795
+ .describe("The server-side pipeline step that failed, if known."),
2796
+ http_status: z
2797
+ .number()
2798
+ .optional()
2799
+ .describe("The HTTP status of the final failed request, if applicable."),
2800
+ },
2801
+ outputSchema: {
2802
+ status: z
2803
+ .enum(["recorded", "rate_limited", "unauthorized", "error", "skipped", "disabled"])
2804
+ .describe("Outcome of the report."),
2805
+ },
2806
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2807
+ },
2808
+ async (input) => {
2809
+ try {
2810
+ return await runReport(ctx, {
2811
+ message: input?.message,
2812
+ context: input?.context,
2813
+ include_conversation: input?.include_conversation === true,
2814
+ category: input?.category,
2815
+ trace_id: input?.trace_id,
2816
+ failed_step: input?.failed_step,
2817
+ http_status: input?.http_status,
2818
+ });
2819
+ } catch (err) {
2820
+ // Belt-and-suspenders: never throw noisily out of a report attempt.
2821
+ return okResult({
2822
+ text: "Couldn't send the report to the CloudGrid team right now. You can try again later.",
2823
+ structured: { status: "error" },
2824
+ });
2825
+ }
2826
+ },
2827
+ );
2828
+
2530
2829
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
2531
2830
 
2532
2831
  // ── CLI-wrapping tools (local edition only) ───────────────────────────────
package/src/web.js CHANGED
@@ -132,7 +132,18 @@ app.post("/mcp", async (req, res) => {
132
132
  }
133
133
  };
134
134
  const server = new McpServer({ name: "cloudgrid-mcp-web", version });
135
- registerTools(server, makeWebContext(newSessionId));
135
+ const webCtx = makeWebContext(newSessionId);
136
+ registerTools(server, webCtx);
137
+ // Capture the calling agent's clientInfo (name+version) for this session once
138
+ // the MCP handshake completes, so gridctl_report can attribute the origin
139
+ // (which agent: Claude/ChatGPT/Cursor/…). Never fatal — missing → "unknown".
140
+ server.server.oninitialized = () => {
141
+ try {
142
+ webCtx.state.client = server.server.getClientVersion() ?? null;
143
+ } catch {
144
+ webCtx.state.client = null;
145
+ }
146
+ };
136
147
  await server.connect(transport);
137
148
  await transport.handleRequest(req, res, req.body);
138
149
  });