@cloudgrid-io/mcp 0.3.3 → 0.4.0

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.3.3",
3
+ "version": "0.4.0",
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
@@ -11,6 +11,7 @@
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { readFile } from "node:fs/promises";
14
+ import { readFileSync } from "node:fs";
14
15
  import { basename } from "node:path";
15
16
  import { z } from "zod";
16
17
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
@@ -24,6 +25,17 @@ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid
24
25
 
25
26
  const ANON_HTML_MAX_BYTES = 2_000_000;
26
27
  const CONSOLE_URL = "https://console.cloudgrid.io/";
28
+
29
+ // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
30
+ const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
31
+ const ORG_PICKER_URI = "ui://cloudgrid/org-picker.html";
32
+ const LIVE_RESULT_HTML = readFileSync(new URL("./widgets/live-result.html", import.meta.url), "utf-8");
33
+ const ORG_PICKER_HTML = readFileSync(new URL("./widgets/org-picker.html", import.meta.url), "utf-8");
34
+ const WIDGET_CSP = {
35
+ connectDomains: ["https://*.cloudgrid.io"],
36
+ resourceDomains: ["https://*.cloudgrid.io"],
37
+ };
38
+
27
39
  const VISIBILITY_LABELS = {
28
40
  private: "Only you",
29
41
  org: "Your org",
@@ -38,8 +50,12 @@ function ok(text) {
38
50
  function fail(text) {
39
51
  return { content: [{ type: "text", text }], isError: true };
40
52
  }
41
- function okResult({ text, structured }) {
42
- return { content: [{ type: "text", text }], structuredContent: structured };
53
+ function okResult({ text, structured, meta }) {
54
+ return {
55
+ content: [{ type: "text", text }],
56
+ structuredContent: structured,
57
+ ...(meta ? { _meta: meta } : {}),
58
+ };
43
59
  }
44
60
 
45
61
  // ── CLI wrapping (local edition only) ──────────────────────────────────────────
@@ -107,6 +123,26 @@ async function fetchUserOrgs(token) {
107
123
  }
108
124
  }
109
125
 
126
+ // After an authenticated web drop, upgrade visibility to "link" so the artifact
127
+ // is shareable and its preview renders without a sign-in wall. Best-effort — a
128
+ // failure here does not fail the drop; the user can always call cloudgrid_visibility.
129
+ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
130
+ const token = await ctx.getToken();
131
+ if (!token || !entityId) return false;
132
+ try {
133
+ const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
134
+ if (orgSlug) hdrs["X-CloudGrid-Org"] = orgSlug;
135
+ const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
136
+ method: "PATCH",
137
+ headers: hdrs,
138
+ body: JSON.stringify({ visibility: "link" }),
139
+ });
140
+ return res.ok;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
110
146
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
111
147
  function looksLikeFullHtml(s) {
112
148
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -240,9 +276,14 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
240
276
  ...(data.expires_at ? { expires_at: data.expires_at } : {}),
241
277
  };
242
278
  if (ctx.edition === "web") {
279
+ // Default authed web drops to "link" visibility so the URL is shareable
280
+ // and the console thumbnail renders without a sign-in wall.
281
+ if (data.visibility !== "link" && data.entity_id) {
282
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
283
+ }
243
284
  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.`);
285
+ const vis = "link";
286
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
246
287
  structured.console_url = CONSOLE_URL;
247
288
  structured.current_visibility = vis;
248
289
  structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
@@ -263,9 +304,13 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
263
304
  ...(data.expires_at ? { expires_at: data.expires_at } : {}),
264
305
  };
265
306
  if (ctx.edition === "web") {
307
+ // Default authed web drops to "link" visibility (same as above).
308
+ if (data.visibility !== "link" && data.entity_id) {
309
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
310
+ }
266
311
  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.`);
312
+ const vis = "link";
313
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
269
314
  structured.console_url = CONSOLE_URL;
270
315
  structured.current_visibility = vis;
271
316
  structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
@@ -411,6 +456,33 @@ async function runVisibility(ctx, { target, visibility, org }) {
411
456
  // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
412
457
  // tools are included (they need a local machine).
413
458
  export function registerTools(server, ctx) {
459
+ // ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
460
+ if (ctx.edition === "web") {
461
+ server.registerResource("cloudgrid-live-result", LIVE_RESULT_URI, {
462
+ description: "Live result card after a CloudGrid drop — shows URL, grid link, and visibility controls.",
463
+ mimeType: "text/html;profile=mcp-app",
464
+ }, async () => ({
465
+ contents: [{
466
+ uri: LIVE_RESULT_URI,
467
+ mimeType: "text/html;profile=mcp-app",
468
+ text: LIVE_RESULT_HTML,
469
+ _meta: { ui: { csp: WIDGET_CSP } },
470
+ }],
471
+ }));
472
+
473
+ server.registerResource("cloudgrid-org-picker", ORG_PICKER_URI, {
474
+ description: "Org picker card — lets the user choose which organization to publish into.",
475
+ mimeType: "text/html;profile=mcp-app",
476
+ }, async () => ({
477
+ contents: [{
478
+ uri: ORG_PICKER_URI,
479
+ mimeType: "text/html;profile=mcp-app",
480
+ text: ORG_PICKER_HTML,
481
+ _meta: { ui: { csp: WIDGET_CSP } },
482
+ }],
483
+ }));
484
+ }
485
+
414
486
  // ── Direct-API tools (both editions) ──────────────────────────────────────
415
487
 
416
488
  // Drop — both editions.
@@ -423,7 +495,7 @@ export function registerTools(server, ctx) {
423
495
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
424
496
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
425
497
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
426
- org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
498
+ 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."),
427
499
  fresh: z
428
500
  .boolean()
429
501
  .optional()
@@ -450,6 +522,12 @@ export function registerTools(server, ctx) {
450
522
  login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
451
523
  },
452
524
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
525
+ ...(ctx.edition === "web" ? {
526
+ _meta: {
527
+ ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
528
+ "openai/outputTemplate": LIVE_RESULT_URI,
529
+ },
530
+ } : {}),
453
531
  },
454
532
  async (input) => {
455
533
  try {
@@ -463,20 +541,27 @@ export function registerTools(server, ctx) {
463
541
  structured: { needs_sign_in: true, login_url: url },
464
542
  });
465
543
  }
466
- // Org disambiguation: if signed in, no org arg, list the user's orgs.
467
- if (!input?.org) {
544
+ // Org disambiguation: always validate the org against the user's real
545
+ // orgs. If the LLM guessed an org slug that doesn't match, ignore it
546
+ // and ask — this is why the >1-org ask didn't fire in the first test.
547
+ {
468
548
  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 };
549
+ const suppliedOrg = input?.org;
550
+ const validOrg = suppliedOrg && orgs.some((o) => o.slug === suppliedOrg);
551
+ if (!validOrg) {
552
+ if (orgs.length > 1) {
553
+ const lines = ["Which org should this be published to?"];
554
+ for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
555
+ lines.push("Pass the org slug in the org parameter to publish.");
556
+ return okResult({
557
+ text: lines.join("\n"),
558
+ structured: { needs_org: true, orgs },
559
+ meta: { "openai/outputTemplate": ORG_PICKER_URI },
560
+ });
561
+ }
562
+ if (orgs.length === 1) {
563
+ input = { ...(input || {}), org: orgs[0].slug };
564
+ }
480
565
  }
481
566
  }
482
567
  }
@@ -0,0 +1,157 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:16px}
9
+ .card{background:#16213e;border-radius:12px;padding:20px;border:1px solid rgba(138,107,255,.2)}
10
+ .title{font-size:14px;font-weight:600;color:#fff;margin-bottom:12px}
11
+ .url{font-size:13px;color:#4dd0ff;word-break:break-all;margin-bottom:16px}
12
+ .buttons{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
13
+ .btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;border:none;cursor:pointer;transition:opacity .15s}
14
+ .btn:hover{opacity:.85}
15
+ .btn-primary{background:linear-gradient(135deg,#8a6bff,#4dd0ff);color:#fff}
16
+ .btn-secondary{background:rgba(138,107,255,.15);color:#a78bfa;border:1px solid rgba(138,107,255,.3)}
17
+ .vis-section{margin-top:12px;border-top:1px solid rgba(255,255,255,.08);padding-top:12px}
18
+ .vis-label{font-size:12px;color:#9ca3af;margin-bottom:8px}
19
+ .vis-pills{display:flex;gap:6px;flex-wrap:wrap}
20
+ .vis-pill{padding:6px 12px;border-radius:6px;font-size:12px;border:1px solid rgba(138,107,255,.3);background:transparent;color:#d1d5db;cursor:pointer;transition:all .15s}
21
+ .vis-pill:hover{border-color:#8a6bff;color:#fff}
22
+ .vis-pill.active{background:rgba(138,107,255,.25);border-color:#8a6bff;color:#fff}
23
+ .vis-pill.updating{opacity:.6;pointer-events:none}
24
+ .status{font-size:12px;color:#9ca3af;margin-top:8px}
25
+ .sign-in{text-align:center;padding:20px 0}
26
+ .sign-in p{margin-bottom:12px;color:#9ca3af;font-size:13px}
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ <script>
32
+ (function () {
33
+ var root = document.getElementById("root");
34
+
35
+ function esc(s) {
36
+ var d = document.createElement("div");
37
+ d.textContent = s;
38
+ return d.innerHTML;
39
+ }
40
+
41
+ function render(data) {
42
+ if (!data) { root.innerHTML = ""; return; }
43
+
44
+ if (data.needs_sign_in) {
45
+ root.innerHTML =
46
+ '<div class="card sign-in">' +
47
+ '<p>Sign in to publish to your org</p>' +
48
+ '<button class="btn btn-primary" id="sign-in-btn">Sign in with Google</button>' +
49
+ '</div>';
50
+ var signInBtn = document.getElementById("sign-in-btn");
51
+ if (signInBtn) {
52
+ signInBtn.addEventListener("click", function () {
53
+ if (data.login_url && window.openai && window.openai.openExternal) {
54
+ window.openai.openExternal({ href: data.login_url, redirectUrl: false });
55
+ }
56
+ });
57
+ }
58
+ return;
59
+ }
60
+
61
+ if (!data.url) {
62
+ root.innerHTML = '<div class="card"><div class="title">Done</div></div>';
63
+ return;
64
+ }
65
+
66
+ var label =
67
+ data.status === "created" ? "Published" :
68
+ data.status === "updated" ? "Updated" :
69
+ data.status === "unchanged" ? "Already live" : "Live";
70
+
71
+ var html =
72
+ '<div class="card">' +
73
+ '<div class="title">' + esc(label) + '</div>' +
74
+ '<div class="url">' + esc(data.url) + '</div>' +
75
+ '<div class="buttons">' +
76
+ '<button class="btn btn-primary" id="open-btn">Open</button>';
77
+
78
+ if (data.console_url) {
79
+ html += '<button class="btn btn-secondary" id="grid-btn">Open in your grid</button>';
80
+ }
81
+ html += '</div>';
82
+
83
+ if (data.visibility_options && data.current_visibility) {
84
+ html +=
85
+ '<div class="vis-section">' +
86
+ '<div class="vis-label">Visible to</div>' +
87
+ '<div class="vis-pills">';
88
+ for (var i = 0; i < data.visibility_options.length; i++) {
89
+ var opt = data.visibility_options[i];
90
+ var cls = opt.value === data.current_visibility ? " active" : "";
91
+ html += '<button class="vis-pill' + cls + '" data-vis="' + esc(opt.value) + '">' + esc(opt.label) + '</button>';
92
+ }
93
+ html += '</div><div class="status" id="vis-status"></div></div>';
94
+ }
95
+
96
+ html += '</div>';
97
+ root.innerHTML = html;
98
+
99
+ var openBtn = document.getElementById("open-btn");
100
+ if (openBtn) {
101
+ openBtn.addEventListener("click", function () {
102
+ if (window.openai && window.openai.openExternal) {
103
+ window.openai.openExternal({ href: data.url, redirectUrl: false });
104
+ }
105
+ });
106
+ }
107
+ var gridBtn = document.getElementById("grid-btn");
108
+ if (gridBtn) {
109
+ gridBtn.addEventListener("click", function () {
110
+ if (window.openai && window.openai.openExternal) {
111
+ window.openai.openExternal({ href: data.console_url, redirectUrl: false });
112
+ }
113
+ });
114
+ }
115
+
116
+ var pills = document.querySelectorAll(".vis-pill");
117
+ for (var j = 0; j < pills.length; j++) {
118
+ (function (pill) {
119
+ pill.addEventListener("click", function () {
120
+ var vis = pill.getAttribute("data-vis");
121
+ if (vis === data.current_visibility) return;
122
+ var all = document.querySelectorAll(".vis-pill");
123
+ for (var k = 0; k < all.length; k++) all[k].classList.add("updating");
124
+ var statusEl = document.getElementById("vis-status");
125
+ if (statusEl) statusEl.textContent = "Updating\u2026";
126
+ if (!window.openai || !window.openai.callTool) return;
127
+ window.openai.callTool("cloudgrid_visibility", { visibility: vis }).then(function (res) {
128
+ var newVis = res && res.structuredContent && res.structuredContent.visibility;
129
+ if (newVis) {
130
+ data.current_visibility = newVis;
131
+ for (var m = 0; m < all.length; m++) {
132
+ all[m].classList.remove("updating", "active");
133
+ if (all[m].getAttribute("data-vis") === newVis) all[m].classList.add("active");
134
+ }
135
+ if (statusEl) statusEl.textContent = "";
136
+ }
137
+ }).catch(function () {
138
+ for (var m = 0; m < all.length; m++) all[m].classList.remove("updating");
139
+ if (statusEl) statusEl.textContent = "Failed to update visibility";
140
+ });
141
+ });
142
+ })(pills[j]);
143
+ }
144
+ }
145
+
146
+ var data = window.openai && window.openai.toolOutput;
147
+ if (data) render(data);
148
+ window.addEventListener("message", function (e) {
149
+ if (e.data && e.data.method === "ui/notifications/tool-result" &&
150
+ e.data.params && e.data.params.structuredContent) {
151
+ render(e.data.params.structuredContent);
152
+ }
153
+ });
154
+ })();
155
+ </script>
156
+ </body>
157
+ </html>
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box}
8
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:16px}
9
+ .card{background:#16213e;border-radius:12px;padding:20px;border:1px solid rgba(138,107,255,.2)}
10
+ .title{font-size:14px;font-weight:600;color:#fff;margin-bottom:4px}
11
+ .subtitle{font-size:12px;color:#9ca3af;margin-bottom:16px}
12
+ .orgs{display:flex;flex-direction:column;gap:8px}
13
+ .org-btn{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-radius:8px;border:1px solid rgba(138,107,255,.3);background:transparent;color:#e0e0e0;cursor:pointer;transition:all .15s;font-size:13px;text-align:left}
14
+ .org-btn:hover{border-color:#8a6bff;background:rgba(138,107,255,.1)}
15
+ .org-btn.loading{opacity:.6;pointer-events:none}
16
+ .org-name{font-weight:500;color:#fff}
17
+ .org-role{font-size:11px;color:#9ca3af}
18
+ .status{font-size:12px;color:#9ca3af;margin-top:12px}
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div id="root"></div>
23
+ <script>
24
+ (function () {
25
+ var root = document.getElementById("root");
26
+
27
+ function esc(s) {
28
+ var d = document.createElement("div");
29
+ d.textContent = s;
30
+ return d.innerHTML;
31
+ }
32
+
33
+ function render(data) {
34
+ if (!data || !data.orgs || data.orgs.length === 0) {
35
+ root.innerHTML = '<div class="card"><div class="title">No organizations found</div></div>';
36
+ return;
37
+ }
38
+
39
+ var html =
40
+ '<div class="card">' +
41
+ '<div class="title">Choose an organization</div>' +
42
+ '<div class="subtitle">Which org should this be published to?</div>' +
43
+ '<div class="orgs">';
44
+ for (var i = 0; i < data.orgs.length; i++) {
45
+ var org = data.orgs[i];
46
+ html +=
47
+ '<button class="org-btn" data-slug="' + esc(org.slug) + '">' +
48
+ '<span class="org-name">' + esc(org.name) + '</span>' +
49
+ '<span class="org-role">' + esc(org.role) + '</span>' +
50
+ '</button>';
51
+ }
52
+ html += '</div><div class="status" id="status"></div></div>';
53
+ root.innerHTML = html;
54
+
55
+ var buttons = document.querySelectorAll(".org-btn");
56
+ for (var j = 0; j < buttons.length; j++) {
57
+ (function (btn) {
58
+ btn.addEventListener("click", function () {
59
+ var slug = btn.getAttribute("data-slug");
60
+ var all = document.querySelectorAll(".org-btn");
61
+ for (var k = 0; k < all.length; k++) all[k].classList.add("loading");
62
+ var statusEl = document.getElementById("status");
63
+ if (statusEl) statusEl.textContent = "Publishing\u2026";
64
+ if (!window.openai || !window.openai.callTool) return;
65
+ var input = (window.openai && window.openai.toolInput) || {};
66
+ var args = {};
67
+ if (input.html) args.html = input.html;
68
+ if (input.path) args.path = input.path;
69
+ if (input.filename) args.filename = input.filename;
70
+ if (input.fresh !== undefined) args.fresh = input.fresh;
71
+ args.org = slug;
72
+ window.openai.callTool("cloudgrid_drop", args).then(function () {
73
+ // The host will render the new result with the live-result card.
74
+ }).catch(function () {
75
+ for (var m = 0; m < all.length; m++) all[m].classList.remove("loading");
76
+ if (statusEl) statusEl.textContent = "Failed to publish";
77
+ });
78
+ });
79
+ })(buttons[j]);
80
+ }
81
+ }
82
+
83
+ var data = window.openai && window.openai.toolOutput;
84
+ if (data) render(data);
85
+ window.addEventListener("message", function (e) {
86
+ if (e.data && e.data.method === "ui/notifications/tool-result" &&
87
+ e.data.params && e.data.params.structuredContent) {
88
+ render(e.data.params.structuredContent);
89
+ }
90
+ });
91
+ })();
92
+ </script>
93
+ </body>
94
+ </html>