@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 +9 -2
- package/package.json +1 -1
- package/src/auth.js +8 -4
- package/src/index.js +2 -2
- package/src/tools.js +83 -44
- package/src/web.js +3 -3
package/README.md
CHANGED
|
@@ -15,11 +15,18 @@ drop, claim, and login tools.
|
|
|
15
15
|
|
|
16
16
|
## CLI compatibility
|
|
17
17
|
|
|
18
|
-
MCP 0.
|
|
19
|
-
`@cloudgrid-io/cli@~0.
|
|
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.
|
|
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
|
|
70
|
-
// line parse — no YAML dependency for one field.
|
|
71
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
198
|
-
const CLI_NPX_PKG = "@cloudgrid-io/cli@~0.
|
|
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.
|
|
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
|
-
|
|
660
|
-
|
|
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
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
const matched = 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 (
|
|
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 =
|
|
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":
|
|
732
|
+
...(edition === "web" ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
|
|
723
733
|
},
|
|
724
734
|
};
|
|
725
735
|
}
|
|
726
|
-
if (
|
|
727
|
-
return { single: { ...
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1165
|
-
|
|
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.
|
|
1546
|
-
|
|
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.
|
|
1708
|
-
|
|
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.
|
|
1804
|
-
|
|
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",
|
|
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:
|
|
1922
|
+
uri: GRID_PICKER_URI,
|
|
1888
1923
|
mimeType: "text/html;profile=mcp-app",
|
|
1889
|
-
text:
|
|
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
|
|
2370
|
-
if (
|
|
2371
|
-
|
|
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
|
|
2374
|
-
const annotated =
|
|
2409
|
+
const activeGrid = await ctx.getActiveGrid();
|
|
2410
|
+
const annotated = grids.map((o) => ({
|
|
2375
2411
|
...o,
|
|
2376
|
-
is_active: o.slug ===
|
|
2412
|
+
is_active: o.slug === activeGrid,
|
|
2377
2413
|
}));
|
|
2378
|
-
// Sort: active
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2674
|
-
if (grid) args.push("--
|
|
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 `
|
|
63
|
-
// the list of
|
|
64
|
-
|
|
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);
|