@cloudgrid-io/mcp 0.16.1 → 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 +1 -1
  2. package/src/tools.js +76 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.16.1",
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": {
package/src/tools.js CHANGED
@@ -2330,11 +2330,66 @@ function isCloudgridHost(hostname) {
2330
2330
  return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
2331
2331
  }
2332
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
+
2333
2387
  // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
2334
2388
  // that lost the content can edit it and re-plug in place. Resolves the fetch URL
2335
- // (explicit url → session lastDrop → composed grid+slug), enforces the
2336
- // `*.cloudgrid.io` SSRF guard, caps the body at SOURCE_MAX_BYTES, and fails
2337
- // 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.
2338
2393
  export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2339
2394
  const last = ctx.state.lastDrop;
2340
2395
  // Resolution order: explicit url → session lastDrop.url (if entity_id matches
@@ -2367,6 +2422,23 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2367
2422
  }
2368
2423
 
2369
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).
2370
2442
  let res;
2371
2443
  try {
2372
2444
  res = await fetch(resolvedUrl, {
@@ -2398,30 +2470,7 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2398
2470
  }
2399
2471
 
2400
2472
  const buf = Buffer.from(await res.arrayBuffer());
2401
- const totalBytes = buf.length;
2402
- const truncated = totalBytes > SOURCE_MAX_BYTES;
2403
- const html = (truncated ? buf.subarray(0, SOURCE_MAX_BYTES) : buf).toString("utf-8");
2404
-
2405
- const lines = [
2406
- `Current source for ${resolvedUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
2407
- ];
2408
- if (truncated) {
2409
- lines.push(
2410
- "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
2411
- );
2412
- }
2413
- lines.push("", html);
2414
-
2415
- return {
2416
- text: lines.join("\n"),
2417
- structured: {
2418
- url: resolvedUrl,
2419
- entity_id: entity_id ?? last?.entity_id ?? null,
2420
- bytes: totalBytes,
2421
- truncated,
2422
- html,
2423
- },
2424
- };
2473
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"));
2425
2474
  }
2426
2475
 
2427
2476
  // ── Registration ───────────────────────────────────────────────────────────────