@cloudgrid-io/mcp 0.5.2 → 0.5.4

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
@@ -79,7 +79,9 @@ also work in the web edition where no CLI exists. `cloudgrid_login` writes the s
79
79
  | `cloudgrid_logout` | `cloudgrid logout` | Sign out and clear local credentials. Destructive. |
80
80
  | `cloudgrid_status` | `cloudgrid status` | Org dashboard or entity detail. Read-only. |
81
81
  | `cloudgrid_info` | `cloudgrid info` | Entity metadata. Read-only. |
82
- | `cloudgrid_grid` | `cloudgrid get entities` | List entities on the grid. Read-only. |
82
+ | `cloudgrid_get` | `cloudgrid get grids\|entities\|spaces` | List grids, entities, or spaces. Read-only. |
83
+ | `cloudgrid_describe_grid` | `cloudgrid describe grid <slug>` | Grid detail. Read-only. |
84
+ | `cloudgrid_pickup` | `cloudgrid pickup <name>` | Download an entity's source and bind the folder. |
83
85
  | `cloudgrid_rename` | `cloudgrid rename` | Rename an entity's display name. |
84
86
  | `cloudgrid_unplug` | `cloudgrid unplug` | Take an entity off the grid. Destructive; requires confirm. |
85
87
  | `cloudgrid_delete` | `cloudgrid delete entity` | Archive an inspiration. Destructive; requires confirm. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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": {
@@ -67,14 +67,14 @@ cloudgrid feedback list
67
67
  The fastest chain has one step and no login. Drop an HTML file, get a public URL:
68
68
 
69
69
  ```
70
- curl -sS -X POST https://api.cloudgrid.io/api/v2/drop/auto \
70
+ curl -sS -X POST https://api.cloudgrid.io/api/v2/plug \
71
71
  -F "artifact=@./index.html;type=text/html"
72
72
  ```
73
73
 
74
- The response includes `url` (the live link) and `claim_url` (sign in within 7 days
75
- to keep it). This is what the `cloudgrid-drop` skill and the `cloudgrid_drop` MCP
76
- tool do for you. It is anonymous and serves inspirations only; a full app needs the
77
- signed-in `plug` flow above.
74
+ The response includes `slug` (the live link is `https://guest.cloudgrid.io/<slug>`)
75
+ and `claim_url` (sign in within 7 days to keep it). This is what the `cloudgrid-drop`
76
+ skill and the `cloudgrid_drop` MCP tool do for you. It is anonymous and serves
77
+ inspirations only; a full app needs the signed-in `plug` flow above.
78
78
 
79
79
  ## Shorter chains
80
80
 
@@ -4,7 +4,8 @@ name: claim
4
4
  description: |
5
5
  Claim an anonymous CloudGrid drop into the user's account. Use when the user
6
6
  dropped something anonymously and now wants to keep it, own it, or stop it from
7
- expiring after signing in. The public URL does not change.
7
+ expiring after signing in. Ownership transfers to the user and the drop re-homes
8
+ into their grid, so its public URL changes.
8
9
  argument-hint: "[claim-url-or-token]"
9
10
  allowed-tools: Bash
10
11
  ---
@@ -12,9 +13,10 @@ allowed-tools: Bash
12
13
  # CloudGrid Claim
13
14
 
14
15
  Turn an anonymous drop into an owned one. After an anonymous drop, the user has a
15
- `claim_url` (or token). Once they sign in, claiming transfers ownership to them and
16
- extends the expiry from 7 days to 30. The shareable URL stays the same, so any link
17
- already shared keeps working.
16
+ `claim_url` (or token) plus the drop's `entity_id`. Once they sign in, claiming
17
+ transfers ownership to them and extends the expiry from 7 days to 30. Claiming
18
+ re-homes the drop into the user's grid, so its public URL changes from
19
+ `guest.cloudgrid.io/<slug>` to `<grid>.cloudgrid.io/<slug>` — share the new link.
18
20
 
19
21
  ## Step 0 — sign in first
20
22
 
@@ -23,31 +25,43 @@ skill (or `cloudgrid login` with the CLI) first.
23
25
 
24
26
  ## What you need
25
27
 
26
- The claim token from the anonymous drop. It is the `token` query parameter inside
27
- the `claim_url` the drop returned, for example
28
- `https://console.cloudgrid.io/claim?token=<TOKEN>`.
28
+ Two things: the **entity id** of the anonymous drop (the `entity_id` field the drop
29
+ returned), and the **claim token** — the `token` query parameter inside the
30
+ `claim_url` the drop returned, e.g. `https://console.cloudgrid.io/claim?token=<TOKEN>`.
31
+ A browser that still holds the `cg_anon_session` cookie from the drop can claim
32
+ without a token instead.
29
33
 
30
34
  ## How to run it
31
35
 
36
+ Claiming is now a "pickup" of the entity into your grid. The entity id goes in the
37
+ URL path; the claim token goes in the body:
38
+
32
39
  ```
33
40
  JWT=$(jq -r .jwt ~/.cloudgrid/credentials 2>/dev/null)
34
- # TOKEN is the claim token (from claim_url's ?token=...)
35
- curl -sS -X POST https://api.cloudgrid.io/api/v2/anon-claim \
36
- -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
41
+ ORG=$(grep -E '^\s*active_org_slug:' ~/.cloudgrid/config.yaml | awk '{print $2}')
42
+ # ENTITY_ID from the drop response; TOKEN from claim_url's ?token=...
43
+ curl -sS -X POST "https://api.cloudgrid.io/api/v2/entities/$ENTITY_ID/pickup" \
44
+ -H "Authorization: Bearer $JWT" -H "X-CloudGrid-Org: $ORG" \
45
+ -H "Content-Type: application/json" \
37
46
  -d "{\"claim_token\":\"$TOKEN\"}"
38
47
  ```
39
48
 
40
- The response is `{ "claimed": [ { "url", "new_expires_at" }, ... ] }`.
49
+ A successful claim returns HTTP 200 with `{ entity_id, slug, grid, url,
50
+ new_expires_at, role_granted: "owner", ... }`.
41
51
 
42
52
  ## Reading the result
43
53
 
44
- - A non-empty `claimed` list means it worked. Tell the user the drop is now theirs,
45
- the URL is unchanged, and the new expiry.
46
- - An empty `claimed` list means there was nothing to claim — it may already be
47
- claimed or expired. Say so plainly.
54
+ - HTTP 200 means it worked. Ownership transferred to the user; tell them the drop is
55
+ now theirs and give them the new `url` and `new_expires_at`. NOTE: claiming
56
+ re-homes the inspiration into the user's grid, so the public URL CHANGES from
57
+ `guest.cloudgrid.io/<slug>` to `<grid>.cloudgrid.io/<slug>` — share the new one.
58
+ - HTTP 409 `ALREADY_CLAIMED` means there was nothing left to claim — it was already
59
+ picked up. Say so plainly.
60
+ - HTTP 403 `CLAIM_TOKEN_REQUIRED` / `INVALID_CLAIM_TOKEN` means the token is missing,
61
+ wrong, or expired (and no owning cookie was sent).
48
62
 
49
63
  ## Notes
50
64
 
51
- - One token claims one drop. To claim several, repeat with each token.
52
- - The MCP `cloudgrid_claim` tool does this for you, and remembers the token from the
53
- last anonymous drop so you can claim with no arguments.
65
+ - One pickup claims one entity. To claim several, repeat with each entity id + token.
66
+ - The MCP `cloudgrid_claim` tool does this for you, and remembers the entity id +
67
+ token from the last anonymous drop so you can claim with no arguments.
@@ -41,17 +41,21 @@ first. The content must be a full HTML document (start with `<!doctype html>` or
41
41
  `<html>`); if you only have a fragment, wrap it in a minimal document. HTML is
42
42
  capped at 2 MB for anonymous drops.
43
43
 
44
- Then POST it:
44
+ Then POST it to the unified plug endpoint:
45
45
 
46
46
  ```
47
- curl -sS -X POST https://api.cloudgrid.io/api/v2/drop/auto \
47
+ curl -sS -X POST https://api.cloudgrid.io/api/v2/plug \
48
48
  -F "artifact=@/path/to/index.html;type=text/html"
49
49
  ```
50
50
 
51
- The response is JSON. Read two fields:
51
+ The response is JSON (HTTP 201 for an anonymous drop). Read these fields:
52
52
 
53
- - `url` — the live, shareable link. Give this to the user.
53
+ - `slug` — the entity's public slug. An anonymous inspiration's live link is
54
+ `https://guest.cloudgrid.io/<slug>`. The response has no ready-made `url`;
55
+ compose it from the slug. (Apps/agents would be `https://<slug>.<grid>.cloudgrid.io`,
56
+ but anonymous drops are always inspirations.)
54
57
  - `claim_url` — the link to sign in and keep it past the 7-day expiry.
58
+ - `claim_message` — a ready-to-show nudge to sign in and claim.
55
59
 
56
60
  ## If the user is signed in
57
61
 
@@ -62,45 +66,35 @@ request:
62
66
  ```
63
67
  JWT=$(jq -r .jwt ~/.cloudgrid/credentials 2>/dev/null)
64
68
  ORG=$(grep -E '^\s*active_org_slug:' ~/.cloudgrid/config.yaml | awk '{print $2}')
65
- curl -sS -X POST https://api.cloudgrid.io/api/v2/drop/auto \
69
+ curl -sS -X POST https://api.cloudgrid.io/api/v2/plug \
66
70
  -H "Authorization: Bearer $JWT" -H "X-CloudGrid-Org: $ORG" \
67
- -F "artifact=@/path/to/index.html;type=text/html" -F "org_slug=$ORG"
71
+ -F "artifact=@/path/to/index.html;type=text/html"
68
72
  ```
69
73
 
70
- The response has `owned_by: "authenticated"` and no `claim_url`. If the user is a
71
- member of several orgs and `X-CloudGrid-Org` is missing, the API replies with the
72
- list of orgs to choose from. The MCP `cloudgrid_drop` tool does this automatically
73
- when credentials are present.
74
-
75
- ## Re-dropping (update in place)
76
-
77
- A re-drop of the same artifact updates it in place — same URL, new version, views
78
- and reactions intact. The platform matches ownership through the anon-session
79
- cookie, so keep a cookie jar across drops:
80
-
81
- ```
82
- curl -sS -c jar.txt -X POST https://api.cloudgrid.io/api/v2/drop/auto \
83
- -F "artifact=@index.html;type=text/html" # first drop
84
- curl -sS -b jar.txt -X POST https://api.cloudgrid.io/api/v2/drop/auto \
85
- -F "previous_id=<entity_id from the first response>" \
86
- -F "artifact=@index.html;type=text/html" # re-drop
87
- ```
74
+ A signed-in drop returns HTTP 202 with `slug` + `grid` and no `claim_url` (it is
75
+ already owned). Its live link is `https://<grid>.cloudgrid.io/<slug>` for an
76
+ inspiration. The org is taken from the `X-CloudGrid-Org` header — there is no
77
+ `org_slug` form field. If the user is a member of several orgs and the header is
78
+ missing, the API replies with the list of orgs to choose from. The MCP
79
+ `cloudgrid_drop` tool does this automatically when credentials are present.
88
80
 
89
- Send `previous_id` before the artifact part. Read the status:
81
+ ## One drop = one entity (no in-place re-drop)
90
82
 
91
- - `200` — updated in place; the same `url` now serves the new bytes.
92
- - `202` — no change; this exact content is already live.
93
- - `201` — created new: no `previous_id`, or the previous drop was claimed, expired,
94
- or not yours — the server falls back to create and never hard-fails.
83
+ The unified `/api/v2/plug` endpoint is create-only for this flow: every drop mints
84
+ a NEW entity with a NEW URL. There is no `previous_id` / in-place-update path and no
85
+ `202 unchanged` no-op — those belonged to the retired `/drop/auto` endpoint. To
86
+ publish an update, drop again; you get a fresh link. (To update an existing app in
87
+ place, sign in and use `cloudgrid:plug`, which targets the entity by name.)
95
88
 
96
- Omit `previous_id` to start a separate new drop. The MCP `cloudgrid_drop` tool does
97
- all of this for you (re-drops update in place; `fresh: true` forces a new one).
89
+ The MCP `cloudgrid_drop` tool accepts a `fresh` flag for backward compatibility,
90
+ but it is now a no-op — each drop is already a fresh create.
98
91
 
99
92
  ## After the drop
100
93
 
101
- Print the `url` on its own line, by itself, so it can be copied in one go. Then add
102
- one line of context: an anonymous drop lasts 7 days and can be claimed to keep it;
103
- a signed-in drop is already owned. Do not bury the URL in prose.
94
+ Print the composed `url` (e.g. `https://guest.cloudgrid.io/<slug>`) on its own line,
95
+ by itself, so it can be copied in one go. Then add one line of context: an anonymous
96
+ drop lasts 7 days and can be claimed to keep it; a signed-in drop is already owned.
97
+ Do not bury the URL in prose.
104
98
 
105
99
  ## Limits and errors
106
100
 
package/src/tools.js CHANGED
@@ -94,7 +94,9 @@ export const CLI_TOOL_VERBS = {
94
94
  cloudgrid_logout: ["logout"],
95
95
  cloudgrid_status: ["status"],
96
96
  cloudgrid_info: ["info"],
97
- cloudgrid_grid: ["get"],
97
+ cloudgrid_get: ["get"],
98
+ cloudgrid_describe_grid: ["describe"],
99
+ cloudgrid_pickup: ["pickup"],
98
100
  cloudgrid_rename: ["rename"],
99
101
  cloudgrid_unplug: ["unplug"],
100
102
  cloudgrid_delete: ["delete"],
@@ -294,6 +296,32 @@ function looksLikeFullHtml(s) {
294
296
  return head.startsWith("<!doctype html") || head.startsWith("<html");
295
297
  }
296
298
 
299
+ // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
300
+ // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
301
+ const GUEST_ORG_SLUG = "guest";
302
+
303
+ // Compose an entity's public URL from the `/api/v2/plug` response. Unlike the
304
+ // retired `/drop/auto`, `/plug` does NOT return a `url` field — it returns
305
+ // `slug` + `grid` (+ a `detection.kind`) and expects the client to derive the
306
+ // canonical URL, mirroring the platform's `entityUrl()` URL derivation:
307
+ // - inspiration (HTML drops): path-based at the org apex
308
+ // https://<grid>.cloudgrid.io/<slug>
309
+ // - runtime (app/agent): subdomain
310
+ // https://<slug>.<grid>.cloudgrid.io
311
+ // Anonymous drops are grid-less in the response (`grid: null`); they live under
312
+ // the Guest Org, so the apex slug is the constant `guest`.
313
+ function composePlugUrl(data) {
314
+ const slug = data?.slug;
315
+ if (!slug) return null;
316
+ const grid = data?.grid || GUEST_ORG_SLUG;
317
+ const kind = data?.detection?.kind;
318
+ if (kind === "app" || kind === "agent") {
319
+ return `https://${slug}.${grid}.cloudgrid.io`;
320
+ }
321
+ // inspiration (and any unknown/static kind) — path-based at the org apex.
322
+ return `https://${grid}.cloudgrid.io/${slug}`;
323
+ }
324
+
297
325
  async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
298
326
  // Defensive: the web edition schema excludes `path`, but if a model still
299
327
  // passes one (e.g. from a cached tool description), reject early with a
@@ -359,19 +387,23 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
359
387
  }
360
388
 
361
389
  const form = new FormData();
362
- // Redrop: a re-drop in the same session updates the previous drop in place (same URL,
363
- // new version). `fresh: true` forces a new drop. The platform validates ownership and
364
- // falls back to create if the caller does not own the previous drop.
365
- // Field appended before the artifact so streaming parsers see it.
366
- if (fresh !== true && ctx.state.lastDrop?.entity_id) {
367
- form.append("previous_id", ctx.state.lastDrop.entity_id);
368
- }
390
+ // The unified `/plug` create path is the same orchestrator `/drop/auto` used,
391
+ // but it is CREATE-ONLY for the drop flow: an anonymous caller naming a
392
+ // `target_entity_id` is rejected 401, and the authed create path mints a NEW
393
+ // entity. So `/plug` has NO in-place redrop / `previous_id` concept — every
394
+ // drop creates a fresh entity (new URL). `fresh` is accepted for backward
395
+ // compatibility but is now a no-op. See PR notes: the in-place-update +
396
+ // `202 unchanged` no-op semantics from `/drop/auto` are gone with M7.
397
+ void fresh;
398
+ // The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
399
+ // create path treats every non-`cloudgrid.yaml` part as raw artifact bytes.
369
400
  form.append("artifact", new Blob([bytes], { type }), name);
370
- if (orgSlug) form.append("org_slug", orgSlug);
401
+ // `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
402
+ // above), not a form field — so `org_slug` is no longer sent.
371
403
 
372
404
  let res;
373
405
  try {
374
- res = await fetch(`${API_BASE}/api/v2/drop/auto`, { method: "POST", headers, body: form });
406
+ res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
375
407
  } catch (err) {
376
408
  throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
377
409
  }
@@ -389,7 +421,8 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
389
421
  throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
390
422
  }
391
423
 
392
- // Persist the platform's anon-session cookie for ownership continuity.
424
+ // Persist the platform's anon-session cookie for ownership continuity (so a
425
+ // cookie-class caller can claim — via pickup — what it dropped).
393
426
  const setCookies = res.headers.getSetCookie
394
427
  ? res.headers.getSetCookie()
395
428
  : [res.headers.get("set-cookie")].filter(Boolean);
@@ -398,68 +431,34 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
398
431
  .find((c) => c.startsWith("cg_anon_session="));
399
432
  if (anonCookie) ctx.state.anonCookie = anonCookie;
400
433
 
401
- // Remember the drop for redrop continuity — any caller class, any 2xx outcome.
402
- if (data.entity_id || data.url) {
434
+ // `/plug` returns no `url`; derive the canonical public URL from slug + grid.
435
+ const url = composePlugUrl(data);
436
+
437
+ // Remember the drop for session continuity — any caller class.
438
+ if (data.entity_id || url) {
403
439
  ctx.state.lastDrop = {
404
440
  entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
405
- url: data.url ?? ctx.state.lastDrop?.url ?? null,
441
+ url: url ?? ctx.state.lastDrop?.url ?? null,
406
442
  };
407
443
  }
408
444
 
409
- if (res.status === 202) {
410
- // Idempotent no-op — the bytes matched the live version exactly.
411
- const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
412
- return {
413
- text: `No change — this exact content is already live: ${url}`,
414
- structured: { url, status: "unchanged" },
415
- };
416
- }
417
-
418
- if (res.status === 200) {
419
- // Updated in place: same URL, new version, views/reactions intact.
420
- const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
445
+ // Authenticated create (202): the drop minted an entity owned by the caller.
446
+ // `/plug`'s authed branch is reached only when an Authorization header was
447
+ // sent, so distinguish on the call class rather than a (now-absent) `owned_by`.
448
+ if (!isAnonymousCall) {
449
+ ctx.state.lastAnonClaim = null;
421
450
  const lines = ctx.edition === "web"
422
451
  ? [`Your app is live: ${url}`]
423
- : [`Updated in place — same link: ${url}`];
424
- if (ctx.edition !== "web" && data.owned_by === "authenticated") lines.push("Owned by you.");
425
- if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
452
+ : [`Published to your org: ${url}`, "Owned by you."];
426
453
  const structured = {
427
454
  url,
428
- status: "updated",
429
- ...(data.owned_by ? { owned_by: data.owned_by } : {}),
430
- ...(data.expires_at ? { expires_at: data.expires_at } : {}),
431
- };
432
- if (ctx.edition === "web") {
433
- // Default authed web drops to "link" visibility so the URL is shareable
434
- // and the console thumbnail renders without a sign-in wall.
435
- if (data.visibility !== "link" && data.entity_id) {
436
- await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
437
- }
438
- lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
439
- const vis = "link";
440
- lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
441
- structured.console_url = CONSOLE_URL;
442
- structured.current_visibility = vis;
443
- structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
444
- }
445
- return { text: lines.join("\n"), structured };
446
- }
447
-
448
- if (data.owned_by === "authenticated") {
449
- ctx.state.lastAnonClaim = null;
450
- const lines = ctx.edition === "web"
451
- ? [`Your app is live: ${data.url}`]
452
- : [`Published to your org: ${data.url}`, "Owned by you."];
453
- if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
454
- const structured = {
455
- url: data.url,
456
455
  status: "created",
457
456
  owned_by: "authenticated",
458
- ...(data.expires_at ? { expires_at: data.expires_at } : {}),
459
457
  };
460
458
  if (ctx.edition === "web") {
461
- // Default authed web drops to "link" visibility (same as above).
462
- if (data.visibility !== "link" && data.entity_id) {
459
+ // Default authed web drops to "link" visibility so the URL is shareable
460
+ // and the console thumbnail renders without a sign-in wall.
461
+ if (data.entity_id) {
463
462
  await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
464
463
  }
465
464
  lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
@@ -468,43 +467,52 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
468
467
  structured.console_url = CONSOLE_URL;
469
468
  structured.current_visibility = vis;
470
469
  structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
471
- } else {
472
- lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
473
470
  }
474
471
  return { text: lines.join("\n"), structured };
475
472
  }
476
473
 
477
- // 201 — created new (first drop, fresh: true, or the server fell back to create).
478
- if (data.claim_url) {
474
+ // Anonymous create (201) — Guest-Org inspiration, 7-day expiry, claimable.
475
+ // `/plug` carries the reward fields (`claim_url` + `claim_message`) and the
476
+ // real `entity_id`; pickup-by-id is how it is later claimed.
477
+ if (data.claim_url || data.entity_id) {
478
+ let token = null;
479
479
  try {
480
- ctx.state.lastAnonClaim = {
481
- token: new URL(data.claim_url).searchParams.get("token"),
482
- entity_id: data.entity_id,
483
- url: data.url,
484
- };
480
+ token = data.claim_url ? new URL(data.claim_url).searchParams.get("token") : null;
485
481
  } catch {
486
- ctx.state.lastAnonClaim = null;
482
+ token = null;
487
483
  }
484
+ ctx.state.lastAnonClaim = {
485
+ token,
486
+ entity_id: data.entity_id ?? null,
487
+ url,
488
+ };
489
+ }
490
+ const lines = [ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`];
491
+ if (data.claim_message) {
492
+ lines.push(data.claim_message);
493
+ } else if (data.claim_url) {
494
+ lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
488
495
  }
489
- const lines = [ctx.edition === "web" ? `Your app is live: ${data.url}` : `Live: ${data.url}`];
490
- if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
491
- if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
492
- lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
493
496
  return {
494
497
  text: lines.join("\n"),
495
498
  structured: {
496
- url: data.url,
499
+ url,
497
500
  status: "created",
498
- ...(data.expires_at ? { expires_at: data.expires_at } : {}),
499
501
  },
500
502
  };
501
503
  }
502
504
 
503
- async function runClaim(ctx, { claim_token, claim_url }) {
505
+ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
504
506
  const token = await ctx.getToken();
505
507
  if (!token) {
506
508
  throw new Error("You are not signed in. Run cloudgrid_login first, then claim.");
507
509
  }
510
+
511
+ // `/api/v2/anon-claim` (claim-token-in-body, returns a list) was retired; the
512
+ // claim now runs through `POST /api/v2/entities/:id/pickup`, which takes the
513
+ // entity id in the PATH and the `claim_token` in the body, and re-homes the
514
+ // Guest-Org inspiration into the caller's grid (ownership transfer). The
515
+ // anon-session cookie is the token-less alternative auth.
508
516
  let claimToken = claim_token;
509
517
  if (!claimToken && claim_url) {
510
518
  try {
@@ -514,16 +522,36 @@ async function runClaim(ctx, { claim_token, claim_url }) {
514
522
  }
515
523
  }
516
524
  if (!claimToken && ctx.state.lastAnonClaim) claimToken = ctx.state.lastAnonClaim.token;
517
- if (!claimToken) {
518
- throw new Error("No claim token. Pass claim_token or claim_url from an anonymous drop.");
525
+
526
+ // Pickup needs the target entity id in the URL path. Prefer an explicit one,
527
+ // else the entity remembered from this session's anonymous drop.
528
+ const targetId = entity_id || ctx.state.lastAnonClaim?.entity_id;
529
+ if (!targetId) {
530
+ throw new Error(
531
+ "No drop to claim. Pass entity_id (or drop something anonymously first in this session), " +
532
+ "so the pickup knows which entity to claim.",
533
+ );
519
534
  }
520
535
 
536
+ const headers = {
537
+ Authorization: `Bearer ${token}`,
538
+ "Content-Type": "application/json",
539
+ };
540
+ // The claim re-homes the inspiration into the caller's grid; the platform
541
+ // resolves the destination from the active-org context, so send the org
542
+ // header (the same header every other authed write uses).
543
+ const orgSlug = await ctx.getActiveOrg();
544
+ if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
545
+ // Replay the anon-session cookie so a cookie-class caller can claim what it
546
+ // dropped, even without a claim token.
547
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
548
+
521
549
  let res;
522
550
  try {
523
- res = await fetch(`${API_BASE}/api/v2/anon-claim`, {
551
+ res = await fetch(`${API_BASE}/api/v2/entities/${encodeURIComponent(targetId)}/pickup`, {
524
552
  method: "POST",
525
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
526
- body: JSON.stringify({ claim_token: claimToken }),
553
+ headers,
554
+ body: JSON.stringify(claimToken ? { claim_token: claimToken } : {}),
527
555
  });
528
556
  } catch (err) {
529
557
  throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
@@ -537,26 +565,27 @@ async function runClaim(ctx, { claim_token, claim_url }) {
537
565
  /* handled below */
538
566
  }
539
567
  if (!res.ok) {
568
+ // 409 ALREADY_CLAIMED is the idempotent "nothing left to do" outcome.
569
+ if (res.status === 409) {
570
+ ctx.state.lastAnonClaim = null;
571
+ return {
572
+ text: "Nothing to claim — it was already claimed.",
573
+ structured: { claimed: 0, urls: [] },
574
+ };
575
+ }
540
576
  const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
541
577
  throw new Error(`Claim failed (HTTP ${res.status}): ${msg}`);
542
578
  }
543
- const claimed = Array.isArray(data?.claimed) ? data.claimed : [];
544
- if (claimed.length === 0) {
545
- return {
546
- text: "Nothing to claim — it may already be claimed or expired.",
547
- structured: { claimed: 0, urls: [] },
548
- };
549
- }
579
+
550
580
  ctx.state.lastAnonClaim = null;
551
- const lines = [`Claimed ${claimed.length}, now yours:`];
552
- for (const c of claimed) {
553
- lines.push(`${c.url}${c.new_expires_at ? ` (expires ${c.new_expires_at})` : ""}`);
554
- }
581
+ const url = data?.url || data?.redirect_url || ctx.state.lastDrop?.url || null;
582
+ const lines = ["Claimed 1, now yours:"];
583
+ lines.push(`${url ?? ""}${data?.new_expires_at ? ` (expires ${data.new_expires_at})` : ""}`.trim());
555
584
  return {
556
585
  text: lines.join("\n"),
557
586
  structured: {
558
- claimed: claimed.length,
559
- urls: claimed.map((c) => c.url),
587
+ claimed: 1,
588
+ urls: url ? [url] : [],
560
589
  },
561
590
  };
562
591
  }
@@ -795,6 +824,7 @@ export function registerTools(server, ctx) {
795
824
  inputSchema: {
796
825
  claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
797
826
  claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
827
+ entity_id: z.string().optional().describe("The entity id of the anonymous drop to claim. Defaults to this session's last anonymous drop."),
798
828
  },
799
829
  outputSchema: {
800
830
  claimed: z.number().describe("Number of drops claimed."),
@@ -1103,12 +1133,69 @@ export function registerTools(server, ctx) {
1103
1133
  }),
1104
1134
  );
1105
1135
 
1136
+ // cloudgrid_get is the single canonical lister for grids, entities, and spaces
1137
+ // (wraps `cloudgrid get <resource> --json`). It replaces the former
1138
+ // cloudgrid_grid (which wrapped only `get entities`) — retired here so there is
1139
+ // exactly one way to list entities. resource="entities" reproduces the old
1140
+ // cloudgrid_grid behaviour with `grid` mapping to the CLI's `--grid` flag.
1106
1141
  server.tool(
1107
- "cloudgrid_grid",
1108
- "List entities on the grid. Wraps `cloudgrid get entities`.",
1109
- { grid: z.string().optional().describe("Grid slug. Omit for the active grid.") },
1142
+ "cloudgrid_get",
1143
+ "List CloudGrid grids, entities, or spaces. Wraps `cloudgrid get <grids|entities|spaces> --json`.",
1144
+ {
1145
+ resource: z.enum(["grids", "entities", "spaces"]).describe("What to list: grids, entities, or spaces."),
1146
+ grid: z.string().optional().describe("Grid slug (entities/spaces only). Omit for the active grid."),
1147
+ kind: z.enum(["app", "agent", "inspiration"]).optional().describe("Filter by kind (entities only)."),
1148
+ status: z.enum(["charged", "live", "dark", "archived"]).optional().describe("Filter by status (entities only)."),
1149
+ space: z.string().optional().describe("Only entities scoped to this space slug (entities only)."),
1150
+ archived: z.boolean().optional().describe("Include archived entities (entities only)."),
1151
+ },
1152
+ { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1153
+ cliTool(({ resource, grid, kind, status, space, archived }) => {
1154
+ const args = ["get", resource];
1155
+ // --grid applies to entities and spaces; grids has no such flag.
1156
+ if (grid && resource !== "grids") args.push("--grid", grid);
1157
+ if (resource === "entities") {
1158
+ if (kind) args.push("--kind", kind);
1159
+ if (status) args.push("--status", status);
1160
+ if (space) args.push("--space", space);
1161
+ if (archived) args.push("--archived");
1162
+ }
1163
+ args.push("--json");
1164
+ return args;
1165
+ }),
1166
+ );
1167
+
1168
+ server.tool(
1169
+ "cloudgrid_describe_grid",
1170
+ "Show a grid's detail: role, members, spaces, tier, wildcard-TLS state. Wraps `cloudgrid describe grid <slug> --json`.",
1171
+ { grid: z.string().describe("Grid slug to describe.") },
1110
1172
  { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1111
- cliTool(({ grid }) => (grid ? ["get", "entities", "--grid", grid] : ["get", "entities"])),
1173
+ cliTool(({ grid }) => ["describe", "grid", grid, "--json"]),
1174
+ );
1175
+
1176
+ server.tool(
1177
+ "cloudgrid_pickup",
1178
+ "Download an entity's source + cloudgrid.yaml and link the folder to it. Overwrites with --force. Wraps `cloudgrid pickup`.",
1179
+ {
1180
+ name: z.string().describe("Entity slug or id to pick up."),
1181
+ target_dir: z.string().optional().describe("Directory to pick up into (relative to cwd). Defaults to the entity name."),
1182
+ grid: z.string().optional().describe("Grid to resolve the entity in. Defaults to the active grid."),
1183
+ version: z.string().optional().describe("Pick up an older version's source instead of HEAD."),
1184
+ force: z.boolean().optional().describe("Pick up into a non-empty directory."),
1185
+ no_bind: z.boolean().optional().describe("Download source only — skip cloudgrid.yaml and the link."),
1186
+ cwd: z.string().optional().describe("Working directory the CLI runs in. The download lands here; pass an explicit, writable directory."),
1187
+ },
1188
+ { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1189
+ cliTool(({ name, target_dir, grid, version, force, no_bind }) => {
1190
+ const args = ["pickup", name];
1191
+ if (target_dir) args.push(target_dir);
1192
+ // The CLI's pickup --help exposes `--org <slug>` (legacy naming) for the grid.
1193
+ if (grid) args.push("--org", grid);
1194
+ if (version) args.push("--version", version);
1195
+ if (force) args.push("--force");
1196
+ if (no_bind) args.push("--no-bind");
1197
+ return args;
1198
+ }, { cwdParam: true }),
1112
1199
  );
1113
1200
 
1114
1201
  server.tool(