@cloudgrid-io/mcp 0.16.0 → 0.16.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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/tools.js +86 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.16.0",
3
+ "version": "0.16.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": {
@@ -39,7 +39,8 @@
39
39
  "smoke:docs": "node test/smoke-docs.mjs",
40
40
  "start:docs": "node src/docs.js",
41
41
  "snapshot:corpus": "node scripts/snapshot-corpus.mjs",
42
- "build:mcpb": "node scripts/build-mcpb.mjs"
42
+ "build:mcpb": "node scripts/build-mcpb.mjs",
43
+ "test:widgets": "node test/apps-widgets.test.mjs"
43
44
  },
44
45
  "dependencies": {
45
46
  "@modelcontextprotocol/sdk": "^1.0.0",
package/src/tools.js CHANGED
@@ -45,6 +45,14 @@ const AUTHED_HTML_MAX_BYTES = 25_000_000;
45
45
  const CONSOLE_URL = "https://console.cloudgrid.io/";
46
46
 
47
47
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
48
+ // The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
49
+ // as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
50
+ // them behind an env flag, DEFAULT OFF, so the drop/plug result is text-first
51
+ // (the live URL is already the first line of the text content) and the widget is
52
+ // optional. Flip MCP_APPS_WIDGETS=1 in the platform manifest to re-enable once
53
+ // the widget HTML is verified to render. The resources stay registered either
54
+ // way (harmless when no tool references them via outputTemplate).
55
+ const APPS_WIDGETS_ENABLED = process.env.MCP_APPS_WIDGETS === "1";
48
56
  const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
49
57
  // URI/resource-name/filename stay `org-picker` — that's the stable contract the
50
58
  // web card is registered under; only the JS identifier moves toward grid.
@@ -753,7 +761,7 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
753
761
  // `needs_grid` is the new field; `needs_org`/`orgs` are kept as aliases
754
762
  // so the existing org-picker.html web widget (reads data.orgs) still works.
755
763
  structured: { needs_grid: true, needs_org: true, grids: annotated, orgs: annotated },
756
- ...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
764
+ ...(edition === "web" && APPS_WIDGETS_ENABLED ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
757
765
  },
758
766
  };
759
767
  }
@@ -2322,11 +2330,66 @@ function isCloudgridHost(hostname) {
2322
2330
  return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
2323
2331
  }
2324
2332
 
2333
+ // Shape an HTML string into the gridctl_source result (shared by the API-read
2334
+ // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES.
2335
+ function shapeSourceResult(sourceUrl, entityId, htmlStr) {
2336
+ const buf = Buffer.from(htmlStr, "utf-8");
2337
+ const totalBytes = buf.length;
2338
+ const truncated = totalBytes > SOURCE_MAX_BYTES;
2339
+ const html = truncated ? buf.subarray(0, SOURCE_MAX_BYTES).toString("utf-8") : htmlStr;
2340
+ const lines = [
2341
+ `Current source for ${sourceUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
2342
+ ];
2343
+ if (truncated) {
2344
+ lines.push(
2345
+ "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
2346
+ );
2347
+ }
2348
+ lines.push("", html);
2349
+ return {
2350
+ text: lines.join("\n"),
2351
+ structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html },
2352
+ };
2353
+ }
2354
+
2355
+ // Read an inspiration's HTML via the API (server-side storage read) instead of
2356
+ // fetching the public *.cloudgrid.io URL. Critical on the hosted edition: the
2357
+ // MCP pod can reach the API (it POSTs /api/v2/plug) but CANNOT egress to the
2358
+ // public ingress ("fetch failed"). getInspirationSource resolves directly by
2359
+ // entity_id when the path segment is a UUID (no org context needed), else by
2360
+ // slug + the X-CloudGrid-Grid active-org header. Returns the html string, or
2361
+ // null if the API can't serve it (→ caller falls back to the direct fetch).
2362
+ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
2363
+ let token = null;
2364
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine for public */ }
2365
+ const baseHeaders = token ? { Authorization: `Bearer ${token}` } : {};
2366
+ const attempts = [];
2367
+ if (entityId) attempts.push({ seg: entityId, grid: null }); // UUID → resolves by id, no org ctx
2368
+ if (slug) attempts.push({ seg: slug, grid: grid || null }); // slug → needs org (grid) context
2369
+ for (const a of attempts) {
2370
+ try {
2371
+ const headers = { ...baseHeaders };
2372
+ if (a.grid) headers["X-CloudGrid-Grid"] = a.grid;
2373
+ const res = await fetch(
2374
+ `${API_BASE}/api/v2/inspirations/${encodeURIComponent(a.seg)}/source`,
2375
+ { method: "GET", headers, signal: AbortSignal.timeout(15_000) },
2376
+ );
2377
+ if (!res.ok) continue;
2378
+ const data = await res.json().catch(() => null);
2379
+ if (data && typeof data.html === "string") return data.html;
2380
+ } catch {
2381
+ // try the next attempt, then the public-fetch fallback
2382
+ }
2383
+ }
2384
+ return null;
2385
+ }
2386
+
2325
2387
  // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
2326
2388
  // that lost the content can edit it and re-plug in place. Resolves the fetch URL
2327
- // (explicit url → session lastDrop → composed grid+slug), enforces the
2328
- // `*.cloudgrid.io` SSRF guard, caps the body at SOURCE_MAX_BYTES, and fails
2329
- // gracefully on a non-200. Uses the global `fetch` seam so tests can mock it.
2389
+ // (explicit url → session lastDrop → composed grid+slug), then reads the HTML
2390
+ // via the API (reachable) BEFORE falling back to a direct `*.cloudgrid.io` fetch
2391
+ // (SSRF-guarded, capped at SOURCE_MAX_BYTES). Uses the global `fetch` seam so
2392
+ // tests can mock it.
2330
2393
  export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2331
2394
  const last = ctx.state.lastDrop;
2332
2395
  // Resolution order: explicit url → session lastDrop.url (if entity_id matches
@@ -2359,6 +2422,23 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2359
2422
  }
2360
2423
 
2361
2424
  const resolvedUrl = parsed.toString();
2425
+ const eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2426
+
2427
+ // ── API-first (reachable) ────────────────────────────────────────────────
2428
+ // Read the HTML from the API server-side rather than fetching the public URL.
2429
+ // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
2430
+ // ingress ("fetch failed") — this is the fix for hosted edit-in-place. Derive
2431
+ // the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
2432
+ const host = parsed.hostname;
2433
+ const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
2434
+ ? host.split(".")[0] : null);
2435
+ const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
2436
+ const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
2437
+ if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml);
2438
+
2439
+ // ── Fallback: direct public fetch ────────────────────────────────────────
2440
+ // Local edition has normal egress; last resort on hosted (e.g. a runtime app
2441
+ // whose source the inspiration route can't serve).
2362
2442
  let res;
2363
2443
  try {
2364
2444
  res = await fetch(resolvedUrl, {
@@ -2390,30 +2470,7 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2390
2470
  }
2391
2471
 
2392
2472
  const buf = Buffer.from(await res.arrayBuffer());
2393
- const totalBytes = buf.length;
2394
- const truncated = totalBytes > SOURCE_MAX_BYTES;
2395
- const html = (truncated ? buf.subarray(0, SOURCE_MAX_BYTES) : buf).toString("utf-8");
2396
-
2397
- const lines = [
2398
- `Current source for ${resolvedUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
2399
- ];
2400
- if (truncated) {
2401
- lines.push(
2402
- "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
2403
- );
2404
- }
2405
- lines.push("", html);
2406
-
2407
- return {
2408
- text: lines.join("\n"),
2409
- structured: {
2410
- url: resolvedUrl,
2411
- entity_id: entity_id ?? last?.entity_id ?? null,
2412
- bytes: totalBytes,
2413
- truncated,
2414
- html,
2415
- },
2416
- };
2473
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"));
2417
2474
  }
2418
2475
 
2419
2476
  // ── Registration ───────────────────────────────────────────────────────────────
@@ -2531,7 +2588,7 @@ export function registerTools(server, ctx) {
2531
2588
  login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
2532
2589
  },
2533
2590
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2534
- ...(ctx.edition === "web" ? {
2591
+ ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
2535
2592
  _meta: {
2536
2593
  ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
2537
2594
  "openai/outputTemplate": LIVE_RESULT_URI,