@cloudgrid-io/mcp 0.2.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "MCP server for CloudGrid. Two editions: a local stdio server (full toolset) and a hosted web server (light, CLI-free toolset) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  const ctx = {
19
19
  edition: "local",
20
- state: { pendingLoginCode: null, lastAnonClaim: null },
20
+ state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
21
21
  canOpenBrowser: true,
22
22
  getToken: async () => (await readCredentials())?.jwt ?? null,
23
23
  getActiveOrg: async () => await readActiveOrgSlug(),
package/src/tools.js CHANGED
@@ -80,7 +80,7 @@ function looksLikeFullHtml(s) {
80
80
  return head.startsWith("<!doctype html") || head.startsWith("<html");
81
81
  }
82
82
 
83
- async function runDrop(ctx, { html, path: filePath, filename, anonymous, org }) {
83
+ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
84
84
  let bytes;
85
85
  let name;
86
86
  let type;
@@ -129,7 +129,22 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
129
129
  headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
130
130
  }
131
131
 
132
+ const isAnonymousCall = !headers["Authorization"];
133
+
134
+ // Ownership continuity: replay the platform's anon-session cookie across drops in
135
+ // this session, so cookie-class callers can redrop (and claim) what they dropped.
136
+ if (isAnonymousCall && ctx.state.anonCookie) {
137
+ headers["Cookie"] = ctx.state.anonCookie;
138
+ }
139
+
132
140
  const form = new FormData();
141
+ // Redrop (anon-redrop spec §6): a re-drop in the same session updates the previous
142
+ // drop in place — same URL, new version. `fresh: true` forces a new drop. The
143
+ // platform validates ownership and silently falls back to create, so this never
144
+ // hard-fails. Field appended before the artifact so streaming parsers see it.
145
+ if (isAnonymousCall && fresh !== true && ctx.state.lastDrop?.entity_id) {
146
+ form.append("previous_id", ctx.state.lastDrop.entity_id);
147
+ }
133
148
  form.append("artifact", new Blob([bytes], { type }), name);
134
149
  if (orgSlug) form.append("org_slug", orgSlug);
135
150
 
@@ -153,6 +168,15 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
153
168
  throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
154
169
  }
155
170
 
171
+ // Persist the platform's anon-session cookie for ownership continuity.
172
+ const setCookies = res.headers.getSetCookie
173
+ ? res.headers.getSetCookie()
174
+ : [res.headers.get("set-cookie")].filter(Boolean);
175
+ const anonCookie = (setCookies || [])
176
+ .map((c) => (c || "").split(";")[0])
177
+ .find((c) => c.startsWith("cg_anon_session="));
178
+ if (anonCookie) ctx.state.anonCookie = anonCookie;
179
+
156
180
  if (data.owned_by === "authenticated") {
157
181
  ctx.state.lastAnonClaim = null;
158
182
  const lines = [`Published to your org: ${data.url}`, "Owned by you."];
@@ -160,6 +184,27 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
160
184
  return lines.join("\n");
161
185
  }
162
186
 
187
+ // Anonymous: remember the drop for redrop continuity (any 2xx outcome).
188
+ if (data.entity_id || data.url) {
189
+ ctx.state.lastDrop = {
190
+ entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
191
+ url: data.url ?? ctx.state.lastDrop?.url ?? null,
192
+ };
193
+ }
194
+
195
+ if (res.status === 202) {
196
+ // Idempotent no-op — the bytes matched the live version exactly.
197
+ return `No change — this exact content is already live: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim();
198
+ }
199
+
200
+ if (res.status === 200) {
201
+ // Updated in place: same URL, new version, views/reactions intact.
202
+ const lines = [`Updated in place — same link: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim()];
203
+ if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
204
+ return lines.join("\n");
205
+ }
206
+
207
+ // 201 — created new (first drop, fresh: true, or the server fell back to create).
163
208
  if (data.claim_url) {
164
209
  try {
165
210
  ctx.state.lastAnonClaim = {
@@ -174,6 +219,7 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
174
219
  const lines = [`Live: ${data.url}`];
175
220
  if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
176
221
  if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
222
+ lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
177
223
  return lines.join("\n");
178
224
  }
179
225
 
@@ -234,13 +280,17 @@ export function registerTools(server, ctx) {
234
280
  // Drop — both editions.
235
281
  server.tool(
236
282
  "cloudgrid_drop",
237
- "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. If signed in, it publishes into the user's org as an owned inspiration (30-day expiry); if not, it drops anonymously (7-day expiry, claimable later). Calls the API directly.",
283
+ "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session update the existing drop in place — same link, new version; pass fresh: true to force a new one. If signed in, it publishes into the user's org as an owned inspiration (30-day expiry); if not, it drops anonymously (7-day expiry, claimable later). Calls the API directly.",
238
284
  {
239
285
  html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
240
286
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
241
287
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
242
288
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
243
289
  org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
290
+ fresh: z
291
+ .boolean()
292
+ .optional()
293
+ .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
244
294
  },
245
295
  async (input) => {
246
296
  try {
package/src/web.js CHANGED
@@ -29,7 +29,7 @@ function makeWebContext(sessionId) {
29
29
  let sessionToken = null;
30
30
  return {
31
31
  edition: "web",
32
- state: { pendingLoginCode: null, lastAnonClaim: null },
32
+ state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
33
33
  canOpenBrowser: false,
34
34
  getToken: async () => sessionToken,
35
35
  // No local config on a shared host. The user passes `org`, or the API returns
@@ -80,7 +80,7 @@ app.post("/mcp", async (req, res) => {
80
80
  transport.onclose = () => {
81
81
  if (transport.sessionId) delete transports[transport.sessionId];
82
82
  };
83
- const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.2" });
83
+ const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.4" });
84
84
  registerTools(server, makeWebContext(newSessionId));
85
85
  await server.connect(transport);
86
86
  }