@cloudgrid-io/mcp 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "MCP server for CloudGrid. Two editions: a local stdio server (full toolset) and a hosted web server (light, CLI-free toolset) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,8 @@
20
20
  "smoke:login": "node test/smoke-login.mjs",
21
21
  "smoke:claim": "node test/smoke-claim.mjs",
22
22
  "smoke:web": "node test/smoke-web.mjs",
23
+ "smoke:redrop": "node test/smoke-redrop.mjs",
24
+ "smoke:oauth": "node test/smoke-oauth.mjs",
23
25
  "test:auth": "node test/auth.test.mjs"
24
26
  },
25
27
  "dependencies": {
package/src/oauth.js ADDED
@@ -0,0 +1,226 @@
1
+ // MCP transport-level OAuth for the web edition.
2
+ //
3
+ // Implements the OAuth 2.1 surface the MCP authorization spec expects — metadata
4
+ // discovery (RFC 8414 + RFC 9728), dynamic client registration (RFC 7591), and the
5
+ // authorization-code flow with PKCE — as a thin BRIDGE over CloudGrid's existing
6
+ // sign-in: /oauth/authorize shows an interstitial, the user completes the normal
7
+ // api.cloudgrid.io/auth/login flow in a new tab, we poll /auth/status server-side,
8
+ // then redirect back to the client with an authorization code. /oauth/token
9
+ // exchanges it (PKCE-verified) for the CloudGrid JWT as the access token.
10
+ //
11
+ // No new identity provider. State is in-memory (single replica, like sessions).
12
+
13
+ import { randomUUID, createHash } from "node:crypto";
14
+ import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
15
+
16
+ const CODE_TTL_MS = 5 * 60 * 1000; // authorize sessions + auth codes live 5 minutes
17
+
18
+ // In-memory stores. { [id]: record } with created timestamps for TTL sweeps.
19
+ const clients = new Map(); // client_id -> { redirect_uris }
20
+ const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, created }
21
+ const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, created }
22
+
23
+ function sweep(map) {
24
+ const now = Date.now();
25
+ for (const [k, v] of map) if (now - v.created > CODE_TTL_MS) map.delete(k);
26
+ }
27
+
28
+ function b64url(buf) {
29
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
+ }
31
+
32
+ function corsOk(res) {
33
+ res.setHeader("Access-Control-Allow-Origin", "*");
34
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
35
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-protocol-version");
36
+ }
37
+
38
+ // The §23-voice interstitial. Opens the CloudGrid sign-in in a new tab and polls
39
+ // until it completes, then returns the browser to the client app.
40
+ function interstitialHtml(sid, loginUrl) {
41
+ return `<!doctype html>
42
+ <html lang="en">
43
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>Connect CloudGrid</title>
45
+ <style>
46
+ body { margin:0; min-height:100vh; display:grid; place-items:center;
47
+ font-family: Inter, system-ui, sans-serif; background:#0d0d0f; color:#fafafa; }
48
+ main { max-width:420px; padding:2.5rem; text-align:center; }
49
+ a.btn { display:inline-block; margin-top:1.25rem; padding:.75rem 1.5rem; border-radius:8px;
50
+ background:#fafafa; color:#0d0d0f; text-decoration:none; font-weight:600; }
51
+ p.status { margin-top:1.5rem; font-size:.9rem; opacity:.7; }
52
+ </style></head>
53
+ <body><main>
54
+ <h1>Connect CloudGrid</h1>
55
+ <p>Sign in with your CloudGrid account. This page returns to the app when you finish.</p>
56
+ <a class="btn" href="${loginUrl}" target="_blank" rel="noopener">Sign in to CloudGrid</a>
57
+ <p class="status" id="st">Waiting for sign-in.</p>
58
+ </main>
59
+ <script>
60
+ async function tick() {
61
+ try {
62
+ const r = await fetch("/oauth/authorize/poll?sid=${sid}");
63
+ const d = await r.json();
64
+ if (d.status === "ready") { location.href = d.redirect; return; }
65
+ if (d.status === "expired") { document.getElementById("st").textContent = "The sign-in window expired. Close this page and connect again."; return; }
66
+ } catch {}
67
+ setTimeout(tick, 2000);
68
+ }
69
+ tick();
70
+ </script></body></html>`;
71
+ }
72
+
73
+ /**
74
+ * Mounts the OAuth surface on an express app.
75
+ * publicBase = this server's public origin (e.g. https://mcp.cloudgrid.io).
76
+ */
77
+ export function mountOAuth(app, publicBase) {
78
+ const base = publicBase.replace(/\/+$/, "");
79
+
80
+ const asMetadata = {
81
+ issuer: base,
82
+ authorization_endpoint: `${base}/oauth/authorize`,
83
+ token_endpoint: `${base}/oauth/token`,
84
+ registration_endpoint: `${base}/oauth/register`,
85
+ response_types_supported: ["code"],
86
+ grant_types_supported: ["authorization_code"],
87
+ code_challenge_methods_supported: ["S256"],
88
+ token_endpoint_auth_methods_supported: ["none"],
89
+ scopes_supported: ["cloudgrid"],
90
+ };
91
+
92
+ app.options(/^\/(\.well-known|oauth)\/.*/, (_req, res) => {
93
+ corsOk(res);
94
+ res.status(204).end();
95
+ });
96
+
97
+ // RFC 9728 — the resource points at its authorization server (us).
98
+ app.get("/.well-known/oauth-protected-resource", (_req, res) => {
99
+ corsOk(res);
100
+ res.json({ resource: `${base}/mcp`, authorization_servers: [base], scopes_supported: ["cloudgrid"] });
101
+ });
102
+ app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
103
+ corsOk(res);
104
+ res.json({ resource: `${base}/mcp`, authorization_servers: [base], scopes_supported: ["cloudgrid"] });
105
+ });
106
+
107
+ // RFC 8414.
108
+ app.get("/.well-known/oauth-authorization-server", (_req, res) => {
109
+ corsOk(res);
110
+ res.json(asMetadata);
111
+ });
112
+
113
+ // RFC 7591 — dynamic client registration. Public clients, PKCE-only.
114
+ app.post("/oauth/register", (req, res) => {
115
+ corsOk(res);
116
+ const redirectUris = Array.isArray(req.body?.redirect_uris) ? req.body.redirect_uris : [];
117
+ if (redirectUris.length === 0) {
118
+ res.status(400).json({ error: "invalid_client_metadata", error_description: "redirect_uris is required." });
119
+ return;
120
+ }
121
+ const clientId = randomUUID();
122
+ clients.set(clientId, { redirect_uris: redirectUris, created: Date.now() });
123
+ res.status(201).json({
124
+ client_id: clientId,
125
+ token_endpoint_auth_method: "none",
126
+ grant_types: ["authorization_code"],
127
+ response_types: ["code"],
128
+ redirect_uris: redirectUris,
129
+ });
130
+ });
131
+
132
+ // Authorization endpoint — render the bridge interstitial.
133
+ app.get("/oauth/authorize", (req, res) => {
134
+ sweep(authSessions);
135
+ const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
136
+ const client = clients.get(String(client_id));
137
+ if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
138
+ res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
139
+ return;
140
+ }
141
+ if (response_type !== "code" || code_challenge_method !== "S256" || !code_challenge) {
142
+ res.status(400).send("This server requires response_type=code with PKCE (S256).");
143
+ return;
144
+ }
145
+ const sid = randomUUID();
146
+ const cgCode = newLoginCode();
147
+ authSessions.set(sid, {
148
+ cgCode,
149
+ client_id: String(client_id),
150
+ redirect_uri: String(redirect_uri),
151
+ state: state ? String(state) : "",
152
+ code_challenge: String(code_challenge),
153
+ created: Date.now(),
154
+ });
155
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
156
+ res.send(interstitialHtml(sid, buildLoginUrl(cgCode)));
157
+ });
158
+
159
+ // The interstitial's poll — bridges to CloudGrid /auth/status.
160
+ app.get("/oauth/authorize/poll", async (req, res) => {
161
+ corsOk(res);
162
+ const sess = authSessions.get(String(req.query.sid));
163
+ if (!sess || Date.now() - sess.created > CODE_TTL_MS) {
164
+ res.json({ status: "expired" });
165
+ return;
166
+ }
167
+ let upstream;
168
+ try {
169
+ upstream = await pollStatusOnce(sess.cgCode);
170
+ } catch {
171
+ res.json({ status: "pending" });
172
+ return;
173
+ }
174
+ if (upstream.status === "authenticated" && upstream.jwt) {
175
+ const code = b64url(Buffer.from(randomUUID()));
176
+ authCodes.set(code, {
177
+ jwt: upstream.jwt,
178
+ client_id: sess.client_id,
179
+ redirect_uri: sess.redirect_uri,
180
+ code_challenge: sess.code_challenge,
181
+ created: Date.now(),
182
+ });
183
+ authSessions.delete(String(req.query.sid));
184
+ const sep = sess.redirect_uri.includes("?") ? "&" : "?";
185
+ const redirect = `${sess.redirect_uri}${sep}code=${encodeURIComponent(code)}${sess.state ? `&state=${encodeURIComponent(sess.state)}` : ""}`;
186
+ res.json({ status: "ready", redirect });
187
+ return;
188
+ }
189
+ if (upstream.status === "expired") {
190
+ res.json({ status: "expired" });
191
+ return;
192
+ }
193
+ res.json({ status: "pending" });
194
+ });
195
+
196
+ // Token endpoint — PKCE-verified exchange; the CloudGrid JWT is the access token.
197
+ app.post("/oauth/token", (req, res) => {
198
+ corsOk(res);
199
+ sweep(authCodes);
200
+ const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body ?? {};
201
+ if (grant_type !== "authorization_code") {
202
+ res.status(400).json({ error: "unsupported_grant_type" });
203
+ return;
204
+ }
205
+ const rec = authCodes.get(String(code));
206
+ if (!rec || rec.client_id !== String(client_id) || rec.redirect_uri !== String(redirect_uri)) {
207
+ res.status(400).json({ error: "invalid_grant" });
208
+ return;
209
+ }
210
+ const challenge = b64url(createHash("sha256").update(String(code_verifier ?? "")).digest());
211
+ if (challenge !== rec.code_challenge) {
212
+ res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed." });
213
+ return;
214
+ }
215
+ authCodes.delete(String(code)); // single use
216
+ const claims = decodeJwt(rec.jwt);
217
+ const expiresIn = claims.exp ? Math.max(60, claims.exp - Math.floor(Date.now() / 1000)) : 30 * 86400;
218
+ res.json({ access_token: rec.jwt, token_type: "Bearer", expires_in: expiresIn, scope: "cloudgrid" });
219
+ });
220
+ }
221
+
222
+ /** The WWW-Authenticate challenge for 401s when auth is required. */
223
+ export function bearerChallenge(publicBase) {
224
+ const base = publicBase.replace(/\/+$/, "");
225
+ return `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`;
226
+ }
package/src/tools.js CHANGED
@@ -139,10 +139,12 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
139
139
 
140
140
  const form = new FormData();
141
141
  // Redrop (anon-redrop spec §6): a re-drop in the same session updates the previous
142
- // drop in place — same URL, new version. `fresh: true` forces a new drop. The
143
- // platform validates ownership and silently falls back to create, so this never
144
- // hard-fails. Field appended before the artifact so streaming parsers see it.
145
- if (isAnonymousCall && fresh !== true && ctx.state.lastDrop?.entity_id) {
142
+ // drop in place — same URL, new version. `fresh: true` forces a new drop. Sent for
143
+ // BOTH anonymous and authed callers: the platform validates ownership and silently
144
+ // falls back to create, so this never hard-fails (authed in-place lands when the
145
+ // platform extends the gate; until then the fallback equals today's behavior).
146
+ // Field appended before the artifact so streaming parsers see it.
147
+ if (fresh !== true && ctx.state.lastDrop?.entity_id) {
146
148
  form.append("previous_id", ctx.state.lastDrop.entity_id);
147
149
  }
148
150
  form.append("artifact", new Blob([bytes], { type }), name);
@@ -177,14 +179,7 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
177
179
  .find((c) => c.startsWith("cg_anon_session="));
178
180
  if (anonCookie) ctx.state.anonCookie = anonCookie;
179
181
 
180
- if (data.owned_by === "authenticated") {
181
- ctx.state.lastAnonClaim = null;
182
- const lines = [`Published to your org: ${data.url}`, "Owned by you."];
183
- if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
184
- return lines.join("\n");
185
- }
186
-
187
- // Anonymous: remember the drop for redrop continuity (any 2xx outcome).
182
+ // Remember the drop for redrop continuity — any caller class, any 2xx outcome.
188
183
  if (data.entity_id || data.url) {
189
184
  ctx.state.lastDrop = {
190
185
  entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
@@ -200,7 +195,16 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
200
195
  if (res.status === 200) {
201
196
  // Updated in place: same URL, new version, views/reactions intact.
202
197
  const lines = [`Updated in place — same link: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim()];
198
+ if (data.owned_by === "authenticated") lines.push("Owned by you.");
199
+ if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
200
+ return lines.join("\n");
201
+ }
202
+
203
+ if (data.owned_by === "authenticated") {
204
+ ctx.state.lastAnonClaim = null;
205
+ const lines = [`Published to your org: ${data.url}`, "Owned by you."];
203
206
  if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
207
+ lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
204
208
  return lines.join("\n");
205
209
  }
206
210
 
package/src/web.js CHANGED
@@ -6,6 +6,12 @@
6
6
  // token held in memory for the life of the MCP session (no local files on a
7
7
  // shared host).
8
8
  //
9
+ // Transport-level OAuth (src/oauth.js): clients can complete the MCP-spec OAuth
10
+ // connect — metadata discovery, dynamic registration, PKCE code flow — bridged to
11
+ // CloudGrid's existing sign-in. A Bearer presented on /mcp requests becomes the
12
+ // session's identity. MCP_REQUIRE_AUTH=1 makes the connect mandatory (401
13
+ // challenge); default is anonymous-first with auth honored when presented.
14
+ //
9
15
  // Run: PORT=8080 node src/web.js Health: GET /healthz
10
16
 
11
17
  import { randomUUID } from "node:crypto";
@@ -14,15 +20,32 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16
22
  import { registerTools, decodeJwt } from "./tools.js";
23
+ import { mountOAuth, bearerChallenge } from "./oauth.js";
17
24
 
18
25
  const PORT = Number(process.env.PORT || 8080);
19
26
 
27
+ // This server's public origin — used in OAuth metadata and the interstitial.
28
+ const PUBLIC_BASE = (process.env.MCP_PUBLIC_URL || "https://mcp.cloudgrid.io").replace(/\/+$/, "");
29
+
30
+ // MCP_REQUIRE_AUTH=1 turns on the 401 challenge: clients must complete the OAuth
31
+ // connect before using tools. Default off — anonymous-first is the GTM posture.
32
+ const REQUIRE_AUTH = process.env.MCP_REQUIRE_AUTH === "1";
33
+
20
34
  // The trusted-server credential, if this host is provisioned as one. Sent on
21
35
  // anonymous drops so the platform keys the anon-drop cap on the per-user id rather
22
36
  // than the shared cluster egress IP. Missing/bad secret falls back to the IP cap
23
37
  // server-side, so it is safe to leave unset.
24
38
  const TRUSTED_SERVER_SECRET = process.env.MCP_TRUSTED_SERVER_SECRET || null;
25
39
 
40
+ // Transport-level identity per MCP session: a Bearer presented on /mcp requests
41
+ // becomes the session's CloudGrid identity (takes precedence over in-tool login).
42
+ const sessionAuth = Object.create(null); // sid -> jwt
43
+
44
+ function bearerOf(req) {
45
+ const h = req.headers.authorization;
46
+ return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
47
+ }
48
+
26
49
  // A web session: identity lives in memory for the session's lifetime only. The
27
50
  // session id doubles as the stable, opaque end-user id for the trusted-server cap.
28
51
  function makeWebContext(sessionId) {
@@ -31,7 +54,8 @@ function makeWebContext(sessionId) {
31
54
  edition: "web",
32
55
  state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
33
56
  canOpenBrowser: false,
34
- getToken: async () => sessionToken,
57
+ // Transport OAuth wins; the in-tool login flow is the fallback.
58
+ getToken: async () => sessionAuth[sessionId] ?? sessionToken,
35
59
  // No local config on a shared host. The user passes `org`, or the API returns
36
60
  // the list of orgs to choose from.
37
61
  getActiveOrg: async () => null,
@@ -48,43 +72,65 @@ function makeWebContext(sessionId) {
48
72
 
49
73
  const app = express();
50
74
  app.use(express.json({ limit: "8mb" }));
75
+ app.use(express.urlencoded({ extended: false })); // OAuth token exchange is form-encoded
51
76
 
52
77
  app.get("/healthz", (_req, res) => res.json({ ok: true, edition: "web" }));
53
78
 
79
+ mountOAuth(app, PUBLIC_BASE);
80
+
54
81
  // One transport per MCP session, keyed by the session id.
55
82
  const transports = Object.create(null);
56
83
 
57
84
  app.post("/mcp", async (req, res) => {
58
85
  const sessionId = req.headers["mcp-session-id"];
59
86
  let transport = sessionId ? transports[sessionId] : undefined;
87
+ const jwt = bearerOf(req);
60
88
 
61
- if (!transport) {
62
- if (sessionId || !isInitializeRequest(req.body)) {
63
- res.status(400).json({
64
- jsonrpc: "2.0",
65
- error: { code: -32000, message: "No valid session. Send an initialize request first." },
66
- id: null,
67
- });
68
- return;
69
- }
70
- // New session: fresh server + per-session identity context. Generate the session
71
- // id up front so it is also the trusted-server end-user id. (Distinct name from the
72
- // incoming `sessionId` header above.)
73
- const newSessionId = randomUUID();
74
- transport = new StreamableHTTPServerTransport({
75
- sessionIdGenerator: () => newSessionId,
76
- onsessioninitialized: (sid) => {
77
- transports[sid] = transport;
78
- },
89
+ if (REQUIRE_AUTH && !jwt) {
90
+ res.setHeader("WWW-Authenticate", bearerChallenge(PUBLIC_BASE));
91
+ res.status(401).json({
92
+ jsonrpc: "2.0",
93
+ error: { code: -32001, message: "Authorization required. Complete the OAuth connect." },
94
+ id: null,
95
+ });
96
+ return;
97
+ }
98
+
99
+ if (transport) {
100
+ if (jwt) sessionAuth[sessionId] = jwt;
101
+ await transport.handleRequest(req, res, req.body);
102
+ return;
103
+ }
104
+
105
+ if (sessionId || !isInitializeRequest(req.body)) {
106
+ res.status(400).json({
107
+ jsonrpc: "2.0",
108
+ error: { code: -32000, message: "No valid session. Send an initialize request first." },
109
+ id: null,
79
110
  });
80
- transport.onclose = () => {
81
- if (transport.sessionId) delete transports[transport.sessionId];
82
- };
83
- const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.4" });
84
- registerTools(server, makeWebContext(newSessionId));
85
- await server.connect(transport);
111
+ return;
86
112
  }
87
113
 
114
+ // New session: fresh server + per-session identity context. Generate the session
115
+ // id up front so it is also the trusted-server end-user id. (Distinct name from
116
+ // the incoming `sessionId` header above.)
117
+ const newSessionId = randomUUID();
118
+ if (jwt) sessionAuth[newSessionId] = jwt;
119
+ transport = new StreamableHTTPServerTransport({
120
+ sessionIdGenerator: () => newSessionId,
121
+ onsessioninitialized: (sid) => {
122
+ transports[sid] = transport;
123
+ },
124
+ });
125
+ transport.onclose = () => {
126
+ if (transport.sessionId) {
127
+ delete transports[transport.sessionId];
128
+ delete sessionAuth[transport.sessionId];
129
+ }
130
+ };
131
+ const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.6" });
132
+ registerTools(server, makeWebContext(newSessionId));
133
+ await server.connect(transport);
88
134
  await transport.handleRequest(req, res, req.body);
89
135
  });
90
136
 
@@ -96,6 +142,8 @@ async function handleSessionRequest(req, res) {
96
142
  res.status(400).send("Invalid or missing session id");
97
143
  return;
98
144
  }
145
+ const jwt = bearerOf(req);
146
+ if (jwt) sessionAuth[sessionId] = jwt;
99
147
  await transport.handleRequest(req, res);
100
148
  }
101
149
 
@@ -104,5 +152,7 @@ app.delete("/mcp", handleSessionRequest);
104
152
 
105
153
  app.listen(PORT, () => {
106
154
  // eslint-disable-next-line no-console
107
- console.error(`cloudgrid-mcp web edition listening on :${PORT} (POST /mcp, GET /healthz)`);
155
+ console.error(
156
+ `cloudgrid-mcp web edition listening on :${PORT} (POST /mcp, GET /healthz, OAuth ${REQUIRE_AUTH ? "required" : "optional"})`,
157
+ );
108
158
  });