@cloudgrid-io/mcp 0.7.2 → 0.8.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/README.md CHANGED
@@ -15,11 +15,18 @@ drop, claim, and login tools.
15
15
 
16
16
  ## CLI compatibility
17
17
 
18
- MCP 0.5.2 is tested against CLI 0.10.1. The lazy-npx fallback pins
19
- `@cloudgrid-io/cli@~0.10.1` so a future CLI major with renamed verbs cannot
18
+ MCP 0.8.0 is tested against CLI 0.12. The lazy-npx fallback pins
19
+ `@cloudgrid-io/cli@~0.12` so a future CLI major with renamed verbs cannot
20
20
  silently break a released MCP. A CI drift guard (`npm run test:drift-guard`)
21
21
  asserts every wrapped verb exists in the CLI help.
22
22
 
23
+ ### `GRID_AUTH_STALE` is out of scope
24
+
25
+ Strict-OIDC (org-owned) grids can return `GRID_AUTH_STALE` when the caller's
26
+ session predates an SSO policy change. The MCP does not target such grids and
27
+ does not handle this code: if a `GRID_AUTH_STALE` response is ever seen, point
28
+ the user at Console SSO to re-authenticate — the MCP will not silently retry.
29
+
23
30
  ## Prerequisite
24
31
 
25
32
  Install and log in to the CLI:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "smoke": "node test/smoke.mjs",
19
19
  "test:plug-wire": "node test/plug-wire.test.mjs",
20
20
  "test:self-healing": "node test/self-healing.test.mjs",
21
+ "test:grid-picker": "node test/grid-picker.test.mjs",
21
22
  "test:drift-guard": "node test/drift-guard.mjs",
22
23
  "smoke:drop": "node test/smoke-drop.mjs",
23
24
  "smoke:login": "node test/smoke-login.mjs",
package/src/auth.js CHANGED
@@ -66,12 +66,16 @@ export async function readCredentials() {
66
66
  }
67
67
  }
68
68
 
69
- // The CLI's active org slug from ~/.cloudgrid/config.yaml, or null. A minimal
70
- // line parse — no YAML dependency for one field.
71
- export async function readActiveOrgSlug() {
69
+ // The CLI's active grid slug from ~/.cloudgrid/config.yaml, or null. A minimal
70
+ // line parse — no YAML dependency for one field. Accepts the grid-native
71
+ // `active_grid_slug` key and falls back to the legacy `active_org_slug` (CLI 0.12
72
+ // still writes the org key; this is forward-compatible with a future rename).
73
+ export async function readActiveGridSlug() {
72
74
  try {
73
75
  const raw = await readFile(join(cloudgridHome(), "config.yaml"), "utf8");
74
- const m = raw.match(/^\s*active_org_slug:\s*(\S+)\s*$/m);
76
+ const m =
77
+ raw.match(/^\s*active_grid_slug:\s*(\S+)\s*$/m) ||
78
+ raw.match(/^\s*active_org_slug:\s*(\S+)\s*$/m);
75
79
  return m ? m[1] : null;
76
80
  } catch {
77
81
  return null;
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { registerTools } from "./tools.js";
12
12
  import {
13
13
  readCredentials,
14
- readActiveOrgSlug,
14
+ readActiveGridSlug,
15
15
  writeCredentials,
16
16
  credentialsPath,
17
17
  } from "./auth.js";
@@ -23,7 +23,7 @@ const ctx = {
23
23
  state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
24
24
  canOpenBrowser: true,
25
25
  getToken: async () => (await readCredentials())?.jwt ?? null,
26
- getActiveOrg: async () => await readActiveOrgSlug(),
26
+ getActiveGrid: async () => await readActiveGridSlug(),
27
27
  saveToken: async (jwt) => await writeCredentials(jwt),
28
28
  savedLocationNote: () => `Credentials saved to ${credentialsPath()}.`,
29
29
  };
package/src/tools.js CHANGED
@@ -30,9 +30,11 @@ const CONSOLE_URL = "https://console.cloudgrid.io/";
30
30
 
31
31
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
32
32
  const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
33
- const ORG_PICKER_URI = "ui://cloudgrid/org-picker.html";
33
+ // URI/resource-name/filename stay `org-picker` — that's the stable contract the
34
+ // web card is registered under; only the JS identifier moves toward grid.
35
+ const GRID_PICKER_URI = "ui://cloudgrid/org-picker.html";
34
36
  const LIVE_RESULT_HTML = readFileSync(new URL("./widgets/live-result.html", import.meta.url), "utf-8");
35
- const ORG_PICKER_HTML = readFileSync(new URL("./widgets/org-picker.html", import.meta.url), "utf-8");
37
+ const GRID_PICKER_HTML = readFileSync(new URL("./widgets/org-picker.html", import.meta.url), "utf-8");
36
38
  const WIDGET_CSP = {
37
39
  connectDomains: ["https://*.cloudgrid.io"],
38
40
  resourceDomains: ["https://*.cloudgrid.io"],
@@ -66,6 +68,7 @@ Operating rules:
66
68
  4. Always return the live share URL at the end — that is the deliverable.
67
69
  5. Minimize questions. Use sensible defaults and build; don't front-load setup questions.
68
70
  6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
71
+ 7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
69
72
 
70
73
  Deploy is edition-dependent: on the hosted MCP call the drop tool with the HTML; on local MCP / CLI write the file and run the plug tool. An HTML page deploys synchronously, so you get a URL right away.`;
71
74
 
@@ -193,11 +196,11 @@ function resolveCwd(cwd) {
193
196
  }
194
197
 
195
198
  // Pin the CLI version for the lazy npx fallback so behaviour is reproducible.
196
- // MCP 0.5.2 is tested against CLI 0.10.1.
197
- const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.10.1";
199
+ // MCP 0.8.0 is tested against CLI 0.12.
200
+ const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.12";
198
201
 
199
202
  // Minimum CLI version that supports the verbs and flags the MCP passes.
200
- const MIN_CLI_VERSION = "0.10.1";
203
+ const MIN_CLI_VERSION = "0.12.0";
201
204
 
202
205
  // Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
203
206
  // The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
@@ -655,8 +658,16 @@ async function fetchUserOrgs(token) {
655
658
  });
656
659
  if (!res.ok) return [];
657
660
  const data = await res.json();
658
- const orgs = Array.isArray(data?.orgs) ? data.orgs : Array.isArray(data) ? data : [];
659
- return orgs.map((o) => ({
661
+ // 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
662
+ // `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
663
+ const grids = Array.isArray(data?.grids)
664
+ ? data.grids
665
+ : Array.isArray(data?.orgs)
666
+ ? data.orgs
667
+ : Array.isArray(data)
668
+ ? data
669
+ : [];
670
+ return grids.map((o) => ({
660
671
  slug: o.slug ?? "",
661
672
  name: o.name ?? o.slug ?? "",
662
673
  role: o.role ?? "member",
@@ -667,6 +678,67 @@ async function fetchUserOrgs(token) {
667
678
  }
668
679
  }
669
680
 
681
+ // ── Shared grid disambiguation (gridctl_drop + gridctl_plug) ──────────────────
682
+ // The stateless "which grid?" ask, used identically by both publish verbs so
683
+ // they never drift. Given the caller's token and a supplied grid, it decides:
684
+ // - supplied grid matches a membership → { proceed: true, grid }
685
+ // - >1 grid and none supplied → { picker } (a ready-to-return result)
686
+ // - exactly one grid → { single: annotatedOrg } — the caller
687
+ // decides how to treat a not-ready single grid (drop blocks; plug warns)
688
+ // - no orgs / listing failed → { proceed: true } (fall through)
689
+ // User-facing text says "grid" (Gilad's org→grid rename); the structured payload
690
+ // carries `needs_grid` AND the `needs_org`/`orgs`/`org`-slug fields the existing
691
+ // org-picker web widget reads, so the web card keeps working. Stateless — no
692
+ // dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
693
+ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
694
+ const listGrids = deps.fetchUserOrgs || fetchUserOrgs;
695
+ const grids = await listGrids(token);
696
+ const activeGrid = await ctx.getActiveGrid();
697
+ const matched = suppliedGrid && grids.find((o) => o.slug === suppliedGrid);
698
+ if (matched) {
699
+ // Supplied grid matches — proceed. The agent should already have checked
700
+ // render_ready and warned the user; we don't block here.
701
+ return { proceed: true, grid: suppliedGrid };
702
+ }
703
+ if (grids.length > 1) {
704
+ // No valid grid supplied and multiple grids — ask once. Mark the active
705
+ // grid so the agent can offer it as the default.
706
+ const annotated = grids.map((o) => ({ ...o, is_active: o.slug === activeGrid }));
707
+ // Sort: active grid first, then ready grids, then not-ready grids.
708
+ annotated.sort((a, b) => {
709
+ if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
710
+ if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
711
+ return 0;
712
+ });
713
+ const lines = ["Which grid should this be published to?"];
714
+ for (const o of annotated) {
715
+ const tags = [];
716
+ if (o.is_active) tags.push("your active grid");
717
+ if (!o.render_ready) tags.push("not set up yet");
718
+ const suffix = tags.length ? ` (${tags.join(", ")})` : "";
719
+ lines.push(` ${o.slug} — ${o.name} (${o.role})${suffix}`);
720
+ }
721
+ lines.push("Pass the grid slug in the `grid` parameter.");
722
+ const readyCount = annotated.filter((o) => o.render_ready).length;
723
+ if (readyCount === 0) {
724
+ lines.push("Note: none of your grids are fully set up yet. You can use anonymous: true as a fallback.");
725
+ }
726
+ return {
727
+ picker: {
728
+ text: lines.join("\n"),
729
+ // `needs_grid` is the new field; `needs_org`/`orgs` are kept as aliases
730
+ // so the existing org-picker.html web widget (reads data.orgs) still works.
731
+ structured: { needs_grid: true, needs_org: true, grids: annotated, orgs: annotated },
732
+ ...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
733
+ },
734
+ };
735
+ }
736
+ if (grids.length === 1) {
737
+ return { single: { ...grids[0], is_active: grids[0].slug === activeGrid } };
738
+ }
739
+ return { proceed: true };
740
+ }
741
+
670
742
  // After an authenticated web drop, upgrade visibility to "link" so the artifact
671
743
  // is shareable and its preview renders without a sign-in wall. Best-effort — a
672
744
  // failure here does not fail the drop; the user can always call gridctl_visibility.
@@ -675,7 +747,12 @@ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
675
747
  if (!token || !entityId) return false;
676
748
  try {
677
749
  const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
678
- if (orgSlug) hdrs["X-CloudGrid-Org"] = orgSlug;
750
+ // Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
751
+ // during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
752
+ if (orgSlug) {
753
+ hdrs["X-CloudGrid-Grid"] = orgSlug;
754
+ hdrs["X-CloudGrid-Org"] = orgSlug;
755
+ }
679
756
  const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
680
757
  method: "PATCH",
681
758
  headers: hdrs,
@@ -733,9 +810,11 @@ export function resolvePlugUrl(data) {
733
810
 
734
811
  export async function runDrop(
735
812
  ctx,
736
- { html, path: filePath, filename, anonymous, org, fresh, entity_id, owner_token },
813
+ { html, path: filePath, filename, anonymous, org, grid, fresh, entity_id, owner_token },
737
814
  deps = {},
738
815
  ) {
816
+ // `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
817
+ const targetGrid = org ?? grid;
739
818
  // Defensive: the web edition schema excludes `path`, but if a model still
740
819
  // passes one (e.g. from a cached tool description), reject early with a
741
820
  // clear explanation.
@@ -779,7 +858,7 @@ export async function runDrop(
779
858
  if (anonymous !== true) {
780
859
  authToken = await ctx.getToken();
781
860
  if (authToken) {
782
- orgSlug = org || (await ctx.getActiveOrg());
861
+ orgSlug = targetGrid || (await ctx.getActiveGrid());
783
862
  }
784
863
  }
785
864
 
@@ -833,7 +912,11 @@ export async function runDrop(
833
912
  const headers = {};
834
913
  if (!isAnonymousWire) {
835
914
  headers["Authorization"] = `Bearer ${authToken}`;
836
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
915
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
916
+ if (orgSlug) {
917
+ headers["X-CloudGrid-Grid"] = orgSlug;
918
+ headers["X-CloudGrid-Org"] = orgSlug;
919
+ }
837
920
  }
838
921
 
839
922
  // Hosted server: attach the trusted-server credential on EVERY anonymous call
@@ -1097,8 +1180,12 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
1097
1180
  // The claim re-homes the inspiration into the caller's grid; the platform
1098
1181
  // resolves the destination from the active-org context, so send the org
1099
1182
  // header (the same header every other authed write uses).
1100
- const orgSlug = await ctx.getActiveOrg();
1101
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1183
+ const orgSlug = await ctx.getActiveGrid();
1184
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1185
+ if (orgSlug) {
1186
+ headers["X-CloudGrid-Grid"] = orgSlug;
1187
+ headers["X-CloudGrid-Org"] = orgSlug;
1188
+ }
1102
1189
  // Replay the anon-session cookie so a cookie-class caller can claim what it
1103
1190
  // dropped, even without a claim token.
1104
1191
  if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
@@ -1478,8 +1565,12 @@ export async function runPlug(ctx, input, deps = {}) {
1478
1565
  // the caller's membership from this header and requires it to MATCH the
1479
1566
  // entity's grid, so pass `grid` here too when the target lives outside the
1480
1567
  // active grid.
1481
- const orgSlug = grid || (await ctx.getActiveOrg());
1482
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1568
+ const orgSlug = grid || (await ctx.getActiveGrid());
1569
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1570
+ if (orgSlug) {
1571
+ headers["X-CloudGrid-Grid"] = orgSlug;
1572
+ headers["X-CloudGrid-Org"] = orgSlug;
1573
+ }
1483
1574
  }
1484
1575
  if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1485
1576
  headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
@@ -1640,8 +1731,12 @@ async function authedApiCall(ctx, { method, pathName, body, verb }) {
1640
1731
  throw new Error(`${verb} requires sign-in. Run gridctl_login first.`);
1641
1732
  }
1642
1733
  const headers = { Authorization: `Bearer ${token}` };
1643
- const orgSlug = await ctx.getActiveOrg();
1644
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1734
+ const orgSlug = await ctx.getActiveGrid();
1735
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1736
+ if (orgSlug) {
1737
+ headers["X-CloudGrid-Grid"] = orgSlug;
1738
+ headers["X-CloudGrid-Org"] = orgSlug;
1739
+ }
1645
1740
  if (body !== undefined) headers["Content-Type"] = "application/json";
1646
1741
  let res;
1647
1742
  try {
@@ -1736,8 +1831,12 @@ async function runVisibility(ctx, { target, visibility, org }) {
1736
1831
  throw new Error("No target. Pass the entity id, or drop something first in this session.");
1737
1832
  }
1738
1833
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1739
- const orgSlug = org || (await ctx.getActiveOrg());
1740
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1834
+ const orgSlug = org || (await ctx.getActiveGrid());
1835
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1836
+ if (orgSlug) {
1837
+ headers["X-CloudGrid-Grid"] = orgSlug;
1838
+ headers["X-CloudGrid-Org"] = orgSlug;
1839
+ }
1741
1840
  let res;
1742
1841
  try {
1743
1842
  res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(id)}`, {
@@ -1815,14 +1914,14 @@ export function registerTools(server, ctx) {
1815
1914
  }],
1816
1915
  }));
1817
1916
 
1818
- server.registerResource("cloudgrid-org-picker", ORG_PICKER_URI, {
1917
+ server.registerResource("cloudgrid-org-picker", GRID_PICKER_URI, {
1819
1918
  description: "Org picker card — lets the user choose which organization to publish into.",
1820
1919
  mimeType: "text/html;profile=mcp-app",
1821
1920
  }, async () => ({
1822
1921
  contents: [{
1823
- uri: ORG_PICKER_URI,
1922
+ uri: GRID_PICKER_URI,
1824
1923
  mimeType: "text/html;profile=mcp-app",
1825
- text: ORG_PICKER_HTML,
1924
+ text: GRID_PICKER_HTML,
1826
1925
  _meta: { ui: { csp: WIDGET_CSP } },
1827
1926
  }],
1828
1927
  }));
@@ -1847,7 +1946,8 @@ export function registerTools(server, ctx) {
1847
1946
  ),
1848
1947
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
1849
1948
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
1850
- 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."),
1949
+ grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
1950
+ org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
1851
1951
  ...dropReplugParams,
1852
1952
  }
1853
1953
  : {
@@ -1855,7 +1955,8 @@ export function registerTools(server, ctx) {
1855
1955
  path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
1856
1956
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
1857
1957
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
1858
- 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."),
1958
+ grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
1959
+ org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
1859
1960
  ...dropReplugParams,
1860
1961
  };
1861
1962
 
@@ -1877,13 +1978,20 @@ export function registerTools(server, ctx) {
1877
1978
  value: z.string().describe("Visibility value to pass to gridctl_visibility."),
1878
1979
  label: z.string().describe("Human-readable label."),
1879
1980
  })).optional().describe("Available visibility levels."),
1880
- needs_org: z.boolean().optional().describe("True when the user must choose an org before dropping."),
1981
+ needs_grid: z.boolean().optional().describe("True when the user must choose a grid before dropping."),
1982
+ needs_org: z.boolean().optional().describe("Deprecated alias of needs_grid (kept so the org-picker widget keeps working)."),
1983
+ grids: z.array(z.object({
1984
+ slug: z.string().describe("Grid slug to pass as the grid parameter."),
1985
+ name: z.string().describe("Human-readable grid name."),
1986
+ role: z.string().describe("User's role in the grid."),
1987
+ is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
1988
+ })).optional().describe("The user's grids, when a grid choice is needed."),
1881
1989
  orgs: z.array(z.object({
1882
- slug: z.string().describe("Org slug to pass as the org parameter."),
1883
- name: z.string().describe("Human-readable org name."),
1884
- role: z.string().describe("User's role in the org."),
1885
- is_active: z.boolean().optional().describe("True if this is the user's currently active org."),
1886
- })).optional().describe("The user's orgs, when org choice is needed."),
1990
+ slug: z.string().describe("Grid slug to pass as the grid parameter."),
1991
+ name: z.string().describe("Human-readable grid name."),
1992
+ role: z.string().describe("User's role in the grid."),
1993
+ is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
1994
+ })).optional().describe("Deprecated alias of grids (kept so the org-picker widget keeps working)."),
1887
1995
  needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
1888
1996
  login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
1889
1997
  },
@@ -1918,63 +2026,37 @@ export function registerTools(server, ctx) {
1918
2026
  }
1919
2027
  }
1920
2028
 
1921
- // Stateless org disambiguation — both editions, when authenticated.
2029
+ // Stateless grid disambiguation — both editions, when authenticated.
1922
2030
  // No dependency on prior-call state so it works even when the client
1923
- // reconnects on every tool call (ChatGPT Apps SDK behaviour).
2031
+ // reconnects on every tool call (ChatGPT Apps SDK behaviour). `grid` is
2032
+ // accepted as an alias for `org` (Gilad's rename); `org` still works.
1924
2033
  if (input?.anonymous !== true) {
1925
2034
  const token = await ctx.getToken();
1926
2035
  if (token) {
1927
- const orgs = await fetchUserOrgs(token);
1928
- const activeOrg = await ctx.getActiveOrg();
1929
- const suppliedOrg = input?.org;
1930
- const matchedOrg = suppliedOrg && orgs.find((o) => o.slug === suppliedOrg);
1931
- if (matchedOrg) {
1932
- // Supplied org matches — proceed with it. The agent should have
1933
- // already checked render_ready via gridctl_orgs and warned the
1934
- // user if this org isn't set up yet. We don't block here.
1935
- input = { ...(input || {}), org: suppliedOrg };
1936
- } else if (orgs.length > 1) {
1937
- // No valid org supplied and multiple orgs — ask once.
1938
- // Mark the active org so the agent can offer it as the default.
1939
- const annotated = orgs.map((o) => ({
1940
- ...o,
1941
- is_active: o.slug === activeOrg,
1942
- }));
1943
- // Sort: active org first, then ready orgs, then not-ready orgs.
1944
- annotated.sort((a, b) => {
1945
- if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
1946
- if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
1947
- return 0;
1948
- });
1949
- const lines = ["Which org should this be published to?"];
1950
- for (const o of annotated) {
1951
- const tags = [];
1952
- if (o.is_active) tags.push("your active org");
1953
- if (!o.render_ready) tags.push("not set up yet");
1954
- const suffix = tags.length ? ` (${tags.join(", ")})` : "";
1955
- lines.push(` ${o.slug} — ${o.name} (${o.role})${suffix}`);
1956
- }
1957
- lines.push("Pass the org slug in the org parameter to publish.");
1958
- const readyCount = annotated.filter((o) => o.render_ready).length;
1959
- if (readyCount === 0) {
1960
- lines.push("Note: none of your orgs are fully set up yet. You can use anonymous: true as a fallback.");
1961
- }
1962
- return okResult({
1963
- text: lines.join("\n"),
1964
- structured: { needs_org: true, orgs: annotated },
1965
- ...(ctx.edition === "web" ? { meta: { "openai/outputTemplate": ORG_PICKER_URI } } : {}),
1966
- });
1967
- } else if (orgs.length === 1) {
1968
- if (!orgs[0].render_ready) {
1969
- // Single org but not set up — tell the agent so it can warn
1970
- // the user and offer anonymous drop as fallback.
1971
- const annotated = [{ ...orgs[0], is_active: orgs[0].slug === activeOrg, render_ready: false }];
2036
+ const suppliedGrid = input?.grid || input?.org;
2037
+ const decision = await resolveGridOrAsk(ctx, {
2038
+ token,
2039
+ suppliedGrid,
2040
+ edition: ctx.edition,
2041
+ });
2042
+ if (decision.picker) {
2043
+ return okResult(decision.picker);
2044
+ }
2045
+ if (decision.single) {
2046
+ if (!decision.single.render_ready) {
2047
+ // Single grid but not set up — tell the agent so it can warn
2048
+ // the user and offer anonymous drop as fallback. (drop-specific:
2049
+ // drop blocks here; plug proceeds with a warning.)
2050
+ const annotated = [{ ...decision.single, render_ready: false }];
1972
2051
  return okResult({
1973
- text: `Your only org "${orgs[0].slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
1974
- structured: { needs_org: true, org_not_ready: true, orgs: annotated },
2052
+ text: `Your only grid "${decision.single.slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
2053
+ structured: { needs_grid: true, needs_org: true, grid_not_ready: true, org_not_ready: true, grids: annotated, orgs: annotated },
1975
2054
  });
1976
2055
  }
1977
- input = { ...(input || {}), org: orgs[0].slug };
2056
+ input = { ...(input || {}), org: decision.single.slug };
2057
+ } else if (decision.grid) {
2058
+ // Supplied grid matched — normalize onto `org` for runDrop.
2059
+ input = { ...(input || {}), org: decision.grid };
1978
2060
  }
1979
2061
  }
1980
2062
  }
@@ -2090,6 +2172,42 @@ export function registerTools(server, ctx) {
2090
2172
  },
2091
2173
  async (input) => {
2092
2174
  try {
2175
+ // Grid-picker parity with gridctl_drop: a signed-in user with >1 grid is
2176
+ // ASKED which grid to publish to on every CREATE. Only for authed creates
2177
+ // (no target_entity_id, not anon). Edits NEVER ask — the grid is fixed by
2178
+ // the entity. Anon proceeds as a Guest-Grid drop. Explicit valid grid
2179
+ // proceeds. A single grid proceeds (with a warning if it isn't set up yet).
2180
+ const isEdit =
2181
+ typeof input?.target_entity_id === "string" && input.target_entity_id.length > 0;
2182
+ if (input?.anon !== true && !isEdit) {
2183
+ const token = await ctx.getToken();
2184
+ if (token) {
2185
+ const decision = await resolveGridOrAsk(ctx, {
2186
+ token,
2187
+ suppliedGrid: input?.grid,
2188
+ edition: ctx.edition,
2189
+ });
2190
+ if (decision.picker) {
2191
+ // Do NOT silently default to the active grid — surface the ask.
2192
+ return okResult(decision.picker);
2193
+ }
2194
+ if (decision.single) {
2195
+ // Proceed into the single grid; warn (don't block) if not set up yet.
2196
+ input = { ...(input || {}), grid: decision.single.slug };
2197
+ if (decision.single.render_ready === false) {
2198
+ const res = await runPlug(ctx, input || {});
2199
+ return okResult({
2200
+ ...res,
2201
+ text:
2202
+ `Warning: your only grid "${decision.single.slug}" isn't fully set up yet — the page may not load until provisioning completes.\n` +
2203
+ res.text,
2204
+ });
2205
+ }
2206
+ } else if (decision.grid) {
2207
+ input = { ...(input || {}), grid: decision.grid };
2208
+ }
2209
+ }
2210
+ }
2093
2211
  return okResult(await runPlug(ctx, input || {}));
2094
2212
  } catch (err) {
2095
2213
  return fail(err.message);
@@ -2283,16 +2401,17 @@ export function registerTools(server, ctx) {
2283
2401
  if (!token) {
2284
2402
  return fail("You are not signed in. Run gridctl_login first.");
2285
2403
  }
2286
- const orgs = await fetchUserOrgs(token);
2287
- if (orgs.length === 0) {
2288
- return okResult({ text: "No organizations found.", structured: { orgs: [] } });
2404
+ const grids = await fetchUserOrgs(token);
2405
+ if (grids.length === 0) {
2406
+ // Structured output stays `orgs` (its declared schema); user text says grid.
2407
+ return okResult({ text: "No grids found.", structured: { orgs: [] } });
2289
2408
  }
2290
- const activeOrg = await ctx.getActiveOrg();
2291
- const annotated = orgs.map((o) => ({
2409
+ const activeGrid = await ctx.getActiveGrid();
2410
+ const annotated = grids.map((o) => ({
2292
2411
  ...o,
2293
- is_active: o.slug === activeOrg,
2412
+ is_active: o.slug === activeGrid,
2294
2413
  }));
2295
- // Sort: active org first, then ready orgs, then not-ready orgs.
2414
+ // Sort: active grid first, then ready grids, then not-ready grids.
2296
2415
  annotated.sort((a, b) => {
2297
2416
  if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
2298
2417
  if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
@@ -2300,15 +2419,16 @@ export function registerTools(server, ctx) {
2300
2419
  });
2301
2420
  const lines = annotated.map((o) => {
2302
2421
  const tags = [];
2303
- if (o.is_active) tags.push("your active org");
2422
+ if (o.is_active) tags.push("your active grid");
2304
2423
  if (!o.render_ready) tags.push("not set up yet");
2305
2424
  const suffix = tags.length ? ` (${tags.join(", ")})` : "";
2306
2425
  return `${o.slug} — ${o.name} (${o.role})${suffix}`;
2307
2426
  });
2308
2427
  const readyCount = annotated.filter((o) => o.render_ready).length;
2309
2428
  if (readyCount === 0 && annotated.length > 0) {
2310
- lines.push("\nNone of your orgs are fully set up yet. You can use an anonymous drop as a fallback, or wait until provisioning completes.");
2429
+ lines.push("\nNone of your grids are fully set up yet. You can use an anonymous drop as a fallback, or wait until provisioning completes.");
2311
2430
  }
2431
+ // Structured output stays `orgs` (its declared schema); user text says grid.
2312
2432
  return okResult({ text: lines.join("\n"), structured: { orgs: annotated } });
2313
2433
  },
2314
2434
  );
@@ -2356,7 +2476,7 @@ export function registerTools(server, ctx) {
2356
2476
  signedIn = false;
2357
2477
  }
2358
2478
  try {
2359
- activeGrid = (await ctx.getActiveOrg()) ?? null;
2479
+ activeGrid = (await ctx.getActiveGrid()) ?? null;
2360
2480
  } catch {
2361
2481
  activeGrid = null;
2362
2482
  }
@@ -2429,7 +2549,8 @@ export function registerTools(server, ctx) {
2429
2549
  if (type) args.push("--type", type);
2430
2550
  if (description) args.push("--description", description);
2431
2551
  if (dir) args.push("--dir", dir);
2432
- if (org) args.push("--org", org);
2552
+ // CLI 0.12 dropped `--org` in favour of `--grid` (same slug).
2553
+ if (org) args.push("--grid", org);
2433
2554
  return args;
2434
2555
  }, { cwdParam: true }),
2435
2556
  );
@@ -2480,7 +2601,8 @@ export function registerTools(server, ctx) {
2480
2601
  const args = ["feedback", "list"];
2481
2602
  if (since) args.push("--since", since);
2482
2603
  if (limit) args.push("--limit", String(limit));
2483
- if (org) args.push("--org", org);
2604
+ // CLI 0.12 dropped `--org` in favour of `--grid` (same slug).
2605
+ if (org) args.push("--grid", org);
2484
2606
  return args;
2485
2607
  }),
2486
2608
  );
@@ -2587,8 +2709,8 @@ export function registerTools(server, ctx) {
2587
2709
  cliTool(({ name, target_dir, grid, version, force, no_bind }) => {
2588
2710
  const args = ["pickup", name];
2589
2711
  if (target_dir) args.push(target_dir);
2590
- // The CLI's pickup --help exposes `--org <slug>` (legacy naming) for the grid.
2591
- if (grid) args.push("--org", grid);
2712
+ // CLI 0.12's pickup exposes `--grid <slug>` (was legacy `--org`).
2713
+ if (grid) args.push("--grid", grid);
2592
2714
  if (version) args.push("--version", version);
2593
2715
  if (force) args.push("--force");
2594
2716
  if (no_bind) args.push("--no-bind");
package/src/web.js CHANGED
@@ -59,9 +59,9 @@ function makeWebContext(sessionId) {
59
59
  canOpenBrowser: false,
60
60
  // Transport OAuth wins; the in-tool login flow is the fallback.
61
61
  getToken: async () => sessionAuth[sessionId] ?? sessionToken,
62
- // No local config on a shared host. The user passes `org`, or the API returns
63
- // the list of orgs to choose from.
64
- getActiveOrg: async () => null,
62
+ // No local config on a shared host. The user passes `grid`, or the API returns
63
+ // the list of grids to choose from.
64
+ getActiveGrid: async () => null,
65
65
  saveToken: async (jwt) => {
66
66
  sessionToken = jwt;
67
67
  return decodeJwt(jwt);
@@ -34,18 +34,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
34
34
  }
35
35
 
36
36
  function render(data) {
37
- if (!data || !data.orgs || data.orgs.length === 0) {
38
- root.innerHTML = '<div class="card"><div class="title">No organizations found</div></div>';
37
+ // `grids` is the current field; fall back to `orgs` for older payloads.
38
+ var items = (data && (data.grids || data.orgs)) || null;
39
+ if (!items || items.length === 0) {
40
+ root.innerHTML = '<div class="card"><div class="title">No grids found</div></div>';
39
41
  return;
40
42
  }
41
43
 
42
44
  var html =
43
45
  '<div class="card">' +
44
- '<div class="title">Choose an organization</div>' +
45
- '<div class="subtitle">Which org should this be published to?</div>' +
46
+ '<div class="title">Choose a grid</div>' +
47
+ '<div class="subtitle">Which grid should this be published to?</div>' +
46
48
  '<div class="orgs">';
47
- for (var i = 0; i < data.orgs.length; i++) {
48
- var org = data.orgs[i];
49
+ for (var i = 0; i < items.length; i++) {
50
+ var org = items[i];
49
51
  var notReady = org.render_ready === false;
50
52
  html +=
51
53
  '<button class="org-btn' + (notReady ? ' not-ready' : '') + '" data-slug="' + esc(org.slug) + '">' +
@@ -71,7 +73,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
71
73
  if (input.html) args.html = input.html;
72
74
  if (input.filename) args.filename = input.filename;
73
75
  if (input.fresh !== undefined) args.fresh = input.fresh;
74
- args.org = slug;
76
+ args.grid = slug;
77
+ args.org = slug; // alias kept for compatibility
75
78
  window.openai.callTool("cloudgrid_drop", args).then(function () {
76
79
  // The host will render the new result with the live-result card.
77
80
  }).catch(function () {