@cloudgrid-io/mcp 0.3.2 → 0.3.3

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 +143 -24
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.3",
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,28 @@ 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
+
80
110
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
81
111
  function looksLikeFullHtml(s) {
82
112
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -198,34 +228,51 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
198
228
  if (res.status === 200) {
199
229
  // Updated in place: same URL, new version, views/reactions intact.
200
230
  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.");
231
+ const lines = ctx.edition === "web"
232
+ ? [`Your app is live: ${url}`]
233
+ : [`Updated in place — same link: ${url}`];
234
+ if (ctx.edition !== "web" && data.owned_by === "authenticated") lines.push("Owned by you.");
203
235
  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
- },
236
+ const structured = {
237
+ url,
238
+ status: "updated",
239
+ ...(data.owned_by ? { owned_by: data.owned_by } : {}),
240
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
212
241
  };
242
+ if (ctx.edition === "web") {
243
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
244
+ const vis = data.visibility || "link";
245
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis] || vis}. Want to change who can see it? I can set it to only you, your org, or anyone with the link.`);
246
+ structured.console_url = CONSOLE_URL;
247
+ structured.current_visibility = vis;
248
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
249
+ }
250
+ return { text: lines.join("\n"), structured };
213
251
  }
214
252
 
215
253
  if (data.owned_by === "authenticated") {
216
254
  ctx.state.lastAnonClaim = null;
217
- const lines = [`Published to your org: ${data.url}`, "Owned by you."];
255
+ const lines = ctx.edition === "web"
256
+ ? [`Your app is live: ${data.url}`]
257
+ : [`Published to your org: ${data.url}`, "Owned by you."];
218
258
  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
- },
259
+ const structured = {
260
+ url: data.url,
261
+ status: "created",
262
+ owned_by: "authenticated",
263
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
228
264
  };
265
+ if (ctx.edition === "web") {
266
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
267
+ const vis = data.visibility || "link";
268
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis] || vis}. Want to change who can see it? I can set it to only you, your org, or anyone with the link.`);
269
+ structured.console_url = CONSOLE_URL;
270
+ structured.current_visibility = vis;
271
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
272
+ } else {
273
+ lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
274
+ }
275
+ return { text: lines.join("\n"), structured };
229
276
  }
230
277
 
231
278
  // 201 — created new (first drop, fresh: true, or the server fell back to create).
@@ -240,7 +287,7 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
240
287
  ctx.state.lastAnonClaim = null;
241
288
  }
242
289
  }
243
- const lines = [`Live: ${data.url}`];
290
+ const lines = [ctx.edition === "web" ? `Your app is live: ${data.url}` : `Live: ${data.url}`];
244
291
  if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
245
292
  if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
246
293
  lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
@@ -383,15 +430,56 @@ export function registerTools(server, ctx) {
383
430
  .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
384
431
  },
385
432
  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."),
433
+ url: z.string().optional().describe("The public URL of the drop."),
434
+ status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
388
435
  owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
389
436
  expires_at: z.string().optional().describe("Expiry timestamp, if any."),
437
+ console_url: z.string().optional().describe("URL to manage all apps in the grid."),
438
+ current_visibility: z.string().optional().describe("Current visibility of the drop."),
439
+ visibility_options: z.array(z.object({
440
+ value: z.string().describe("Visibility value to pass to cloudgrid_visibility."),
441
+ label: z.string().describe("Human-readable label."),
442
+ })).optional().describe("Available visibility levels."),
443
+ needs_org: z.boolean().optional().describe("True when the user must choose an org before dropping."),
444
+ orgs: z.array(z.object({
445
+ slug: z.string().describe("Org slug to pass as the org parameter."),
446
+ name: z.string().describe("Human-readable org name."),
447
+ role: z.string().describe("User's role in the org."),
448
+ })).optional().describe("The user's orgs, when org choice is needed."),
449
+ needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
450
+ login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
390
451
  },
391
452
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
392
453
  },
393
454
  async (input) => {
394
455
  try {
456
+ // Web edition: sign-in guidance when unauthenticated.
457
+ if (ctx.edition === "web" && input?.anonymous !== true) {
458
+ const token = await ctx.getToken();
459
+ if (!token) {
460
+ const url = buildLoginUrl(newLoginCode());
461
+ return okResult({
462
+ text: `Sign in to publish to your org.\n${url}`,
463
+ structured: { needs_sign_in: true, login_url: url },
464
+ });
465
+ }
466
+ // Org disambiguation: if signed in, no org arg, list the user's orgs.
467
+ if (!input?.org) {
468
+ const orgs = await fetchUserOrgs(token);
469
+ if (orgs.length > 1) {
470
+ const lines = ["Which org should this be published to?"];
471
+ for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
472
+ lines.push("Pass the org slug in the org parameter to publish.");
473
+ return okResult({
474
+ text: lines.join("\n"),
475
+ structured: { needs_org: true, orgs },
476
+ });
477
+ }
478
+ if (orgs.length === 1) {
479
+ input = { ...(input || {}), org: orgs[0].slug };
480
+ }
481
+ }
482
+ }
395
483
  return okResult(await runDrop(ctx, input || {}));
396
484
  } catch (err) {
397
485
  return fail(err.message);
@@ -502,7 +590,7 @@ export function registerTools(server, ctx) {
502
590
  server.registerTool(
503
591
  "cloudgrid_visibility",
504
592
  {
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.",
593
+ 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
594
  inputSchema: {
507
595
  visibility: z.enum(["private", "space", "authenticated", "org", "link"]).describe("The new scope."),
508
596
  target: z.string().optional().describe("Entity id. Defaults to this session's last drop."),
@@ -523,6 +611,37 @@ export function registerTools(server, ctx) {
523
611
  },
524
612
  );
525
613
 
614
+ // Org listing — web edition only (local edition uses cloudgrid_whoami).
615
+ if (ctx.edition === "web") {
616
+ server.registerTool(
617
+ "cloudgrid_orgs",
618
+ {
619
+ 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.",
620
+ inputSchema: {},
621
+ outputSchema: {
622
+ orgs: z.array(z.object({
623
+ slug: z.string().describe("Org slug."),
624
+ name: z.string().describe("Human-readable org name."),
625
+ role: z.string().describe("User's role in the org."),
626
+ })).describe("The user's org memberships."),
627
+ },
628
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
629
+ },
630
+ async () => {
631
+ const token = await ctx.getToken();
632
+ if (!token) {
633
+ return fail("You are not signed in. Run cloudgrid_login first.");
634
+ }
635
+ const orgs = await fetchUserOrgs(token);
636
+ if (orgs.length === 0) {
637
+ return okResult({ text: "No organizations found.", structured: { orgs: [] } });
638
+ }
639
+ const lines = orgs.map((o) => `${o.slug} — ${o.name} (${o.role})`);
640
+ return okResult({ text: lines.join("\n"), structured: { orgs } });
641
+ },
642
+ );
643
+ }
644
+
526
645
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
527
646
 
528
647
  // ── CLI-wrapping tools (local edition only) ───────────────────────────────