@cloudgrid-io/mcp 0.4.0 → 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.4.0",
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
@@ -150,6 +150,15 @@ function looksLikeFullHtml(s) {
150
150
  }
151
151
 
152
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
+
153
162
  let bytes;
154
163
  let name;
155
164
  let type;
@@ -485,22 +494,35 @@ export function registerTools(server, ctx) {
485
494
 
486
495
  // ── Direct-API tools (both editions) ──────────────────────────────────────
487
496
 
488
- // Drop — both editions.
489
- server.registerTool(
490
- "cloudgrid_drop",
491
- {
492
- 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.",
493
- 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
+ : {
494
513
  html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
495
514
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
496
515
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
497
516
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
498
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."),
499
- fresh: z
500
- .boolean()
501
- .optional()
502
- .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
503
- },
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,
504
526
  outputSchema: {
505
527
  url: z.string().optional().describe("The public URL of the drop."),
506
528
  status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
@@ -531,6 +553,15 @@ export function registerTools(server, ctx) {
531
553
  },
532
554
  async (input) => {
533
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
+
534
565
  // Web edition: sign-in guidance when unauthenticated.
535
566
  if (ctx.edition === "web" && input?.anonymous !== true) {
536
567
  const token = await ctx.getToken();
@@ -541,15 +572,26 @@ export function registerTools(server, ctx) {
541
572
  structured: { needs_sign_in: true, login_url: url },
542
573
  });
543
574
  }
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.
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.
547
581
  {
548
582
  const orgs = await fetchUserOrgs(token);
549
- const suppliedOrg = input?.org;
550
- const validOrg = suppliedOrg && orgs.some((o) => o.slug === suppliedOrg);
551
- if (!validOrg) {
552
- 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;
553
595
  const lines = ["Which org should this be published to?"];
554
596
  for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
555
597
  lines.push("Pass the org slug in the org parameter to publish.");
@@ -559,9 +601,8 @@ export function registerTools(server, ctx) {
559
601
  meta: { "openai/outputTemplate": ORG_PICKER_URI },
560
602
  });
561
603
  }
562
- if (orgs.length === 1) {
563
- input = { ...(input || {}), org: orgs[0].slug };
564
- }
604
+ } else if (orgs.length === 1) {
605
+ input = { ...(input || {}), org: orgs[0].slug };
565
606
  }
566
607
  }
567
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,
@@ -65,7 +65,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
65
65
  var input = (window.openai && window.openai.toolInput) || {};
66
66
  var args = {};
67
67
  if (input.html) args.html = input.html;
68
- if (input.path) args.path = input.path;
69
68
  if (input.filename) args.filename = input.filename;
70
69
  if (input.fresh !== undefined) args.fresh = input.fresh;
71
70
  args.org = slug;