@cloudgrid-io/mcp 0.2.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 ADDED
@@ -0,0 +1,83 @@
1
+ # @cloudgrid-io/mcp
2
+
3
+ An MCP server for CloudGrid. It exposes the CloudGrid actions as MCP tools.
4
+
5
+ It ships in two editions from one codebase:
6
+
7
+ - **Local (stdio)** — runs on your machine, full toolset including the CLI-wrapping
8
+ tools. This README covers it. For Claude Code, Cursor, Claude Desktop.
9
+ - **Web (hosted HTTP)** — a light, CLI-free toolset (drop, claim, login) for
10
+ web clients like claude.ai. See [REMOTE.md](REMOTE.md).
11
+
12
+ The local edition wraps the `cloudgrid` CLI for authenticated operations (the CLI
13
+ handles auth, org context, and error formatting) and calls the API directly for the
14
+ drop, claim, and login tools.
15
+
16
+ ## Prerequisite
17
+
18
+ Install and log in to the CLI:
19
+
20
+ ```
21
+ npm install -g @cloudgrid-io/cli
22
+ cloudgrid login
23
+ ```
24
+
25
+ The server reads no credentials directly. It runs `cloudgrid`, which uses its own
26
+ stored credentials at `~/.cloudgrid/credentials`.
27
+
28
+ ## Run
29
+
30
+ ```
31
+ npx -y @cloudgrid-io/mcp
32
+ ```
33
+
34
+ Or from a clone:
35
+
36
+ ```
37
+ cd mcp-server
38
+ npm install
39
+ npm start
40
+ ```
41
+
42
+ It speaks MCP over stdio. Point any MCP client at the `cloudgrid-mcp` command.
43
+
44
+ ## Tools
45
+
46
+ | Tool | Wraps | Notes |
47
+ |---|---|---|
48
+ | `cloudgrid_drop` | `POST /api/v2/drop/auto` | Artifact drop. Anonymous, or owned if signed in. No CLI. Direct API. |
49
+ | `cloudgrid_claim` | `POST /api/v2/anon-claim` | Claim an anonymous drop into the signed-in account. Direct API. |
50
+ | `cloudgrid_login` | `GET /auth/login` | Start a CLI-free sign-in; returns a URL to open. Calls the API directly. |
51
+ | `cloudgrid_login_status` | `GET /auth/status` | Finish the sign-in; saves the token to the shared CLI credentials. |
52
+ | `cloudgrid_init` | `cloudgrid init` | Register an app or agent; optionally seed a web service. |
53
+ | `cloudgrid_plug` | `cloudgrid plug` | Deploy a directory or URL. |
54
+ | `cloudgrid_logs` | `cloudgrid logs` | Snapshot of recent logs. Does not stream. |
55
+ | `cloudgrid_share` | `cloudgrid visibility set` | Set visibility, default link. |
56
+ | `cloudgrid_feedback` | `cloudgrid feedback list` | Read the org feedback feed. |
57
+ | `cloudgrid_brain` | `cloudgrid brain refresh` | Re-run the Grid Brain hooks. |
58
+
59
+ `cloudgrid_drop` and the two `cloudgrid_login` tools are the ones that do not wrap
60
+ the CLI — both are about working without it. The anonymous drop has no identity to
61
+ manage; login exists to get an identity without the CLI. Both call the API directly.
62
+ `cloudgrid_login` writes the same `~/.cloudgrid/credentials` the CLI uses, so the two
63
+ share one identity.
64
+
65
+ ## Test
66
+
67
+ A smoke test spawns the server with a real MCP client, lists the tools, and calls
68
+ the read-only `cloudgrid_feedback` tool end to end:
69
+
70
+ ```
71
+ cd mcp-server
72
+ npm install
73
+ npm run smoke
74
+ ```
75
+
76
+ It needs a logged-in CLI on `$PATH`.
77
+
78
+ ## Design
79
+
80
+ - Shells out with `execFile` and an argument array, so there is no shell and no
81
+ injection surface.
82
+ - `cloudgrid_logs` never uses `--follow`; a streaming call would never return.
83
+ - Stateless. Each call is one CLI invocation.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@cloudgrid-io/mcp",
3
+ "version": "0.2.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "cloudgrid-mcp": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "start": "node src/index.js",
17
+ "start:web": "node src/web.js",
18
+ "smoke": "node test/smoke.mjs",
19
+ "smoke:drop": "node test/smoke-drop.mjs",
20
+ "smoke:login": "node test/smoke-login.mjs",
21
+ "smoke:claim": "node test/smoke-claim.mjs",
22
+ "smoke:web": "node test/smoke-web.mjs",
23
+ "test:auth": "node test/auth.test.mjs"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "express": "^4.22.2",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "license": "Apache-2.0",
31
+ "homepage": "https://github.com/cloudgrid-io/skills/tree/main/mcp-server",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/cloudgrid-io/skills.git",
35
+ "directory": "mcp-server"
36
+ }
37
+ }
package/src/auth.js ADDED
@@ -0,0 +1,98 @@
1
+ // CLI-free sign-in against CloudGrid's existing OAuth backend.
2
+ //
3
+ // The flow is the same one `cloudgrid login` uses, driven without the CLI:
4
+ // 1. pick a UUID `code`
5
+ // 2. user opens GET /auth/login?code=<code> and signs in with Google
6
+ // 3. poll GET /auth/status?code=<code> until it returns the signed JWT (once)
7
+ // 4. write the JWT to ~/.cloudgrid/credentials in the CLI's format, so the CLI
8
+ // and the MCP share one identity.
9
+ //
10
+ // No new identity provider, no localhost callback, no CLI dependency.
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import { mkdir, writeFile, chmod, readFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid.io").replace(/\/+$/, "");
18
+
19
+ export function cloudgridHome() {
20
+ return process.env.CLOUDGRID_HOME || join(homedir(), ".cloudgrid");
21
+ }
22
+
23
+ export function credentialsPath() {
24
+ return join(cloudgridHome(), "credentials");
25
+ }
26
+
27
+ export function newLoginCode() {
28
+ return randomUUID();
29
+ }
30
+
31
+ export function buildLoginUrl(code) {
32
+ return `${API_BASE}/auth/login?code=${encodeURIComponent(code)}`;
33
+ }
34
+
35
+ // One poll. Returns { status: 'not_started' | 'pending' | 'authenticated' | 'expired', jwt? }.
36
+ // A 404 means the session for this code does not exist yet — the user has not
37
+ // opened the sign-in URL — so it is reported as 'not_started', not an error.
38
+ export async function pollStatusOnce(code) {
39
+ const res = await fetch(`${API_BASE}/auth/status?code=${encodeURIComponent(code)}`);
40
+ if (res.status === 404) {
41
+ return { status: "not_started" };
42
+ }
43
+ if (!res.ok) {
44
+ throw new Error(`Sign-in status check failed: HTTP ${res.status}`);
45
+ }
46
+ return res.json();
47
+ }
48
+
49
+ // Read the stored credentials, or null if absent/unreadable. Same file the CLI
50
+ // writes, so an MCP can reuse a CLI login (and vice versa).
51
+ export async function readCredentials() {
52
+ try {
53
+ const creds = JSON.parse(await readFile(credentialsPath(), "utf8"));
54
+ return creds && creds.jwt ? creds : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // The CLI's active org slug from ~/.cloudgrid/config.yaml, or null. A minimal
61
+ // line parse — no YAML dependency for one field.
62
+ export async function readActiveOrgSlug() {
63
+ try {
64
+ const raw = await readFile(join(cloudgridHome(), "config.yaml"), "utf8");
65
+ const m = raw.match(/^\s*active_org_slug:\s*(\S+)\s*$/m);
66
+ return m ? m[1] : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // Decode a JWT payload without verifying it — only to populate the credentials
73
+ // file's email/user_id fields. The server verifies the token on every use.
74
+ export function decodeJwt(jwt) {
75
+ try {
76
+ const part = jwt.split(".")[1];
77
+ const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
78
+ return JSON.parse(json);
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+
84
+ // Write the JWT to ~/.cloudgrid/credentials in the CLI's exact shape and 0600.
85
+ export async function writeCredentials(jwt) {
86
+ const claims = decodeJwt(jwt);
87
+ const creds = {
88
+ jwt,
89
+ issued_at: new Date().toISOString(),
90
+ email: claims.email ?? null,
91
+ user_id: claims.sub ?? null,
92
+ };
93
+ await mkdir(cloudgridHome(), { recursive: true });
94
+ const path = credentialsPath();
95
+ await writeFile(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
96
+ await chmod(path, 0o600); // enforce even if the file pre-existed
97
+ return creds;
98
+ }
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // CloudGrid MCP server — local edition (stdio).
3
+ //
4
+ // Runs as a subprocess of a local MCP client (Claude Code, Cursor, Claude
5
+ // Desktop). Full toolset, including the CLI-wrapping tools. Identity comes from
6
+ // the shared ~/.cloudgrid/credentials file, so it interoperates with the CLI.
7
+
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { registerTools } from "./tools.js";
11
+ import {
12
+ readCredentials,
13
+ readActiveOrgSlug,
14
+ writeCredentials,
15
+ credentialsPath,
16
+ } from "./auth.js";
17
+
18
+ const ctx = {
19
+ edition: "local",
20
+ state: { pendingLoginCode: null, lastAnonClaim: null },
21
+ canOpenBrowser: true,
22
+ getToken: async () => (await readCredentials())?.jwt ?? null,
23
+ getActiveOrg: async () => await readActiveOrgSlug(),
24
+ saveToken: async (jwt) => await writeCredentials(jwt),
25
+ savedLocationNote: () => `Credentials saved to ${credentialsPath()}.`,
26
+ };
27
+
28
+ const server = new McpServer({ name: "cloudgrid-mcp", version: "0.2.0" });
29
+ registerTools(server, ctx);
30
+
31
+ const transport = new StdioServerTransport();
32
+ await server.connect(transport);
package/src/tools.js ADDED
@@ -0,0 +1,419 @@
1
+ // Shared tool core for both editions of the CloudGrid MCP server.
2
+ //
3
+ // Two editions register from here:
4
+ // - local (stdio): full toolset, including the CLI-wrapping tools. Identity
5
+ // comes from ~/.cloudgrid/credentials.
6
+ // - web (HTTP, hosted): the light, CLI-free toolset (drop, claim, login).
7
+ // Identity is a per-session token held in memory.
8
+ //
9
+ // The difference is injected as a `ctx` object, so the tool logic is written once.
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { readFile } from "node:fs/promises";
14
+ import { basename } from "node:path";
15
+ import { z } from "zod";
16
+ import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid.io").replace(
21
+ /\/+$/,
22
+ "",
23
+ );
24
+
25
+ const ANON_HTML_MAX_BYTES = 2_000_000;
26
+
27
+ function ok(text) {
28
+ return { content: [{ type: "text", text }] };
29
+ }
30
+ function fail(text) {
31
+ return { content: [{ type: "text", text }], isError: true };
32
+ }
33
+
34
+ // ── CLI wrapping (local edition only) ──────────────────────────────────────────
35
+ async function runCloudgrid(args) {
36
+ try {
37
+ const { stdout, stderr } = await execFileAsync("cloudgrid", args, {
38
+ maxBuffer: 16 * 1024 * 1024,
39
+ timeout: 10 * 60 * 1000,
40
+ });
41
+ return (stdout || stderr || "").trim() || "Done.";
42
+ } catch (err) {
43
+ if (err && err.code === "ENOENT") {
44
+ throw new Error(
45
+ "The cloudgrid CLI is not installed. Install it with: npm install -g @cloudgrid-io/cli",
46
+ );
47
+ }
48
+ const detail = [err && err.stdout, err && err.stderr, err && err.message]
49
+ .filter(Boolean)
50
+ .join("\n")
51
+ .trim();
52
+ throw new Error(detail || "cloudgrid command failed");
53
+ }
54
+ }
55
+
56
+ function cliTool(buildArgs) {
57
+ return async (input) => {
58
+ try {
59
+ return ok(await runCloudgrid(buildArgs(input || {})));
60
+ } catch (err) {
61
+ return fail(err.message);
62
+ }
63
+ };
64
+ }
65
+
66
+ function tryOpenBrowser(url) {
67
+ if (process.env.CLOUDGRID_NO_BROWSER === "1") return;
68
+ const cmd =
69
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
70
+ try {
71
+ execFile(cmd, [url], () => {});
72
+ } catch {
73
+ // ignore — the URL is returned to the user anyway
74
+ }
75
+ }
76
+
77
+ // ── Direct-API tools (both editions) ───────────────────────────────────────────
78
+ function looksLikeFullHtml(s) {
79
+ const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
80
+ return head.startsWith("<!doctype html") || head.startsWith("<html");
81
+ }
82
+
83
+ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org }) {
84
+ let bytes;
85
+ let name;
86
+ let type;
87
+
88
+ if (filePath) {
89
+ bytes = await readFile(filePath);
90
+ name = filename || basename(filePath);
91
+ type = "application/octet-stream";
92
+ } else if (typeof html === "string" && html.length > 0) {
93
+ let content = html;
94
+ if (!looksLikeFullHtml(content)) {
95
+ content =
96
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
97
+ `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
98
+ }
99
+ bytes = Buffer.from(content, "utf8");
100
+ name = filename || "index.html";
101
+ type = "text/html";
102
+ if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
103
+ throw new Error(
104
+ `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
105
+ `Trim it, or sign in to publish larger.`,
106
+ );
107
+ }
108
+ } else {
109
+ throw new Error("Provide either `html` (inline content) or `path` (a local file).");
110
+ }
111
+
112
+ const headers = {};
113
+ let orgSlug = null;
114
+ if (anonymous !== true) {
115
+ const token = await ctx.getToken();
116
+ if (token) {
117
+ headers["Authorization"] = `Bearer ${token}`;
118
+ orgSlug = org || (await ctx.getActiveOrg());
119
+ if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
120
+ }
121
+ }
122
+
123
+ const form = new FormData();
124
+ form.append("artifact", new Blob([bytes], { type }), name);
125
+ if (orgSlug) form.append("org_slug", orgSlug);
126
+
127
+ let res;
128
+ try {
129
+ res = await fetch(`${API_BASE}/api/v2/drop/auto`, { method: "POST", headers, body: form });
130
+ } catch (err) {
131
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
132
+ }
133
+
134
+ const raw = await res.text();
135
+ let data = null;
136
+ try {
137
+ data = JSON.parse(raw);
138
+ } catch {
139
+ /* handled below */
140
+ }
141
+ if (!res.ok) {
142
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
143
+ const hint = data?.error?.details?.[0]?.hint;
144
+ throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
145
+ }
146
+
147
+ if (data.owned_by === "authenticated") {
148
+ ctx.state.lastAnonClaim = null;
149
+ const lines = [`Published to your org: ${data.url}`, "Owned by you."];
150
+ if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
151
+ return lines.join("\n");
152
+ }
153
+
154
+ if (data.claim_url) {
155
+ try {
156
+ ctx.state.lastAnonClaim = {
157
+ token: new URL(data.claim_url).searchParams.get("token"),
158
+ entity_id: data.entity_id,
159
+ url: data.url,
160
+ };
161
+ } catch {
162
+ ctx.state.lastAnonClaim = null;
163
+ }
164
+ }
165
+ const lines = [`Live: ${data.url}`];
166
+ if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
167
+ if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
168
+ return lines.join("\n");
169
+ }
170
+
171
+ async function runClaim(ctx, { claim_token, claim_url }) {
172
+ const token = await ctx.getToken();
173
+ if (!token) {
174
+ throw new Error("You are not signed in. Run cloudgrid_login first, then claim.");
175
+ }
176
+ let claimToken = claim_token;
177
+ if (!claimToken && claim_url) {
178
+ try {
179
+ claimToken = new URL(claim_url).searchParams.get("token");
180
+ } catch {
181
+ claimToken = null;
182
+ }
183
+ }
184
+ if (!claimToken && ctx.state.lastAnonClaim) claimToken = ctx.state.lastAnonClaim.token;
185
+ if (!claimToken) {
186
+ throw new Error("No claim token. Pass claim_token or claim_url from an anonymous drop.");
187
+ }
188
+
189
+ let res;
190
+ try {
191
+ res = await fetch(`${API_BASE}/api/v2/anon-claim`, {
192
+ method: "POST",
193
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
194
+ body: JSON.stringify({ claim_token: claimToken }),
195
+ });
196
+ } catch (err) {
197
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
198
+ }
199
+
200
+ const raw = await res.text();
201
+ let data = null;
202
+ try {
203
+ data = JSON.parse(raw);
204
+ } catch {
205
+ /* handled below */
206
+ }
207
+ if (!res.ok) {
208
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
209
+ throw new Error(`Claim failed (HTTP ${res.status}): ${msg}`);
210
+ }
211
+ const claimed = Array.isArray(data?.claimed) ? data.claimed : [];
212
+ if (claimed.length === 0) return "Nothing to claim — it may already be claimed or expired.";
213
+ ctx.state.lastAnonClaim = null;
214
+ const lines = [`Claimed ${claimed.length}, now yours:`];
215
+ for (const c of claimed) {
216
+ lines.push(`${c.url}${c.new_expires_at ? ` (expires ${c.new_expires_at})` : ""}`);
217
+ }
218
+ return lines.join("\n");
219
+ }
220
+
221
+ // ── Registration ───────────────────────────────────────────────────────────────
222
+ // Registers the tools onto `server`. ctx.edition decides whether the CLI-wrapping
223
+ // tools are included (they need a local machine).
224
+ export function registerTools(server, ctx) {
225
+ // Drop — both editions.
226
+ server.tool(
227
+ "cloudgrid_drop",
228
+ "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. 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.",
229
+ {
230
+ html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
231
+ path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
232
+ filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
233
+ anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
234
+ org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
235
+ },
236
+ async (input) => {
237
+ try {
238
+ return ok(await runDrop(ctx, input || {}));
239
+ } catch (err) {
240
+ return fail(err.message);
241
+ }
242
+ },
243
+ );
244
+
245
+ // Claim — both editions.
246
+ server.tool(
247
+ "cloudgrid_claim",
248
+ "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring in 7 days. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. Requires sign-in (cloudgrid_login). Calls the API directly.",
249
+ {
250
+ claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
251
+ claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
252
+ },
253
+ async (input) => {
254
+ try {
255
+ return ok(await runClaim(ctx, input || {}));
256
+ } catch (err) {
257
+ return fail(err.message);
258
+ }
259
+ },
260
+ );
261
+
262
+ // Login — both editions. Local opens a browser and saves to the credentials
263
+ // file; web returns the URL and saves to the session.
264
+ server.tool(
265
+ "cloudgrid_login",
266
+ "Start a CLI-free CloudGrid sign-in. Use when the user wants to log in, sign in, or authenticate, or to claim an anonymous drop. Returns a URL to open in the browser; then call cloudgrid_login_status to finish. Uses CloudGrid's existing OAuth.",
267
+ {},
268
+ async () => {
269
+ const code = newLoginCode();
270
+ ctx.state.pendingLoginCode = code;
271
+ const url = buildLoginUrl(code);
272
+ if (ctx.canOpenBrowser) tryOpenBrowser(url);
273
+ return ok(
274
+ `To sign in, open this URL in your browser and finish with Google:\n${url}\n\n` +
275
+ `After you complete it, run cloudgrid_login_status to finish signing in.`,
276
+ );
277
+ },
278
+ );
279
+
280
+ server.tool(
281
+ "cloudgrid_login_status",
282
+ "Finish a sign-in started by cloudgrid_login. Polls once: if you have completed the browser sign-in, it saves your session; otherwise it tells you to finish and try again.",
283
+ {
284
+ code: z.string().optional().describe("The sign-in code. Defaults to the most recent cloudgrid_login."),
285
+ },
286
+ async (input) => {
287
+ const code = input?.code || ctx.state.pendingLoginCode;
288
+ if (!code) return fail("No sign-in is in progress. Run cloudgrid_login first.");
289
+ let status;
290
+ try {
291
+ status = await pollStatusOnce(code);
292
+ } catch (err) {
293
+ return fail(err.message);
294
+ }
295
+ if (status.status === "authenticated" && status.jwt) {
296
+ let info;
297
+ try {
298
+ info = await ctx.saveToken(status.jwt);
299
+ } catch (err) {
300
+ return fail(`Signed in, but could not save credentials: ${err.message}`);
301
+ }
302
+ ctx.state.pendingLoginCode = null;
303
+ const who = info?.email ? ` as ${info.email}` : "";
304
+ return ok(`Signed in${who}. ${ctx.savedLocationNote()}`);
305
+ }
306
+ if (status.status === "pending" || status.status === "not_started") {
307
+ return ok(
308
+ "Still waiting for you to finish signing in. Open the URL from cloudgrid_login " +
309
+ "in your browser, complete it with Google, then run cloudgrid_login_status again.",
310
+ );
311
+ }
312
+ return fail("The sign-in window expired (5 minutes). Run cloudgrid_login to start again.");
313
+ },
314
+ );
315
+
316
+ if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
317
+
318
+ // ── CLI-wrapping tools (local edition only) ──
319
+ server.tool(
320
+ "cloudgrid_init",
321
+ "Register a new CloudGrid app or agent, optionally seeding a web service. Wraps `cloudgrid init`.",
322
+ {
323
+ kind: z.enum(["app", "agent"]).describe("Entity kind."),
324
+ name: z.string().describe("Slug: 3-40 lowercase alphanumerics and hyphens."),
325
+ type: z.enum(["node", "nextjs", "python", "static"]).optional().describe("Seed a web service of this type."),
326
+ description: z.string().optional().describe("Initial one-line description."),
327
+ dir: z.string().optional().describe("Target directory. Defaults to ./<name>."),
328
+ org: z.string().optional().describe("Override the active org for this init."),
329
+ },
330
+ cliTool(({ kind, name, type, description, dir, org }) => {
331
+ const args = ["init", kind, name];
332
+ if (type) args.push("--type", type);
333
+ if (description) args.push("--description", description);
334
+ if (dir) args.push("--dir", dir);
335
+ if (org) args.push("--org", org);
336
+ return args;
337
+ }),
338
+ );
339
+
340
+ server.tool(
341
+ "cloudgrid_plug",
342
+ "Build and deploy a directory or URL. Prints the live URL. Wraps `cloudgrid plug`.",
343
+ {
344
+ target: z.string().optional().describe("Path or URL. Omit to deploy the entity linked to the current directory."),
345
+ org: z.string().optional().describe("Pick or override the org."),
346
+ no_deploy: z.boolean().optional().describe("Register the entity but do not build or deploy."),
347
+ },
348
+ cliTool(({ target, org, no_deploy }) => {
349
+ const args = ["plug"];
350
+ if (target) args.push(target);
351
+ if (org) args.push("--org", org);
352
+ if (no_deploy) args.push("--no-deploy");
353
+ args.push("--no-clipboard", "--no-notify");
354
+ return args;
355
+ }),
356
+ );
357
+
358
+ server.tool(
359
+ "cloudgrid_logs",
360
+ "Tail recent logs for an entity. Does not stream; returns a snapshot. Wraps `cloudgrid logs`.",
361
+ {
362
+ name: z.string().optional().describe("Entity name. Omit to use the entity linked to the current directory."),
363
+ tail: z.number().int().positive().optional().describe("Number of recent lines. Default 100."),
364
+ since: z.string().optional().describe("Only logs newer than this, e.g. 5m, 1h, 2d."),
365
+ },
366
+ cliTool(({ name, tail, since }) => {
367
+ const args = ["logs"];
368
+ if (name) args.push(name);
369
+ args.push("--tail", String(tail ?? 100));
370
+ if (since) args.push("--since", since);
371
+ return args;
372
+ }),
373
+ );
374
+
375
+ server.tool(
376
+ "cloudgrid_share",
377
+ "Set an entity's visibility and print its URL. Defaults to link (anyone with the URL). Wraps `cloudgrid visibility set`.",
378
+ {
379
+ name: z.string().describe("Entity slug."),
380
+ mode: z.enum(["link", "private", "authenticated", "org", "space"]).optional().describe("Visibility mode. Default link."),
381
+ },
382
+ cliTool(({ name, mode }) => ["visibility", "set", name, mode ?? "link"]),
383
+ );
384
+
385
+ server.tool(
386
+ "cloudgrid_feedback",
387
+ "List recent feedback events for the active org. Read-only. Wraps `cloudgrid feedback list`.",
388
+ {
389
+ since: z.string().optional().describe("Only events newer than this, e.g. 24h, 7d."),
390
+ limit: z.number().int().positive().max(200).optional().describe("Number of events. Default 50, max 200."),
391
+ org: z.string().optional().describe("Read another org's feed where you have access."),
392
+ },
393
+ cliTool(({ since, limit, org }) => {
394
+ const args = ["feedback", "list"];
395
+ if (since) args.push("--since", since);
396
+ if (limit) args.push("--limit", String(limit));
397
+ if (org) args.push("--org", org);
398
+ return args;
399
+ }),
400
+ );
401
+
402
+ server.tool(
403
+ "cloudgrid_brain",
404
+ "Re-run an entity's Grid Brain hooks to re-classify its description, tags, and diagram. Wraps `cloudgrid brain refresh`.",
405
+ {
406
+ name: z.string().describe("Entity slug."),
407
+ wait: z.boolean().optional().describe("Wait for the refresh to finish. Default true."),
408
+ org: z.string().optional().describe("Target an entity in another org."),
409
+ },
410
+ cliTool(({ name, wait, org }) => {
411
+ const args = ["brain", "refresh", name];
412
+ if (wait !== false) args.push("--wait");
413
+ if (org) args.push("--org", org);
414
+ return args;
415
+ }),
416
+ );
417
+ }
418
+
419
+ export { decodeJwt };
package/src/web.js ADDED
@@ -0,0 +1,95 @@
1
+ // CloudGrid MCP server — web edition (HTTP, hosted).
2
+ //
3
+ // The same tool core as the local edition, served over the MCP Streamable HTTP
4
+ // transport so web clients (claude.ai) can connect by URL with nothing installed.
5
+ // The light, CLI-free toolset only: drop, claim, login. Identity is a per-session
6
+ // token held in memory for the life of the MCP session (no local files on a
7
+ // shared host).
8
+ //
9
+ // Run: PORT=8080 node src/web.js Health: GET /healthz
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import express from "express";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16
+ import { registerTools, decodeJwt } from "./tools.js";
17
+
18
+ const PORT = Number(process.env.PORT || 8080);
19
+
20
+ // A web session: identity lives in memory for the session's lifetime only.
21
+ function makeWebContext() {
22
+ let sessionToken = null;
23
+ return {
24
+ edition: "web",
25
+ state: { pendingLoginCode: null, lastAnonClaim: null },
26
+ canOpenBrowser: false,
27
+ getToken: async () => sessionToken,
28
+ // No local config on a shared host. The user passes `org`, or the API returns
29
+ // the list of orgs to choose from.
30
+ getActiveOrg: async () => null,
31
+ saveToken: async (jwt) => {
32
+ sessionToken = jwt;
33
+ return decodeJwt(jwt);
34
+ },
35
+ savedLocationNote: () => "You are signed in for this session.",
36
+ };
37
+ }
38
+
39
+ const app = express();
40
+ app.use(express.json({ limit: "8mb" }));
41
+
42
+ app.get("/healthz", (_req, res) => res.json({ ok: true, edition: "web" }));
43
+
44
+ // One transport per MCP session, keyed by the session id.
45
+ const transports = Object.create(null);
46
+
47
+ app.post("/mcp", async (req, res) => {
48
+ const sessionId = req.headers["mcp-session-id"];
49
+ let transport = sessionId ? transports[sessionId] : undefined;
50
+
51
+ if (!transport) {
52
+ if (sessionId || !isInitializeRequest(req.body)) {
53
+ res.status(400).json({
54
+ jsonrpc: "2.0",
55
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
56
+ id: null,
57
+ });
58
+ return;
59
+ }
60
+ // New session: fresh server + per-session identity context.
61
+ transport = new StreamableHTTPServerTransport({
62
+ sessionIdGenerator: () => randomUUID(),
63
+ onsessioninitialized: (sid) => {
64
+ transports[sid] = transport;
65
+ },
66
+ });
67
+ transport.onclose = () => {
68
+ if (transport.sessionId) delete transports[transport.sessionId];
69
+ };
70
+ const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.0" });
71
+ registerTools(server, makeWebContext());
72
+ await server.connect(transport);
73
+ }
74
+
75
+ await transport.handleRequest(req, res, req.body);
76
+ });
77
+
78
+ // SSE stream (GET) and session close (DELETE) reuse the same transport.
79
+ async function handleSessionRequest(req, res) {
80
+ const sessionId = req.headers["mcp-session-id"];
81
+ const transport = sessionId ? transports[sessionId] : undefined;
82
+ if (!transport) {
83
+ res.status(400).send("Invalid or missing session id");
84
+ return;
85
+ }
86
+ await transport.handleRequest(req, res);
87
+ }
88
+
89
+ app.get("/mcp", handleSessionRequest);
90
+ app.delete("/mcp", handleSessionRequest);
91
+
92
+ app.listen(PORT, () => {
93
+ // eslint-disable-next-line no-console
94
+ console.error(`cloudgrid-mcp web edition listening on :${PORT} (POST /mcp, GET /healthz)`);
95
+ });