@cloudgrid-io/mcp 0.7.3 → 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.3",
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": {
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"],
@@ -194,11 +196,11 @@ function resolveCwd(cwd) {
194
196
  }
195
197
 
196
198
  // Pin the CLI version for the lazy npx fallback so behaviour is reproducible.
197
- // MCP 0.5.2 is tested against CLI 0.10.1.
198
- 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";
199
201
 
200
202
  // Minimum CLI version that supports the verbs and flags the MCP passes.
201
- const MIN_CLI_VERSION = "0.10.1";
203
+ const MIN_CLI_VERSION = "0.12.0";
202
204
 
203
205
  // Verb map for the drift guard: each CLI-wrapping tool's top-level verb(s).
204
206
  // The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
@@ -656,8 +658,16 @@ async function fetchUserOrgs(token) {
656
658
  });
657
659
  if (!res.ok) return [];
658
660
  const data = await res.json();
659
- const orgs = Array.isArray(data?.orgs) ? data.orgs : Array.isArray(data) ? data : [];
660
- 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) => ({
661
671
  slug: o.slug ?? "",
662
672
  name: o.name ?? o.slug ?? "",
663
673
  role: o.role ?? "member",
@@ -681,19 +691,19 @@ async function fetchUserOrgs(token) {
681
691
  // org-picker web widget reads, so the web card keeps working. Stateless — no
682
692
  // dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
683
693
  export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
684
- const listOrgs = deps.fetchUserOrgs || fetchUserOrgs;
685
- const orgs = await listOrgs(token);
686
- const activeOrg = await ctx.getActiveOrg();
687
- const matched = suppliedGrid && orgs.find((o) => o.slug === suppliedGrid);
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);
688
698
  if (matched) {
689
699
  // Supplied grid matches — proceed. The agent should already have checked
690
700
  // render_ready and warned the user; we don't block here.
691
701
  return { proceed: true, grid: suppliedGrid };
692
702
  }
693
- if (orgs.length > 1) {
703
+ if (grids.length > 1) {
694
704
  // No valid grid supplied and multiple grids — ask once. Mark the active
695
705
  // grid so the agent can offer it as the default.
696
- const annotated = orgs.map((o) => ({ ...o, is_active: o.slug === activeOrg }));
706
+ const annotated = grids.map((o) => ({ ...o, is_active: o.slug === activeGrid }));
697
707
  // Sort: active grid first, then ready grids, then not-ready grids.
698
708
  annotated.sort((a, b) => {
699
709
  if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
@@ -719,12 +729,12 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
719
729
  // `needs_grid` is the new field; `needs_org`/`orgs` are kept as aliases
720
730
  // so the existing org-picker.html web widget (reads data.orgs) still works.
721
731
  structured: { needs_grid: true, needs_org: true, grids: annotated, orgs: annotated },
722
- ...(edition === "web" ? { meta: { "openai/outputTemplate": ORG_PICKER_URI } } : {}),
732
+ ...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
723
733
  },
724
734
  };
725
735
  }
726
- if (orgs.length === 1) {
727
- return { single: { ...orgs[0], is_active: orgs[0].slug === activeOrg } };
736
+ if (grids.length === 1) {
737
+ return { single: { ...grids[0], is_active: grids[0].slug === activeGrid } };
728
738
  }
729
739
  return { proceed: true };
730
740
  }
@@ -737,7 +747,12 @@ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
737
747
  if (!token || !entityId) return false;
738
748
  try {
739
749
  const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
740
- 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
+ }
741
756
  const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
742
757
  method: "PATCH",
743
758
  headers: hdrs,
@@ -843,7 +858,7 @@ export async function runDrop(
843
858
  if (anonymous !== true) {
844
859
  authToken = await ctx.getToken();
845
860
  if (authToken) {
846
- orgSlug = targetGrid || (await ctx.getActiveOrg());
861
+ orgSlug = targetGrid || (await ctx.getActiveGrid());
847
862
  }
848
863
  }
849
864
 
@@ -897,7 +912,11 @@ export async function runDrop(
897
912
  const headers = {};
898
913
  if (!isAnonymousWire) {
899
914
  headers["Authorization"] = `Bearer ${authToken}`;
900
- 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
+ }
901
920
  }
902
921
 
903
922
  // Hosted server: attach the trusted-server credential on EVERY anonymous call
@@ -1161,8 +1180,12 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
1161
1180
  // The claim re-homes the inspiration into the caller's grid; the platform
1162
1181
  // resolves the destination from the active-org context, so send the org
1163
1182
  // header (the same header every other authed write uses).
1164
- const orgSlug = await ctx.getActiveOrg();
1165
- 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
+ }
1166
1189
  // Replay the anon-session cookie so a cookie-class caller can claim what it
1167
1190
  // dropped, even without a claim token.
1168
1191
  if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
@@ -1542,8 +1565,12 @@ export async function runPlug(ctx, input, deps = {}) {
1542
1565
  // the caller's membership from this header and requires it to MATCH the
1543
1566
  // entity's grid, so pass `grid` here too when the target lives outside the
1544
1567
  // active grid.
1545
- const orgSlug = grid || (await ctx.getActiveOrg());
1546
- 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
+ }
1547
1574
  }
1548
1575
  if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1549
1576
  headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
@@ -1704,8 +1731,12 @@ async function authedApiCall(ctx, { method, pathName, body, verb }) {
1704
1731
  throw new Error(`${verb} requires sign-in. Run gridctl_login first.`);
1705
1732
  }
1706
1733
  const headers = { Authorization: `Bearer ${token}` };
1707
- const orgSlug = await ctx.getActiveOrg();
1708
- 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
+ }
1709
1740
  if (body !== undefined) headers["Content-Type"] = "application/json";
1710
1741
  let res;
1711
1742
  try {
@@ -1800,8 +1831,12 @@ async function runVisibility(ctx, { target, visibility, org }) {
1800
1831
  throw new Error("No target. Pass the entity id, or drop something first in this session.");
1801
1832
  }
1802
1833
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1803
- const orgSlug = org || (await ctx.getActiveOrg());
1804
- 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
+ }
1805
1840
  let res;
1806
1841
  try {
1807
1842
  res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(id)}`, {
@@ -1879,14 +1914,14 @@ export function registerTools(server, ctx) {
1879
1914
  }],
1880
1915
  }));
1881
1916
 
1882
- server.registerResource("cloudgrid-org-picker", ORG_PICKER_URI, {
1917
+ server.registerResource("cloudgrid-org-picker", GRID_PICKER_URI, {
1883
1918
  description: "Org picker card — lets the user choose which organization to publish into.",
1884
1919
  mimeType: "text/html;profile=mcp-app",
1885
1920
  }, async () => ({
1886
1921
  contents: [{
1887
- uri: ORG_PICKER_URI,
1922
+ uri: GRID_PICKER_URI,
1888
1923
  mimeType: "text/html;profile=mcp-app",
1889
- text: ORG_PICKER_HTML,
1924
+ text: GRID_PICKER_HTML,
1890
1925
  _meta: { ui: { csp: WIDGET_CSP } },
1891
1926
  }],
1892
1927
  }));
@@ -2366,16 +2401,17 @@ export function registerTools(server, ctx) {
2366
2401
  if (!token) {
2367
2402
  return fail("You are not signed in. Run gridctl_login first.");
2368
2403
  }
2369
- const orgs = await fetchUserOrgs(token);
2370
- if (orgs.length === 0) {
2371
- 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: [] } });
2372
2408
  }
2373
- const activeOrg = await ctx.getActiveOrg();
2374
- const annotated = orgs.map((o) => ({
2409
+ const activeGrid = await ctx.getActiveGrid();
2410
+ const annotated = grids.map((o) => ({
2375
2411
  ...o,
2376
- is_active: o.slug === activeOrg,
2412
+ is_active: o.slug === activeGrid,
2377
2413
  }));
2378
- // Sort: active org first, then ready orgs, then not-ready orgs.
2414
+ // Sort: active grid first, then ready grids, then not-ready grids.
2379
2415
  annotated.sort((a, b) => {
2380
2416
  if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
2381
2417
  if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
@@ -2383,15 +2419,16 @@ export function registerTools(server, ctx) {
2383
2419
  });
2384
2420
  const lines = annotated.map((o) => {
2385
2421
  const tags = [];
2386
- if (o.is_active) tags.push("your active org");
2422
+ if (o.is_active) tags.push("your active grid");
2387
2423
  if (!o.render_ready) tags.push("not set up yet");
2388
2424
  const suffix = tags.length ? ` (${tags.join(", ")})` : "";
2389
2425
  return `${o.slug} — ${o.name} (${o.role})${suffix}`;
2390
2426
  });
2391
2427
  const readyCount = annotated.filter((o) => o.render_ready).length;
2392
2428
  if (readyCount === 0 && annotated.length > 0) {
2393
- 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.");
2394
2430
  }
2431
+ // Structured output stays `orgs` (its declared schema); user text says grid.
2395
2432
  return okResult({ text: lines.join("\n"), structured: { orgs: annotated } });
2396
2433
  },
2397
2434
  );
@@ -2439,7 +2476,7 @@ export function registerTools(server, ctx) {
2439
2476
  signedIn = false;
2440
2477
  }
2441
2478
  try {
2442
- activeGrid = (await ctx.getActiveOrg()) ?? null;
2479
+ activeGrid = (await ctx.getActiveGrid()) ?? null;
2443
2480
  } catch {
2444
2481
  activeGrid = null;
2445
2482
  }
@@ -2512,7 +2549,8 @@ export function registerTools(server, ctx) {
2512
2549
  if (type) args.push("--type", type);
2513
2550
  if (description) args.push("--description", description);
2514
2551
  if (dir) args.push("--dir", dir);
2515
- 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);
2516
2554
  return args;
2517
2555
  }, { cwdParam: true }),
2518
2556
  );
@@ -2563,7 +2601,8 @@ export function registerTools(server, ctx) {
2563
2601
  const args = ["feedback", "list"];
2564
2602
  if (since) args.push("--since", since);
2565
2603
  if (limit) args.push("--limit", String(limit));
2566
- 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);
2567
2606
  return args;
2568
2607
  }),
2569
2608
  );
@@ -2670,8 +2709,8 @@ export function registerTools(server, ctx) {
2670
2709
  cliTool(({ name, target_dir, grid, version, force, no_bind }) => {
2671
2710
  const args = ["pickup", name];
2672
2711
  if (target_dir) args.push(target_dir);
2673
- // The CLI's pickup --help exposes `--org <slug>` (legacy naming) for the grid.
2674
- 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);
2675
2714
  if (version) args.push("--version", version);
2676
2715
  if (force) args.push("--force");
2677
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);