@cloudgrid-io/mcp 0.3.2 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +179 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.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/tools.js CHANGED
@@ -23,6 +23,14 @@ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid
23
23
  );
24
24
 
25
25
  const ANON_HTML_MAX_BYTES = 2_000_000;
26
+ const CONSOLE_URL = "https://console.cloudgrid.io/";
27
+ const VISIBILITY_LABELS = {
28
+ private: "Only you",
29
+ org: "Your org",
30
+ authenticated: "Anyone signed in",
31
+ space: "A space",
32
+ link: "Anyone with the link",
33
+ };
26
34
 
27
35
  function ok(text) {
28
36
  return { content: [{ type: "text", text }] };
@@ -77,6 +85,48 @@ function tryOpenBrowser(url) {
77
85
  }
78
86
  }
79
87
 
88
+ // ── Org listing (bearer-authed, web edition) ──────────────────────────────────
89
+ // Fetches the signed-in user's orgs via GET /api/v2/orgs. The JWT does not
90
+ // carry orgs (claims: sub, email, name, iat, exp), so the API is the canonical
91
+ // source. Returns [{slug, name, role}].
92
+ async function fetchUserOrgs(token) {
93
+ try {
94
+ const res = await fetch(`${API_BASE}/api/v2/orgs`, {
95
+ headers: { Authorization: `Bearer ${token}` },
96
+ });
97
+ if (!res.ok) return [];
98
+ const data = await res.json();
99
+ const orgs = Array.isArray(data?.orgs) ? data.orgs : Array.isArray(data) ? data : [];
100
+ return orgs.map((o) => ({
101
+ slug: o.slug ?? "",
102
+ name: o.name ?? o.slug ?? "",
103
+ role: o.role ?? "member",
104
+ }));
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ // After an authenticated web drop, upgrade visibility to "link" so the artifact
111
+ // is shareable and its preview renders without a sign-in wall. Best-effort — a
112
+ // failure here does not fail the drop; the user can always call cloudgrid_visibility.
113
+ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
114
+ const token = await ctx.getToken();
115
+ if (!token || !entityId) return false;
116
+ try {
117
+ const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
118
+ if (orgSlug) hdrs["X-CloudGrid-Org"] = orgSlug;
119
+ const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
120
+ method: "PATCH",
121
+ headers: hdrs,
122
+ body: JSON.stringify({ visibility: "link" }),
123
+ });
124
+ return res.ok;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
80
130
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
81
131
  function looksLikeFullHtml(s) {
82
132
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -198,34 +248,60 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
198
248
  if (res.status === 200) {
199
249
  // Updated in place: same URL, new version, views/reactions intact.
200
250
  const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
201
- const lines = [`Updated in place — same link: ${url}`];
202
- if (data.owned_by === "authenticated") lines.push("Owned by you.");
251
+ const lines = ctx.edition === "web"
252
+ ? [`Your app is live: ${url}`]
253
+ : [`Updated in place — same link: ${url}`];
254
+ if (ctx.edition !== "web" && data.owned_by === "authenticated") lines.push("Owned by you.");
203
255
  if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
204
- return {
205
- text: lines.join("\n"),
206
- structured: {
207
- url,
208
- status: "updated",
209
- ...(data.owned_by ? { owned_by: data.owned_by } : {}),
210
- ...(data.expires_at ? { expires_at: data.expires_at } : {}),
211
- },
256
+ const structured = {
257
+ url,
258
+ status: "updated",
259
+ ...(data.owned_by ? { owned_by: data.owned_by } : {}),
260
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
212
261
  };
262
+ if (ctx.edition === "web") {
263
+ // Default authed web drops to "link" visibility so the URL is shareable
264
+ // and the console thumbnail renders without a sign-in wall.
265
+ if (data.visibility !== "link" && data.entity_id) {
266
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
267
+ }
268
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
269
+ const vis = "link";
270
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
271
+ structured.console_url = CONSOLE_URL;
272
+ structured.current_visibility = vis;
273
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
274
+ }
275
+ return { text: lines.join("\n"), structured };
213
276
  }
214
277
 
215
278
  if (data.owned_by === "authenticated") {
216
279
  ctx.state.lastAnonClaim = null;
217
- const lines = [`Published to your org: ${data.url}`, "Owned by you."];
280
+ const lines = ctx.edition === "web"
281
+ ? [`Your app is live: ${data.url}`]
282
+ : [`Published to your org: ${data.url}`, "Owned by you."];
218
283
  if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
219
- lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
220
- return {
221
- text: lines.join("\n"),
222
- structured: {
223
- url: data.url,
224
- status: "created",
225
- owned_by: "authenticated",
226
- ...(data.expires_at ? { expires_at: data.expires_at } : {}),
227
- },
284
+ const structured = {
285
+ url: data.url,
286
+ status: "created",
287
+ owned_by: "authenticated",
288
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
228
289
  };
290
+ if (ctx.edition === "web") {
291
+ // Default authed web drops to "link" visibility (same as above).
292
+ if (data.visibility !== "link" && data.entity_id) {
293
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
294
+ }
295
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
296
+ const vis = "link";
297
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
298
+ structured.console_url = CONSOLE_URL;
299
+ structured.current_visibility = vis;
300
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
301
+ } else {
302
+ lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
303
+ }
304
+ return { text: lines.join("\n"), structured };
229
305
  }
230
306
 
231
307
  // 201 — created new (first drop, fresh: true, or the server fell back to create).
@@ -240,7 +316,7 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
240
316
  ctx.state.lastAnonClaim = null;
241
317
  }
242
318
  }
243
- const lines = [`Live: ${data.url}`];
319
+ const lines = [ctx.edition === "web" ? `Your app is live: ${data.url}` : `Live: ${data.url}`];
244
320
  if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
245
321
  if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
246
322
  lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
@@ -376,22 +452,69 @@ export function registerTools(server, ctx) {
376
452
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
377
453
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
378
454
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
379
- org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
455
+ org: z.string().optional().describe("Leave unset; the tool will ask the user which org to publish into. Only set this after the user picks from the list the tool returns."),
380
456
  fresh: z
381
457
  .boolean()
382
458
  .optional()
383
459
  .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
384
460
  },
385
461
  outputSchema: {
386
- url: z.string().describe("The public URL of the drop."),
387
- status: z.enum(["created", "updated", "unchanged"]).describe("What happened to the drop."),
462
+ url: z.string().optional().describe("The public URL of the drop."),
463
+ status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
388
464
  owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
389
465
  expires_at: z.string().optional().describe("Expiry timestamp, if any."),
466
+ console_url: z.string().optional().describe("URL to manage all apps in the grid."),
467
+ current_visibility: z.string().optional().describe("Current visibility of the drop."),
468
+ visibility_options: z.array(z.object({
469
+ value: z.string().describe("Visibility value to pass to cloudgrid_visibility."),
470
+ label: z.string().describe("Human-readable label."),
471
+ })).optional().describe("Available visibility levels."),
472
+ needs_org: z.boolean().optional().describe("True when the user must choose an org before dropping."),
473
+ orgs: z.array(z.object({
474
+ slug: z.string().describe("Org slug to pass as the org parameter."),
475
+ name: z.string().describe("Human-readable org name."),
476
+ role: z.string().describe("User's role in the org."),
477
+ })).optional().describe("The user's orgs, when org choice is needed."),
478
+ needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
479
+ login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
390
480
  },
391
481
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
392
482
  },
393
483
  async (input) => {
394
484
  try {
485
+ // Web edition: sign-in guidance when unauthenticated.
486
+ if (ctx.edition === "web" && input?.anonymous !== true) {
487
+ const token = await ctx.getToken();
488
+ if (!token) {
489
+ const url = buildLoginUrl(newLoginCode());
490
+ return okResult({
491
+ text: `Sign in to publish to your org.\n${url}`,
492
+ structured: { needs_sign_in: true, login_url: url },
493
+ });
494
+ }
495
+ // Org disambiguation: always validate the org against the user's real
496
+ // orgs. If the LLM guessed an org slug that doesn't match, ignore it
497
+ // and ask — this is why the >1-org ask didn't fire in the first test.
498
+ {
499
+ const orgs = await fetchUserOrgs(token);
500
+ const suppliedOrg = input?.org;
501
+ const validOrg = suppliedOrg && orgs.some((o) => o.slug === suppliedOrg);
502
+ if (!validOrg) {
503
+ if (orgs.length > 1) {
504
+ const lines = ["Which org should this be published to?"];
505
+ for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
506
+ lines.push("Pass the org slug in the org parameter to publish.");
507
+ return okResult({
508
+ text: lines.join("\n"),
509
+ structured: { needs_org: true, orgs },
510
+ });
511
+ }
512
+ if (orgs.length === 1) {
513
+ input = { ...(input || {}), org: orgs[0].slug };
514
+ }
515
+ }
516
+ }
517
+ }
395
518
  return okResult(await runDrop(ctx, input || {}));
396
519
  } catch (err) {
397
520
  return fail(err.message);
@@ -502,7 +625,7 @@ export function registerTools(server, ctx) {
502
625
  server.registerTool(
503
626
  "cloudgrid_visibility",
504
627
  {
505
- description: "Change who can see a CloudGrid inspiration: private, space, authenticated, org, or link (anyone with the URL). Use when the user wants to make a drop private, restrict who sees it, or open it up. Defaults to the drop made in this session. Requires sign-in. Calls the API directly.",
628
+ description: "Change who can see a CloudGrid inspiration: private, space, authenticated, org, or link (anyone with the URL). Use when the user wants to make a drop private, restrict who sees it, or open it up — including right after a drop, with no target id needed. Defaults to the drop made in this session. Requires sign-in. Calls the API directly.",
506
629
  inputSchema: {
507
630
  visibility: z.enum(["private", "space", "authenticated", "org", "link"]).describe("The new scope."),
508
631
  target: z.string().optional().describe("Entity id. Defaults to this session's last drop."),
@@ -523,6 +646,37 @@ export function registerTools(server, ctx) {
523
646
  },
524
647
  );
525
648
 
649
+ // Org listing — web edition only (local edition uses cloudgrid_whoami).
650
+ if (ctx.edition === "web") {
651
+ server.registerTool(
652
+ "cloudgrid_orgs",
653
+ {
654
+ description: "List the signed-in user's organizations. Returns each org's slug, name, and the user's role. Use to discover which org to publish to. Requires sign-in.",
655
+ inputSchema: {},
656
+ outputSchema: {
657
+ orgs: z.array(z.object({
658
+ slug: z.string().describe("Org slug."),
659
+ name: z.string().describe("Human-readable org name."),
660
+ role: z.string().describe("User's role in the org."),
661
+ })).describe("The user's org memberships."),
662
+ },
663
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
664
+ },
665
+ async () => {
666
+ const token = await ctx.getToken();
667
+ if (!token) {
668
+ return fail("You are not signed in. Run cloudgrid_login first.");
669
+ }
670
+ const orgs = await fetchUserOrgs(token);
671
+ if (orgs.length === 0) {
672
+ return okResult({ text: "No organizations found.", structured: { orgs: [] } });
673
+ }
674
+ const lines = orgs.map((o) => `${o.slug} — ${o.name} (${o.role})`);
675
+ return okResult({ text: lines.join("\n"), structured: { orgs } });
676
+ },
677
+ );
678
+ }
679
+
526
680
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
527
681
 
528
682
  // ── CLI-wrapping tools (local edition only) ───────────────────────────────