@cloudgrid-io/mcp 0.18.0 → 0.19.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +139 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.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": {
package/src/tools.js CHANGED
@@ -100,6 +100,10 @@ Operating rules:
100
100
  12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Classify the ARTIFACT to pick the deploy: ONE self-contained HTML page (a single file — CSS+JS inline, images/fonts as data: URIs; that is the normal hosted output) → an inspiration — instant, ANY edition, deploy via grid_plug with the inline html param. Only genuinely SEPARATE files/folders (a real assets/ dir, separate .css/.js files, multiple pages, a SPA build) — OR anything needing needs: (data/server/LLM/cron) → a runtime app — grid_plug on a linked folder with a cloudgrid.yaml, local edition only, async build.
101
101
  13. Before writing a cloudgrid.yaml, fetch the reference: grid_fetch({kind:"doc", name:"cloudgrid-yaml"}) — it has the full schema and the needs: vocabulary. Declare infrastructure with needs: (the deployer injects from it): needs: { database: true } → Mongo (DATABASE_MONGODB_URL); needs: { cache: true } → Redis; scheduled work → a service of type: cron (Python or Node). Use needs: OR requires:, never both — declaring both is rejected.
102
102
  14. Databases — CloudGrid supports both, so never tell the user to self-host. Managed: needs: { database: true } provisions Mongo and injects DATABASE_MONGODB_URL. Bring-your-own (they already run Postgres / MySQL / MongoDB / Supabase / Neon / PlanetScale / etc.): needs: { database: { tier: external, secret: MY_DB } } plus grid secrets set MY_DB=<connection-string> — the connection string lives in env SECRETS, never committed. If asked "what databases does CloudGrid support?": all of them — the managed CloudGrid database out of the box, or bring your own via keys — ask which they want.
103
+ 15. Editing an existing thing from just its URL (a fresh chat, no prior context — e.g. "change the background to green here <url>"): call grid_source(url) first. It resolves the entity_id and returns the current HTML plus its kind, single_html, capabilities, and replug_handle — read those to pick the branch:
104
+ - Single-HTML inspiration you can re-plug (single_html: true and capabilities.replug: true): edit the returned HTML and call grid_plug with target_entity_id (or grid+slug — the replug_handle) to update the SAME URL in place. This works on every edition, including hosted.
105
+ - Multi-file app or agent (kind is app or agent, or single_html: false): do NOT try to edit it as one inline HTML file. Tell the user it is a multi-file <kind>, give them the entity_id and the source (source_download_url), and explain that rebuilding it needs the local edition (Claude Desktop/Code) or the CLI — the hosted server cannot rebuild a multi-file app.
106
+ - Not yours (capabilities.replug: false, reason not_owner): do NOT attempt a re-plug. Offer to fork it into the user's own grid with grid_fork and edit the copy instead.
103
107
 
104
108
  Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.`;
105
109
 
@@ -1520,6 +1524,7 @@ export async function runPlug(ctx, input, deps = {}) {
1520
1524
  cloudgrid_yaml,
1521
1525
  target_entity_id,
1522
1526
  grid,
1527
+ slug,
1523
1528
  hints,
1524
1529
  anon,
1525
1530
  owner_token,
@@ -1575,7 +1580,19 @@ export async function runPlug(ctx, input, deps = {}) {
1575
1580
  );
1576
1581
  }
1577
1582
 
1578
- const isEdit = typeof target_entity_id === "string" && target_entity_id.length > 0;
1583
+ // grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
1584
+ // explicit target_entity_id was given but a grid+slug pair is, resolve it to
1585
+ // an existing entity_id and re-plug that in place. A slug that does NOT resolve
1586
+ // to an existing entity → targetEntityId stays empty → this is a CREATE (no
1587
+ // false-positive re-plug). target_entity_id remains the primary/documented
1588
+ // handle. Best-effort resolve (pickup contract); never fetches the public URL.
1589
+ let targetEntityId = target_entity_id;
1590
+ if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
1591
+ const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
1592
+ if (resolved?.entity_id) targetEntityId = resolved.entity_id;
1593
+ }
1594
+
1595
+ const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
1579
1596
 
1580
1597
  // An inspiration edit content-versions the FIRST uploaded artifact — when a
1581
1598
  // multi-file folder rides a re-plug, put the primary entry first so the edit
@@ -1595,10 +1612,10 @@ export async function runPlug(ctx, input, deps = {}) {
1595
1612
  if (isEdit && !ownerToken) {
1596
1613
  // Recover the owner token from session state when re-plugging the drop this
1597
1614
  // session made anonymously.
1598
- if (ctx.state.lastDrop?.entity_id === target_entity_id && ctx.state.lastDrop.owner_token) {
1615
+ if (ctx.state.lastDrop?.entity_id === targetEntityId && ctx.state.lastDrop.owner_token) {
1599
1616
  ownerToken = ctx.state.lastDrop.owner_token;
1600
1617
  } else if (
1601
- ctx.state.lastAnonClaim?.entity_id === target_entity_id &&
1618
+ ctx.state.lastAnonClaim?.entity_id === targetEntityId &&
1602
1619
  ctx.state.lastAnonClaim.token
1603
1620
  ) {
1604
1621
  ownerToken = ctx.state.lastAnonClaim.token;
@@ -1686,7 +1703,7 @@ export async function runPlug(ctx, input, deps = {}) {
1686
1703
  form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
1687
1704
  }
1688
1705
  if (isEdit) {
1689
- form.append("target_entity_id", target_entity_id);
1706
+ form.append("target_entity_id", targetEntityId);
1690
1707
  if (useAnonWire) {
1691
1708
  form.append("owner_token", ownerToken);
1692
1709
  } else {
@@ -2038,8 +2055,10 @@ function isCloudgridHost(hostname) {
2038
2055
  }
2039
2056
 
2040
2057
  // Shape an HTML string into the grid_source result (shared by the API-read
2041
- // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES.
2042
- function shapeSourceResult(sourceUrl, entityId, htmlStr) {
2058
+ // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
2059
+ // optional edition metadata resolved from the pickup contract (kind, single_html,
2060
+ // capabilities, replug_handle, source_download_url) — merged into structured.
2061
+ function shapeSourceResult(sourceUrl, entityId, htmlStr, extra = {}) {
2043
2062
  const buf = Buffer.from(htmlStr, "utf-8");
2044
2063
  const totalBytes = buf.length;
2045
2064
  const truncated = totalBytes > SOURCE_MAX_BYTES;
@@ -2055,7 +2074,7 @@ function shapeSourceResult(sourceUrl, entityId, htmlStr) {
2055
2074
  lines.push("", html);
2056
2075
  return {
2057
2076
  text: lines.join("\n"),
2058
- structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html },
2077
+ structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html, ...extra },
2059
2078
  };
2060
2079
  }
2061
2080
 
@@ -2082,8 +2101,17 @@ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
2082
2101
  { method: "GET", headers, signal: AbortSignal.timeout(15_000) },
2083
2102
  );
2084
2103
  if (!res.ok) continue;
2085
- const data = await res.json().catch(() => null);
2086
- if (data && typeof data.html === "string") return data.html;
2104
+ // The route (GET /v2/inspirations/:seg/source) serves the RAW HTML bytes
2105
+ // as text/html — NOT a JSON { html } envelope. Read the body as text.
2106
+ // (Tolerate a JSON { html } shape too, in case a variant ever returns it.)
2107
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
2108
+ if (ct.includes("application/json")) {
2109
+ const data = await res.json().catch(() => null);
2110
+ if (data && typeof data.html === "string") return data.html;
2111
+ continue;
2112
+ }
2113
+ const html = await res.text().catch(() => null);
2114
+ if (typeof html === "string" && html.length > 0) return html;
2087
2115
  } catch {
2088
2116
  // try the next attempt, then the public-fetch fallback
2089
2117
  }
@@ -2091,6 +2119,50 @@ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
2091
2119
  return null;
2092
2120
  }
2093
2121
 
2122
+ // Resolve a public URL / grid+slug to a REAL entity_id (+ edition metadata) via
2123
+ // the deployed pickup contract (POST /api/v2/entities/:target/pickup — the same
2124
+ // endpoint runClaim uses). Used by runSource when a bare URL arrives with no
2125
+ // session entity_id (a fresh chat): the contract returns
2126
+ // { entity_id, slug, grid, kind, single_html, capabilities, replug_handle,
2127
+ // source_download_url, ... }
2128
+ // so the agent can re-plug the SAME entity in place. This is a metadata resolve
2129
+ // (no claim_token in the body → no ownership transfer). Best-effort: returns null
2130
+ // on any failure so the caller falls back to today's behavior — and NEVER fetches
2131
+ // the public *.cloudgrid.io URL (the hosted pod cannot egress to it).
2132
+ async function resolveEntityViaPickup(ctx, { target, url, grid }) {
2133
+ const pathSeg = target || url;
2134
+ if (!pathSeg) return null;
2135
+ let token = null;
2136
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine */ }
2137
+ const headers = { "Content-Type": "application/json" };
2138
+ if (token) headers["Authorization"] = `Bearer ${token}`;
2139
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
2140
+ if (grid) {
2141
+ headers["X-CloudGrid-Grid"] = grid;
2142
+ headers["X-CloudGrid-Org"] = grid;
2143
+ }
2144
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
2145
+ try {
2146
+ const res = await fetch(
2147
+ `${API_BASE}/api/v2/entities/${encodeURIComponent(pathSeg)}/pickup`,
2148
+ {
2149
+ method: "POST",
2150
+ headers,
2151
+ // Send the URL as a resolution key too (the contract accepts id, slug, or
2152
+ // a {url} body). NO claim_token → resolve only, never a claim.
2153
+ body: JSON.stringify(url ? { url } : {}),
2154
+ signal: AbortSignal.timeout(15_000),
2155
+ },
2156
+ );
2157
+ if (!res.ok) return null;
2158
+ const data = await res.json().catch(() => null);
2159
+ if (data && typeof data.entity_id === "string" && data.entity_id.length > 0) return data;
2160
+ return null;
2161
+ } catch {
2162
+ return null;
2163
+ }
2164
+ }
2165
+
2094
2166
  // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
2095
2167
  // that lost the content can edit it and re-plug in place. Resolves the fetch URL
2096
2168
  // (explicit url → session lastDrop → composed grid+slug), then reads the HTML
@@ -2129,19 +2201,40 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2129
2201
  }
2130
2202
 
2131
2203
  const resolvedUrl = parsed.toString();
2132
- const eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2204
+ let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2133
2205
 
2134
- // ── API-first (reachable) ────────────────────────────────────────────────
2135
- // Read the HTML from the API server-side rather than fetching the public URL.
2136
- // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
2137
- // ingress ("fetch failed") — this is the fix for hosted edit-in-place. Derive
2138
- // the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
2206
+ // Derive the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
2139
2207
  const host = parsed.hostname;
2140
2208
  const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
2141
2209
  ? host.split(".")[0] : null);
2142
2210
  const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
2211
+
2212
+ // ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
2213
+ // When a bare URL/slug arrived with no known entity_id, resolve a REAL
2214
+ // entity_id (+ edition metadata) so the agent can re-plug in place. Best-
2215
+ // effort: a failure falls back to today's behavior (never regress the read),
2216
+ // and we NEVER fetch the public URL for resolution.
2217
+ let extra = {};
2218
+ if (!eid) {
2219
+ const pickup = await resolveEntityViaPickup(ctx, { target: slugHint, url: resolvedUrl, grid: gridHint });
2220
+ if (pickup?.entity_id) {
2221
+ eid = pickup.entity_id;
2222
+ extra = {
2223
+ ...(pickup.kind ? { kind: pickup.kind } : {}),
2224
+ ...(typeof pickup.single_html === "boolean" ? { single_html: pickup.single_html } : {}),
2225
+ ...(pickup.capabilities ? { capabilities: pickup.capabilities } : {}),
2226
+ ...(pickup.replug_handle ? { replug_handle: pickup.replug_handle } : {}),
2227
+ ...(pickup.source_download_url ? { source_download_url: pickup.source_download_url } : {}),
2228
+ };
2229
+ }
2230
+ }
2231
+
2232
+ // ── API-first (reachable) ────────────────────────────────────────────────
2233
+ // Read the HTML from the API server-side rather than fetching the public URL.
2234
+ // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
2235
+ // ingress ("fetch failed") — this is the fix for hosted edit-in-place.
2143
2236
  const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
2144
- if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml);
2237
+ if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml, extra);
2145
2238
 
2146
2239
  // ── Fallback: direct public fetch ────────────────────────────────────────
2147
2240
  // Local edition has normal egress; last resort on hosted (e.g. a runtime app
@@ -2177,7 +2270,7 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2177
2270
  }
2178
2271
 
2179
2272
  const buf = Buffer.from(await res.arrayBuffer());
2180
- return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"));
2273
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"), extra);
2181
2274
  }
2182
2275
 
2183
2276
  // ── Registration ───────────────────────────────────────────────────────────────
@@ -2299,6 +2392,12 @@ export function registerTools(server, ctx) {
2299
2392
  "never moves grids, but pass its home grid here when it differs from your active grid (the API " +
2300
2393
  "checks your membership in the entity's grid). Anonymous → always the Guest grid.",
2301
2394
  ),
2395
+ slug: z.string().optional().describe(
2396
+ "Alternative RE-PLUG handle: paired with `grid`, resolves to an existing entity (the pickup " +
2397
+ "contract's replug_handle) and updates it in place — for a client that holds only grid+slug, not the " +
2398
+ "raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
2399
+ "entity is treated as a CREATE (never a false-positive re-plug).",
2400
+ ),
2302
2401
  hints: z.object({
2303
2402
  kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
2304
2403
  yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
@@ -2326,7 +2425,8 @@ export function registerTools(server, ctx) {
2326
2425
  ? "or `artifact_files` — a multi-file app inline. "
2327
2426
  : "`path` — a local folder/file (a multi-file app); or `artifact_files` — inline files. ") +
2328
2427
  "No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
2329
- "with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
2428
+ "with target_entity_id (or grid+slug — the replug_handle, when you hold only those) → RE-PLUG: " +
2429
+ "update the SAME entity in place — same entity_id, same URL, same " +
2330
2430
  "deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
2331
2431
  "them (plus owner_token for anonymous pages) to update the entity in later sessions. " +
2332
2432
  "Note: in-place re-plug currently supports inspirations (HTML/static pages); to rebuild a deployed " +
@@ -2476,10 +2576,14 @@ export function registerTools(server, ctx) {
2476
2576
  description:
2477
2577
  "Retrieve the CURRENT deployed HTML of an inspiration/drop inline as text, so you can edit it and " +
2478
2578
  "re-plug the SAME URL when you no longer have its source in context (e.g. the user asks to 'change the " +
2479
- "color' of a drop you made earlier). Defaults to this session's last drop; otherwise pass the public " +
2480
- "url (or grid+slug). Works for single-document HTML drops/inspirations (the common case). For " +
2481
- "multi-file or runtime (app/agent) deploys, use grid_download (signed tarball URLs) instead. " +
2482
- "Fetches the public *.cloudgrid.io URL server-side; read-only, creates nothing.",
2579
+ "color' of a page — even in a fresh chat with only its URL). Defaults to this session's last drop; " +
2580
+ "otherwise pass the public url (or grid+slug). Given just a URL with no session, it resolves the " +
2581
+ "entity_id via the pickup contract and also returns the entity's kind, single_html, capabilities " +
2582
+ "(replug/fork), and replug_handle — read those to decide whether to edit in place (single-HTML + " +
2583
+ "capabilities.replug), fall back for a multi-file app/agent (use source_download_url + the local " +
2584
+ "edition/CLI), or offer a fork when it isn't yours. For multi-file or runtime (app/agent) source " +
2585
+ "bundles, use grid_download (signed tarball URLs). Reads the HTML from the API server-side; " +
2586
+ "read-only, creates nothing.",
2483
2587
  inputSchema: {
2484
2588
  entity_id: z.string().optional().describe("The drop's durable id. Defaults to this session's last drop."),
2485
2589
  url: z.string().optional().describe("The public URL of the drop (e.g. https://<grid>.cloudgrid.io/<slug>). Defaults to this session's last drop URL."),
@@ -2492,6 +2596,19 @@ export function registerTools(server, ctx) {
2492
2596
  bytes: z.number().describe("Total size of the live document in bytes."),
2493
2597
  truncated: z.boolean().describe("True if the body exceeded 1.5MB and was cut — the drop may be multi-file."),
2494
2598
  html: z.string().describe("The current deployed HTML (truncated to 1.5MB when oversized)."),
2599
+ kind: z.string().optional().describe("Resolved via the pickup contract: inspiration | app | agent."),
2600
+ single_html: z.boolean().optional().describe("Resolved via the pickup contract: true when this is a single editable HTML document (edit-in-place); false → multi-file."),
2601
+ capabilities: z.object({
2602
+ replug: z.boolean().describe("Whether the caller may re-plug (write) this entity in place."),
2603
+ fork: z.boolean().optional().describe("Whether the caller may fork it."),
2604
+ reason: z.string().optional().describe("Why an action is unavailable, e.g. not_owner."),
2605
+ }).optional().describe("Resolved via the pickup contract: what the caller may do with this entity."),
2606
+ replug_handle: z.object({
2607
+ target_entity_id: z.string().optional().describe("Pass as grid_plug's target_entity_id to re-plug in place."),
2608
+ grid: z.string().optional().describe("The entity's home grid slug."),
2609
+ slug: z.string().optional().describe("The entity's grid-scoped slug."),
2610
+ }).optional().describe("Resolved via the pickup contract: the durable re-plug handle."),
2611
+ source_download_url: z.string().optional().describe("Resolved via the pickup contract: the source-download route (used for the multi-file fallback message)."),
2495
2612
  },
2496
2613
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2497
2614
  },