@cloudgrid-io/mcp 0.3.4 → 0.4.1

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.4",
3
+ "version": "0.4.1",
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) ──────────────────────────────────────────
@@ -134,6 +150,15 @@ function looksLikeFullHtml(s) {
134
150
  }
135
151
 
136
152
  async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
153
+ // Defensive: the web edition schema excludes `path`, but if a model still
154
+ // passes one (e.g. from a cached tool description), reject early with a
155
+ // clear explanation.
156
+ if (ctx.edition === "web" && filePath) {
157
+ throw new Error(
158
+ "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
159
+ );
160
+ }
161
+
137
162
  let bytes;
138
163
  let name;
139
164
  let type;
@@ -440,24 +465,64 @@ async function runVisibility(ctx, { target, visibility, org }) {
440
465
  // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
441
466
  // tools are included (they need a local machine).
442
467
  export function registerTools(server, ctx) {
468
+ // ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
469
+ if (ctx.edition === "web") {
470
+ server.registerResource("cloudgrid-live-result", LIVE_RESULT_URI, {
471
+ description: "Live result card after a CloudGrid drop — shows URL, grid link, and visibility controls.",
472
+ mimeType: "text/html;profile=mcp-app",
473
+ }, async () => ({
474
+ contents: [{
475
+ uri: LIVE_RESULT_URI,
476
+ mimeType: "text/html;profile=mcp-app",
477
+ text: LIVE_RESULT_HTML,
478
+ _meta: { ui: { csp: WIDGET_CSP } },
479
+ }],
480
+ }));
481
+
482
+ server.registerResource("cloudgrid-org-picker", ORG_PICKER_URI, {
483
+ description: "Org picker card — lets the user choose which organization to publish into.",
484
+ mimeType: "text/html;profile=mcp-app",
485
+ }, async () => ({
486
+ contents: [{
487
+ uri: ORG_PICKER_URI,
488
+ mimeType: "text/html;profile=mcp-app",
489
+ text: ORG_PICKER_HTML,
490
+ _meta: { ui: { csp: WIDGET_CSP } },
491
+ }],
492
+ }));
493
+ }
494
+
443
495
  // ── Direct-API tools (both editions) ──────────────────────────────────────
444
496
 
445
- // Drop — both editions.
446
- server.registerTool(
447
- "cloudgrid_drop",
448
- {
449
- description: "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.",
450
- inputSchema: {
497
+ // Drop — both editions, but the input schema is edition-aware: the web
498
+ // edition removes `path` (the cloud server cannot read local files) and
499
+ // strengthens `html` so the model always pastes the full document inline.
500
+ const dropInputSchema = ctx.edition === "web"
501
+ ? {
502
+ html: z.string().optional().describe(
503
+ "The COMPLETE HTML document to publish — paste the full file contents here (not a path). " +
504
+ "For a game, include all HTML/CSS/JS inline so it runs standalone. " +
505
+ "A fragment is wrapped into a full document automatically.",
506
+ ),
507
+ filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
508
+ anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
509
+ 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."),
510
+ fresh: z.boolean().optional().describe("Force a new drop even if you already dropped in this session (default: update in place)."),
511
+ }
512
+ : {
451
513
  html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
452
514
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
453
515
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
454
516
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
455
517
  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."),
456
- fresh: z
457
- .boolean()
458
- .optional()
459
- .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
460
- },
518
+ fresh: z.boolean().optional().describe("Force a new drop even if you already dropped in this session (default: update in place)."),
519
+ };
520
+
521
+ server.registerTool(
522
+ "cloudgrid_drop",
523
+ {
524
+ description: "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.",
525
+ inputSchema: dropInputSchema,
461
526
  outputSchema: {
462
527
  url: z.string().optional().describe("The public URL of the drop."),
463
528
  status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
@@ -479,9 +544,24 @@ export function registerTools(server, ctx) {
479
544
  login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
480
545
  },
481
546
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
547
+ ...(ctx.edition === "web" ? {
548
+ _meta: {
549
+ ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
550
+ "openai/outputTemplate": LIVE_RESULT_URI,
551
+ },
552
+ } : {}),
482
553
  },
483
554
  async (input) => {
484
555
  try {
556
+ // Web edition: reject `path` early — the hosted server cannot read
557
+ // local files. The schema already omits it, but a model with a
558
+ // cached tool description might still send one.
559
+ if (ctx.edition === "web" && input?.path) {
560
+ return fail(
561
+ "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
562
+ );
563
+ }
564
+
485
565
  // Web edition: sign-in guidance when unauthenticated.
486
566
  if (ctx.edition === "web" && input?.anonymous !== true) {
487
567
  const token = await ctx.getToken();
@@ -492,26 +572,37 @@ export function registerTools(server, ctx) {
492
572
  structured: { needs_sign_in: true, login_url: url },
493
573
  });
494
574
  }
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.
575
+ // Org disambiguation with per-session pick gate: the first drop
576
+ // always shows the picker when >1 org (even if the model guessed an
577
+ // org). The flag ctx.state.awaitingOrgPick gates: only after the
578
+ // picker was shown and the re-call supplies a valid slug do we honor
579
+ // it. This prevents the model from silently guessing and skipping
580
+ // the user's choice.
498
581
  {
499
582
  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) {
583
+ if (orgs.length > 1) {
584
+ const suppliedOrg = input?.org;
585
+ const validOrg = suppliedOrg && orgs.some((o) => o.slug === suppliedOrg);
586
+ if (ctx.state.awaitingOrgPick && validOrg) {
587
+ // The picker was shown previously and the user (or widget)
588
+ // picked a valid org — honor it, clear the flag, and publish.
589
+ ctx.state.awaitingOrgPick = false;
590
+ input = { ...(input || {}), org: suppliedOrg };
591
+ } else {
592
+ // First drop (any model-guessed org is ignored) or the
593
+ // re-call supplied an invalid slug — show the picker.
594
+ ctx.state.awaitingOrgPick = true;
504
595
  const lines = ["Which org should this be published to?"];
505
596
  for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
506
597
  lines.push("Pass the org slug in the org parameter to publish.");
507
598
  return okResult({
508
599
  text: lines.join("\n"),
509
600
  structured: { needs_org: true, orgs },
601
+ meta: { "openai/outputTemplate": ORG_PICKER_URI },
510
602
  });
511
603
  }
512
- if (orgs.length === 1) {
513
- input = { ...(input || {}), org: orgs[0].slug };
514
- }
604
+ } else if (orgs.length === 1) {
605
+ input = { ...(input || {}), org: orgs[0].slug };
515
606
  }
516
607
  }
517
608
  }
package/src/web.js CHANGED
@@ -55,7 +55,7 @@ function makeWebContext(sessionId) {
55
55
  let sessionToken = null;
56
56
  return {
57
57
  edition: "web",
58
- state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
58
+ state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null, awaitingOrgPick: false },
59
59
  canOpenBrowser: false,
60
60
  // Transport OAuth wins; the in-tool login flow is the fallback.
61
61
  getToken: async () => sessionAuth[sessionId] ?? sessionToken,
@@ -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,93 @@
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.filename) args.filename = input.filename;
69
+ if (input.fresh !== undefined) args.fresh = input.fresh;
70
+ args.org = slug;
71
+ window.openai.callTool("cloudgrid_drop", args).then(function () {
72
+ // The host will render the new result with the live-result card.
73
+ }).catch(function () {
74
+ for (var m = 0; m < all.length; m++) all[m].classList.remove("loading");
75
+ if (statusEl) statusEl.textContent = "Failed to publish";
76
+ });
77
+ });
78
+ })(buttons[j]);
79
+ }
80
+ }
81
+
82
+ var data = window.openai && window.openai.toolOutput;
83
+ if (data) render(data);
84
+ window.addEventListener("message", function (e) {
85
+ if (e.data && e.data.method === "ui/notifications/tool-result" &&
86
+ e.data.params && e.data.params.structuredContent) {
87
+ render(e.data.params.structuredContent);
88
+ }
89
+ });
90
+ })();
91
+ </script>
92
+ </body>
93
+ </html>