@cloudgrid-io/mcp 0.2.3 → 0.2.5
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 +3 -1
- package/src/index.js +1 -1
- package/src/oauth.js +226 -0
- package/src/tools.js +52 -2
- package/src/web.js +77 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const ctx = {
|
|
19
19
|
edition: "local",
|
|
20
|
-
state: { pendingLoginCode: null, lastAnonClaim: null },
|
|
20
|
+
state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
|
|
21
21
|
canOpenBrowser: true,
|
|
22
22
|
getToken: async () => (await readCredentials())?.jwt ?? null,
|
|
23
23
|
getActiveOrg: async () => await readActiveOrgSlug(),
|
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
|
@@ -80,7 +80,7 @@ function looksLikeFullHtml(s) {
|
|
|
80
80
|
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
async function runDrop(ctx, { html, path: filePath, filename, anonymous, org }) {
|
|
83
|
+
async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
|
|
84
84
|
let bytes;
|
|
85
85
|
let name;
|
|
86
86
|
let type;
|
|
@@ -129,7 +129,22 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
|
|
|
129
129
|
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
const isAnonymousCall = !headers["Authorization"];
|
|
133
|
+
|
|
134
|
+
// Ownership continuity: replay the platform's anon-session cookie across drops in
|
|
135
|
+
// this session, so cookie-class callers can redrop (and claim) what they dropped.
|
|
136
|
+
if (isAnonymousCall && ctx.state.anonCookie) {
|
|
137
|
+
headers["Cookie"] = ctx.state.anonCookie;
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
const form = new FormData();
|
|
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) {
|
|
146
|
+
form.append("previous_id", ctx.state.lastDrop.entity_id);
|
|
147
|
+
}
|
|
133
148
|
form.append("artifact", new Blob([bytes], { type }), name);
|
|
134
149
|
if (orgSlug) form.append("org_slug", orgSlug);
|
|
135
150
|
|
|
@@ -153,6 +168,15 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
|
|
|
153
168
|
throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
|
|
154
169
|
}
|
|
155
170
|
|
|
171
|
+
// Persist the platform's anon-session cookie for ownership continuity.
|
|
172
|
+
const setCookies = res.headers.getSetCookie
|
|
173
|
+
? res.headers.getSetCookie()
|
|
174
|
+
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
175
|
+
const anonCookie = (setCookies || [])
|
|
176
|
+
.map((c) => (c || "").split(";")[0])
|
|
177
|
+
.find((c) => c.startsWith("cg_anon_session="));
|
|
178
|
+
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
179
|
+
|
|
156
180
|
if (data.owned_by === "authenticated") {
|
|
157
181
|
ctx.state.lastAnonClaim = null;
|
|
158
182
|
const lines = [`Published to your org: ${data.url}`, "Owned by you."];
|
|
@@ -160,6 +184,27 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
|
|
|
160
184
|
return lines.join("\n");
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
// Anonymous: remember the drop for redrop continuity (any 2xx outcome).
|
|
188
|
+
if (data.entity_id || data.url) {
|
|
189
|
+
ctx.state.lastDrop = {
|
|
190
|
+
entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
|
|
191
|
+
url: data.url ?? ctx.state.lastDrop?.url ?? null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (res.status === 202) {
|
|
196
|
+
// Idempotent no-op — the bytes matched the live version exactly.
|
|
197
|
+
return `No change — this exact content is already live: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (res.status === 200) {
|
|
201
|
+
// Updated in place: same URL, new version, views/reactions intact.
|
|
202
|
+
const lines = [`Updated in place — same link: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim()];
|
|
203
|
+
if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 201 — created new (first drop, fresh: true, or the server fell back to create).
|
|
163
208
|
if (data.claim_url) {
|
|
164
209
|
try {
|
|
165
210
|
ctx.state.lastAnonClaim = {
|
|
@@ -174,6 +219,7 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org })
|
|
|
174
219
|
const lines = [`Live: ${data.url}`];
|
|
175
220
|
if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
|
|
176
221
|
if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
|
|
222
|
+
lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
|
|
177
223
|
return lines.join("\n");
|
|
178
224
|
}
|
|
179
225
|
|
|
@@ -234,13 +280,17 @@ export function registerTools(server, ctx) {
|
|
|
234
280
|
// Drop — both editions.
|
|
235
281
|
server.tool(
|
|
236
282
|
"cloudgrid_drop",
|
|
237
|
-
"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.",
|
|
283
|
+
"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. Re-drops in the same session update the existing drop in place — same link, new version; pass fresh: true to force a new one. 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.",
|
|
238
284
|
{
|
|
239
285
|
html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
|
|
240
286
|
path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
|
|
241
287
|
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
242
288
|
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
243
289
|
org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
|
|
290
|
+
fresh: z
|
|
291
|
+
.boolean()
|
|
292
|
+
.optional()
|
|
293
|
+
.describe("Force a new drop even if you already dropped in this session (default: update in place)."),
|
|
244
294
|
},
|
|
245
295
|
async (input) => {
|
|
246
296
|
try {
|
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,24 +20,42 @@ 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) {
|
|
29
52
|
let sessionToken = null;
|
|
30
53
|
return {
|
|
31
54
|
edition: "web",
|
|
32
|
-
state: { pendingLoginCode: null, lastAnonClaim: null },
|
|
55
|
+
state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
|
|
33
56
|
canOpenBrowser: false,
|
|
34
|
-
|
|
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 (!
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
if (transport.sessionId) delete transports[transport.sessionId];
|
|
82
|
-
};
|
|
83
|
-
const server = new McpServer({ name: "cloudgrid-mcp-web", version: "0.2.2" });
|
|
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.5" });
|
|
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(
|
|
155
|
+
console.error(
|
|
156
|
+
`cloudgrid-mcp web edition listening on :${PORT} (POST /mcp, GET /healthz, OAuth ${REQUIRE_AUTH ? "required" : "optional"})`,
|
|
157
|
+
);
|
|
108
158
|
});
|