@cloudgrid-io/mcp 0.5.3 → 0.5.5
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 +2 -1
- package/src/corpus/cookbook.md +5 -5
- package/src/corpus/skill-claim.md +32 -18
- package/src/corpus/skill-drop.md +28 -34
- package/src/tools.js +185 -105
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"smoke:redrop": "node test/smoke-redrop.mjs",
|
|
25
25
|
"smoke:oauth": "node test/smoke-oauth.mjs",
|
|
26
26
|
"test:auth": "node test/auth.test.mjs",
|
|
27
|
+
"test:win-cli": "node test/windows-cli-resolution.test.mjs",
|
|
27
28
|
"smoke:docs": "node test/smoke-docs.mjs",
|
|
28
29
|
"start:docs": "node src/docs.js",
|
|
29
30
|
"snapshot:corpus": "node scripts/snapshot-corpus.mjs",
|
package/src/corpus/cookbook.md
CHANGED
|
@@ -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/
|
|
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 `
|
|
75
|
-
to keep it). This is what the `cloudgrid-drop`
|
|
76
|
-
tool do for you. It is anonymous and serves
|
|
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.
|
|
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)
|
|
16
|
-
extends the expiry from 7 days to 30.
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
the `
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
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
|
|
52
|
-
- The MCP `cloudgrid_claim` tool does this for you, and remembers 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.
|
package/src/corpus/skill-drop.md
CHANGED
|
@@ -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/
|
|
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
|
|
51
|
+
The response is JSON (HTTP 201 for an anonymous drop). Read these fields:
|
|
52
52
|
|
|
53
|
-
- `
|
|
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/
|
|
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"
|
|
71
|
+
-F "artifact=@/path/to/index.html;type=text/html"
|
|
68
72
|
```
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
+
## One drop = one entity (no in-place re-drop)
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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,
|
|
102
|
-
|
|
103
|
-
a signed-in drop is already owned.
|
|
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
|
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
|
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
14
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
15
15
|
import { basename, dirname, resolve, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
|
|
18
19
|
|
|
@@ -125,20 +126,67 @@ function meetsMinVersion(version) {
|
|
|
125
126
|
// Resolve the bundled CLI from this package's OWN node_modules only — never
|
|
126
127
|
// walk up to parent directories (which may contain stale CLI versions).
|
|
127
128
|
// Returns { entry, version } or null.
|
|
128
|
-
|
|
129
|
+
//
|
|
130
|
+
// `fileURLToPath` (not `new URL(url).pathname`) is required for Windows: on a
|
|
131
|
+
// Windows `file://` URL, `.pathname` is `/C:/Users/.../src` — a leading slash
|
|
132
|
+
// before the drive letter, which is NOT a valid fs path, so `existsSync` fails
|
|
133
|
+
// and the bundled CLI is never found even though the `.mcpb` bundles it.
|
|
134
|
+
// `fileURLToPath` yields a proper `C:\Users\...\src`. macOS/Linux are unaffected.
|
|
135
|
+
//
|
|
136
|
+
// The `deps` seam (moduleUrl + path/fs impls) exists ONLY so the Windows-URL
|
|
137
|
+
// regression test can drive the win32 code path deterministically on a POSIX CI
|
|
138
|
+
// box. Production always uses the real `import.meta.url` and host path/fs.
|
|
139
|
+
export function resolveBundledCli(deps = {}) {
|
|
140
|
+
const {
|
|
141
|
+
moduleUrl = import.meta.url,
|
|
142
|
+
urlToPath = fileURLToPath,
|
|
143
|
+
pathImpl = { dirname, join },
|
|
144
|
+
fsExists = existsSync,
|
|
145
|
+
fsRead = readFileSync,
|
|
146
|
+
} = deps;
|
|
129
147
|
try {
|
|
130
|
-
const srcDir = dirname(
|
|
131
|
-
const pkgPath = join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
|
|
132
|
-
if (!
|
|
133
|
-
const pkg = JSON.parse(
|
|
148
|
+
const srcDir = pathImpl.dirname(urlToPath(moduleUrl));
|
|
149
|
+
const pkgPath = pathImpl.join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
|
|
150
|
+
if (!fsExists(pkgPath)) return null;
|
|
151
|
+
const pkg = JSON.parse(fsRead(pkgPath, "utf-8"));
|
|
134
152
|
const bin = pkg.bin && pkg.bin.cloudgrid;
|
|
135
153
|
if (!bin) return null;
|
|
136
|
-
return { entry: join(dirname(pkgPath), bin), version: pkg.version || null };
|
|
154
|
+
return { entry: pathImpl.join(pathImpl.dirname(pkgPath), bin), version: pkg.version || null };
|
|
137
155
|
} catch {
|
|
138
156
|
return null;
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
159
|
|
|
160
|
+
// Windows `.cmd`/`.bat` shims (cloudgrid.cmd, npx.cmd) cannot be launched by
|
|
161
|
+
// `execFile` without a shell — modern patched Node (CVE-2024-27980) rejects it
|
|
162
|
+
// with EINVAL, and even where it doesn't, ENOENT results because the bare name
|
|
163
|
+
// resolves to a `.cmd`. On win32 we route those through `cmd.exe /d /s /c` with
|
|
164
|
+
// an explicitly quoted command line so args with spaces/quotes stay intact.
|
|
165
|
+
// macOS/Linux never take this branch — they keep the plain execFile path.
|
|
166
|
+
const IS_WIN = process.platform === "win32";
|
|
167
|
+
|
|
168
|
+
// Quote a single Windows command-line token for cmd.exe. Wrap in double quotes,
|
|
169
|
+
// escape embedded quotes/backslash-before-quote per Windows argv rules, and also
|
|
170
|
+
// caret-escape cmd.exe metacharacters so nothing is interpreted by the shell.
|
|
171
|
+
function winQuoteArg(arg) {
|
|
172
|
+
const s = String(arg);
|
|
173
|
+
// Escape backslashes that precede a quote, then the quotes themselves.
|
|
174
|
+
let inner = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
175
|
+
inner = `"${inner}"`;
|
|
176
|
+
// Caret-escape cmd.exe special chars (they live outside the quoted-arg parsing).
|
|
177
|
+
return inner.replace(/[()%!^"<>&|]/g, "^$&");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// execFile a command that may be a Windows `.cmd` shim, safely on every OS.
|
|
181
|
+
function execMaybeCmd(command, args, options) {
|
|
182
|
+
if (!IS_WIN) return execFileAsync(command, args, options);
|
|
183
|
+
const line = [command, ...args].map(winQuoteArg).join(" ");
|
|
184
|
+
return execFileAsync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
|
|
185
|
+
...options,
|
|
186
|
+
windowsVerbatimArguments: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
142
190
|
async function runCloudgrid(args, opts = {}) {
|
|
143
191
|
const cwd = resolveCwd(opts.cwd);
|
|
144
192
|
const execOpts = {
|
|
@@ -175,11 +223,13 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
175
223
|
);
|
|
176
224
|
}
|
|
177
225
|
|
|
178
|
-
// 2. Global `cloudgrid` on PATH — version-gated
|
|
226
|
+
// 2. Global `cloudgrid` on PATH — version-gated.
|
|
227
|
+
// On Windows the bin is `cloudgrid.cmd`; run it via cmd.exe (execMaybeCmd).
|
|
179
228
|
{
|
|
229
|
+
const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
|
|
180
230
|
let useGlobal = false;
|
|
181
231
|
try {
|
|
182
|
-
const { stdout: vOut } = await
|
|
232
|
+
const { stdout: vOut } = await execMaybeCmd(globalBin, ["--version"], {
|
|
183
233
|
timeout: 5_000,
|
|
184
234
|
stdio: ["ignore", "pipe", "pipe"],
|
|
185
235
|
});
|
|
@@ -196,7 +246,7 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
196
246
|
}
|
|
197
247
|
if (useGlobal) {
|
|
198
248
|
try {
|
|
199
|
-
const { stdout, stderr } = await
|
|
249
|
+
const { stdout, stderr } = await execMaybeCmd(globalBin, args, execOpts);
|
|
200
250
|
return (stdout || stderr || "").trim() || "Done.";
|
|
201
251
|
} catch (err) {
|
|
202
252
|
throw extract(err);
|
|
@@ -206,10 +256,12 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
206
256
|
|
|
207
257
|
// 3. Lazy fetch: npx -y @cloudgrid-io/cli@<pinned> <args>
|
|
208
258
|
// One-time download; npx caches it for subsequent invocations.
|
|
259
|
+
// On Windows this is `npx.cmd`, run via cmd.exe (execMaybeCmd) so patched
|
|
260
|
+
// Node doesn't EINVAL on the `.cmd` shim.
|
|
209
261
|
console.error("cloudgrid-mcp: fetching the CloudGrid CLI (first use)…");
|
|
210
|
-
const npx =
|
|
262
|
+
const npx = IS_WIN ? "npx.cmd" : "npx";
|
|
211
263
|
try {
|
|
212
|
-
const { stdout, stderr } = await
|
|
264
|
+
const { stdout, stderr } = await execMaybeCmd(
|
|
213
265
|
npx,
|
|
214
266
|
["-y", CLI_NPX_PKG, ...args],
|
|
215
267
|
execOpts,
|
|
@@ -296,6 +348,32 @@ function looksLikeFullHtml(s) {
|
|
|
296
348
|
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
|
297
349
|
}
|
|
298
350
|
|
|
351
|
+
// Anonymous drops are owned by the platform Guest Org, whose slug is the apex
|
|
352
|
+
// the public URL hangs off (https://guest.cloudgrid.io/<slug>).
|
|
353
|
+
const GUEST_ORG_SLUG = "guest";
|
|
354
|
+
|
|
355
|
+
// Compose an entity's public URL from the `/api/v2/plug` response. Unlike the
|
|
356
|
+
// retired `/drop/auto`, `/plug` does NOT return a `url` field — it returns
|
|
357
|
+
// `slug` + `grid` (+ a `detection.kind`) and expects the client to derive the
|
|
358
|
+
// canonical URL, mirroring the platform's `entityUrl()` URL derivation:
|
|
359
|
+
// - inspiration (HTML drops): path-based at the org apex
|
|
360
|
+
// https://<grid>.cloudgrid.io/<slug>
|
|
361
|
+
// - runtime (app/agent): subdomain
|
|
362
|
+
// https://<slug>.<grid>.cloudgrid.io
|
|
363
|
+
// Anonymous drops are grid-less in the response (`grid: null`); they live under
|
|
364
|
+
// the Guest Org, so the apex slug is the constant `guest`.
|
|
365
|
+
function composePlugUrl(data) {
|
|
366
|
+
const slug = data?.slug;
|
|
367
|
+
if (!slug) return null;
|
|
368
|
+
const grid = data?.grid || GUEST_ORG_SLUG;
|
|
369
|
+
const kind = data?.detection?.kind;
|
|
370
|
+
if (kind === "app" || kind === "agent") {
|
|
371
|
+
return `https://${slug}.${grid}.cloudgrid.io`;
|
|
372
|
+
}
|
|
373
|
+
// inspiration (and any unknown/static kind) — path-based at the org apex.
|
|
374
|
+
return `https://${grid}.cloudgrid.io/${slug}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
299
377
|
async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
|
|
300
378
|
// Defensive: the web edition schema excludes `path`, but if a model still
|
|
301
379
|
// passes one (e.g. from a cached tool description), reject early with a
|
|
@@ -361,19 +439,23 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
361
439
|
}
|
|
362
440
|
|
|
363
441
|
const form = new FormData();
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
442
|
+
// The unified `/plug` create path is the same orchestrator `/drop/auto` used,
|
|
443
|
+
// but it is CREATE-ONLY for the drop flow: an anonymous caller naming a
|
|
444
|
+
// `target_entity_id` is rejected 401, and the authed create path mints a NEW
|
|
445
|
+
// entity. So `/plug` has NO in-place redrop / `previous_id` concept — every
|
|
446
|
+
// drop creates a fresh entity (new URL). `fresh` is accepted for backward
|
|
447
|
+
// compatibility but is now a no-op. See PR notes: the in-place-update +
|
|
448
|
+
// `202 unchanged` no-op semantics from `/drop/auto` are gone with M7.
|
|
449
|
+
void fresh;
|
|
450
|
+
// The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
|
|
451
|
+
// create path treats every non-`cloudgrid.yaml` part as raw artifact bytes.
|
|
371
452
|
form.append("artifact", new Blob([bytes], { type }), name);
|
|
372
|
-
|
|
453
|
+
// `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
|
|
454
|
+
// above), not a form field — so `org_slug` is no longer sent.
|
|
373
455
|
|
|
374
456
|
let res;
|
|
375
457
|
try {
|
|
376
|
-
res = await fetch(`${API_BASE}/api/v2/
|
|
458
|
+
res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
|
|
377
459
|
} catch (err) {
|
|
378
460
|
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
379
461
|
}
|
|
@@ -391,7 +473,8 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
391
473
|
throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
|
|
392
474
|
}
|
|
393
475
|
|
|
394
|
-
// Persist the platform's anon-session cookie for ownership continuity
|
|
476
|
+
// Persist the platform's anon-session cookie for ownership continuity (so a
|
|
477
|
+
// cookie-class caller can claim — via pickup — what it dropped).
|
|
395
478
|
const setCookies = res.headers.getSetCookie
|
|
396
479
|
? res.headers.getSetCookie()
|
|
397
480
|
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
@@ -400,68 +483,34 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
400
483
|
.find((c) => c.startsWith("cg_anon_session="));
|
|
401
484
|
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
402
485
|
|
|
403
|
-
//
|
|
404
|
-
|
|
486
|
+
// `/plug` returns no `url`; derive the canonical public URL from slug + grid.
|
|
487
|
+
const url = composePlugUrl(data);
|
|
488
|
+
|
|
489
|
+
// Remember the drop for session continuity — any caller class.
|
|
490
|
+
if (data.entity_id || url) {
|
|
405
491
|
ctx.state.lastDrop = {
|
|
406
492
|
entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
|
|
407
|
-
url:
|
|
493
|
+
url: url ?? ctx.state.lastDrop?.url ?? null,
|
|
408
494
|
};
|
|
409
495
|
}
|
|
410
496
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
structured: { url, status: "unchanged" },
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (res.status === 200) {
|
|
421
|
-
// Updated in place: same URL, new version, views/reactions intact.
|
|
422
|
-
const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
|
|
497
|
+
// Authenticated create (202): the drop minted an entity owned by the caller.
|
|
498
|
+
// `/plug`'s authed branch is reached only when an Authorization header was
|
|
499
|
+
// sent, so distinguish on the call class rather than a (now-absent) `owned_by`.
|
|
500
|
+
if (!isAnonymousCall) {
|
|
501
|
+
ctx.state.lastAnonClaim = null;
|
|
423
502
|
const lines = ctx.edition === "web"
|
|
424
503
|
? [`Your app is live: ${url}`]
|
|
425
|
-
: [`
|
|
426
|
-
if (ctx.edition !== "web" && data.owned_by === "authenticated") lines.push("Owned by you.");
|
|
427
|
-
if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
|
|
504
|
+
: [`Published to your org: ${url}`, "Owned by you."];
|
|
428
505
|
const structured = {
|
|
429
506
|
url,
|
|
430
|
-
status: "updated",
|
|
431
|
-
...(data.owned_by ? { owned_by: data.owned_by } : {}),
|
|
432
|
-
...(data.expires_at ? { expires_at: data.expires_at } : {}),
|
|
433
|
-
};
|
|
434
|
-
if (ctx.edition === "web") {
|
|
435
|
-
// Default authed web drops to "link" visibility so the URL is shareable
|
|
436
|
-
// and the console thumbnail renders without a sign-in wall.
|
|
437
|
-
if (data.visibility !== "link" && data.entity_id) {
|
|
438
|
-
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
439
|
-
}
|
|
440
|
-
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
441
|
-
const vis = "link";
|
|
442
|
-
lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
|
|
443
|
-
structured.console_url = CONSOLE_URL;
|
|
444
|
-
structured.current_visibility = vis;
|
|
445
|
-
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
446
|
-
}
|
|
447
|
-
return { text: lines.join("\n"), structured };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (data.owned_by === "authenticated") {
|
|
451
|
-
ctx.state.lastAnonClaim = null;
|
|
452
|
-
const lines = ctx.edition === "web"
|
|
453
|
-
? [`Your app is live: ${data.url}`]
|
|
454
|
-
: [`Published to your org: ${data.url}`, "Owned by you."];
|
|
455
|
-
if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
|
|
456
|
-
const structured = {
|
|
457
|
-
url: data.url,
|
|
458
507
|
status: "created",
|
|
459
508
|
owned_by: "authenticated",
|
|
460
|
-
...(data.expires_at ? { expires_at: data.expires_at } : {}),
|
|
461
509
|
};
|
|
462
510
|
if (ctx.edition === "web") {
|
|
463
|
-
// Default authed web drops to "link" visibility
|
|
464
|
-
|
|
511
|
+
// Default authed web drops to "link" visibility so the URL is shareable
|
|
512
|
+
// and the console thumbnail renders without a sign-in wall.
|
|
513
|
+
if (data.entity_id) {
|
|
465
514
|
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
466
515
|
}
|
|
467
516
|
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
@@ -470,43 +519,52 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
470
519
|
structured.console_url = CONSOLE_URL;
|
|
471
520
|
structured.current_visibility = vis;
|
|
472
521
|
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
473
|
-
} else {
|
|
474
|
-
lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
|
|
475
522
|
}
|
|
476
523
|
return { text: lines.join("\n"), structured };
|
|
477
524
|
}
|
|
478
525
|
|
|
479
|
-
// 201 —
|
|
480
|
-
|
|
526
|
+
// Anonymous create (201) — Guest-Org inspiration, 7-day expiry, claimable.
|
|
527
|
+
// `/plug` carries the reward fields (`claim_url` + `claim_message`) and the
|
|
528
|
+
// real `entity_id`; pickup-by-id is how it is later claimed.
|
|
529
|
+
if (data.claim_url || data.entity_id) {
|
|
530
|
+
let token = null;
|
|
481
531
|
try {
|
|
482
|
-
|
|
483
|
-
token: new URL(data.claim_url).searchParams.get("token"),
|
|
484
|
-
entity_id: data.entity_id,
|
|
485
|
-
url: data.url,
|
|
486
|
-
};
|
|
532
|
+
token = data.claim_url ? new URL(data.claim_url).searchParams.get("token") : null;
|
|
487
533
|
} catch {
|
|
488
|
-
|
|
534
|
+
token = null;
|
|
489
535
|
}
|
|
536
|
+
ctx.state.lastAnonClaim = {
|
|
537
|
+
token,
|
|
538
|
+
entity_id: data.entity_id ?? null,
|
|
539
|
+
url,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const lines = [ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`];
|
|
543
|
+
if (data.claim_message) {
|
|
544
|
+
lines.push(data.claim_message);
|
|
545
|
+
} else if (data.claim_url) {
|
|
546
|
+
lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
|
|
490
547
|
}
|
|
491
|
-
const lines = [ctx.edition === "web" ? `Your app is live: ${data.url}` : `Live: ${data.url}`];
|
|
492
|
-
if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
|
|
493
|
-
if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
|
|
494
|
-
lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
|
|
495
548
|
return {
|
|
496
549
|
text: lines.join("\n"),
|
|
497
550
|
structured: {
|
|
498
|
-
url
|
|
551
|
+
url,
|
|
499
552
|
status: "created",
|
|
500
|
-
...(data.expires_at ? { expires_at: data.expires_at } : {}),
|
|
501
553
|
},
|
|
502
554
|
};
|
|
503
555
|
}
|
|
504
556
|
|
|
505
|
-
async function runClaim(ctx, { claim_token, claim_url }) {
|
|
557
|
+
async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
506
558
|
const token = await ctx.getToken();
|
|
507
559
|
if (!token) {
|
|
508
560
|
throw new Error("You are not signed in. Run cloudgrid_login first, then claim.");
|
|
509
561
|
}
|
|
562
|
+
|
|
563
|
+
// `/api/v2/anon-claim` (claim-token-in-body, returns a list) was retired; the
|
|
564
|
+
// claim now runs through `POST /api/v2/entities/:id/pickup`, which takes the
|
|
565
|
+
// entity id in the PATH and the `claim_token` in the body, and re-homes the
|
|
566
|
+
// Guest-Org inspiration into the caller's grid (ownership transfer). The
|
|
567
|
+
// anon-session cookie is the token-less alternative auth.
|
|
510
568
|
let claimToken = claim_token;
|
|
511
569
|
if (!claimToken && claim_url) {
|
|
512
570
|
try {
|
|
@@ -516,16 +574,36 @@ async function runClaim(ctx, { claim_token, claim_url }) {
|
|
|
516
574
|
}
|
|
517
575
|
}
|
|
518
576
|
if (!claimToken && ctx.state.lastAnonClaim) claimToken = ctx.state.lastAnonClaim.token;
|
|
519
|
-
|
|
520
|
-
|
|
577
|
+
|
|
578
|
+
// Pickup needs the target entity id in the URL path. Prefer an explicit one,
|
|
579
|
+
// else the entity remembered from this session's anonymous drop.
|
|
580
|
+
const targetId = entity_id || ctx.state.lastAnonClaim?.entity_id;
|
|
581
|
+
if (!targetId) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
"No drop to claim. Pass entity_id (or drop something anonymously first in this session), " +
|
|
584
|
+
"so the pickup knows which entity to claim.",
|
|
585
|
+
);
|
|
521
586
|
}
|
|
522
587
|
|
|
588
|
+
const headers = {
|
|
589
|
+
Authorization: `Bearer ${token}`,
|
|
590
|
+
"Content-Type": "application/json",
|
|
591
|
+
};
|
|
592
|
+
// The claim re-homes the inspiration into the caller's grid; the platform
|
|
593
|
+
// resolves the destination from the active-org context, so send the org
|
|
594
|
+
// header (the same header every other authed write uses).
|
|
595
|
+
const orgSlug = await ctx.getActiveOrg();
|
|
596
|
+
if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
|
|
597
|
+
// Replay the anon-session cookie so a cookie-class caller can claim what it
|
|
598
|
+
// dropped, even without a claim token.
|
|
599
|
+
if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
|
|
600
|
+
|
|
523
601
|
let res;
|
|
524
602
|
try {
|
|
525
|
-
res = await fetch(`${API_BASE}/api/v2/
|
|
603
|
+
res = await fetch(`${API_BASE}/api/v2/entities/${encodeURIComponent(targetId)}/pickup`, {
|
|
526
604
|
method: "POST",
|
|
527
|
-
headers
|
|
528
|
-
body: JSON.stringify({ claim_token: claimToken }),
|
|
605
|
+
headers,
|
|
606
|
+
body: JSON.stringify(claimToken ? { claim_token: claimToken } : {}),
|
|
529
607
|
});
|
|
530
608
|
} catch (err) {
|
|
531
609
|
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
@@ -539,26 +617,27 @@ async function runClaim(ctx, { claim_token, claim_url }) {
|
|
|
539
617
|
/* handled below */
|
|
540
618
|
}
|
|
541
619
|
if (!res.ok) {
|
|
620
|
+
// 409 ALREADY_CLAIMED is the idempotent "nothing left to do" outcome.
|
|
621
|
+
if (res.status === 409) {
|
|
622
|
+
ctx.state.lastAnonClaim = null;
|
|
623
|
+
return {
|
|
624
|
+
text: "Nothing to claim — it was already claimed.",
|
|
625
|
+
structured: { claimed: 0, urls: [] },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
542
628
|
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
543
629
|
throw new Error(`Claim failed (HTTP ${res.status}): ${msg}`);
|
|
544
630
|
}
|
|
545
|
-
|
|
546
|
-
if (claimed.length === 0) {
|
|
547
|
-
return {
|
|
548
|
-
text: "Nothing to claim — it may already be claimed or expired.",
|
|
549
|
-
structured: { claimed: 0, urls: [] },
|
|
550
|
-
};
|
|
551
|
-
}
|
|
631
|
+
|
|
552
632
|
ctx.state.lastAnonClaim = null;
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
633
|
+
const url = data?.url || data?.redirect_url || ctx.state.lastDrop?.url || null;
|
|
634
|
+
const lines = ["Claimed 1, now yours:"];
|
|
635
|
+
lines.push(`${url ?? ""}${data?.new_expires_at ? ` (expires ${data.new_expires_at})` : ""}`.trim());
|
|
557
636
|
return {
|
|
558
637
|
text: lines.join("\n"),
|
|
559
638
|
structured: {
|
|
560
|
-
claimed:
|
|
561
|
-
urls:
|
|
639
|
+
claimed: 1,
|
|
640
|
+
urls: url ? [url] : [],
|
|
562
641
|
},
|
|
563
642
|
};
|
|
564
643
|
}
|
|
@@ -797,6 +876,7 @@ export function registerTools(server, ctx) {
|
|
|
797
876
|
inputSchema: {
|
|
798
877
|
claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
|
|
799
878
|
claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
|
|
879
|
+
entity_id: z.string().optional().describe("The entity id of the anonymous drop to claim. Defaults to this session's last anonymous drop."),
|
|
800
880
|
},
|
|
801
881
|
outputSchema: {
|
|
802
882
|
claimed: z.number().describe("Number of drops claimed."),
|