@cloudgrid-io/mcp 0.21.2 → 0.21.4

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.21.2",
3
+ "version": "0.21.4",
4
4
  "description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,19 @@ function isTokenExpired(jwt, now) {
5
5
  return decodeJwt(jwt).exp * 1000 <= now();
6
6
  }
7
7
 
8
+ // Is this token a credential the server can actually authenticate with right
9
+ // now? A present, decodable token whose numeric `exp` is still in the future.
10
+ // Anything else — absent, undecodable, or lacking a real expiry — is unusable.
11
+ // Unlike isTokenExpired (which treats an undecodable token as "not expired"
12
+ // because NaN <= now is false), this answers the transport guard's question,
13
+ // where a token we cannot vouch for must not pass.
14
+ function isCredentialUsable(jwt, now) {
15
+ if (!jwt) return false;
16
+ const { exp } = decodeJwt(jwt);
17
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
18
+ return exp * 1000 > now();
19
+ }
20
+
8
21
  export function createWebIdentity({
9
22
  initialTransportToken = null,
10
23
  now = () => Date.now(),
@@ -33,6 +46,15 @@ export function createWebIdentity({
33
46
  return { identityChanged };
34
47
  },
35
48
 
49
+ // Whether the session currently holds any credential the server can use:
50
+ // the effective token (explicit login shadowing transport per #279) is
51
+ // present, decodable, and unexpired. The transport guard asks this before
52
+ // accepting a request, so an expired/invalid Bearer with no valid explicit
53
+ // login is challenged rather than degrading to an in-band login link.
54
+ hasUsableCredential() {
55
+ return isCredentialUsable(effectiveToken(), now);
56
+ },
57
+
36
58
  async getToken() {
37
59
  const jwt = effectiveToken();
38
60
  return isTokenExpired(jwt, now) ? null : jwt;
package/src/web.js CHANGED
@@ -55,8 +55,9 @@ function bearerOf(req) {
55
55
 
56
56
  // A web session: identity lives in memory for the session's lifetime only. The
57
57
  // session id doubles as the stable, opaque end-user id for the trusted-server cap.
58
- function makeWebContext(sessionId, initialTransportToken = null) {
59
- const identity = createWebIdentity({ initialTransportToken });
58
+ // The identity is built by the caller so the transport auth guard can consult it
59
+ // (hasUsableCredential) before the session is wired up.
60
+ function makeWebContext(sessionId, identity) {
60
61
  return {
61
62
  edition: "web",
62
63
  state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
@@ -89,6 +90,19 @@ mountOAuth(app, PUBLIC_BASE, { requireAuth: REQUIRE_AUTH });
89
90
  const transports = Object.create(null);
90
91
  const sessionContexts = Object.create(null);
91
92
 
93
+ // Per the MCP authorization spec, a resource server that receives no usable
94
+ // credential answers 401 with a Bearer WWW-Authenticate challenge — the signal
95
+ // Claude web needs to re-run OAuth and render its native login UI. Reused for
96
+ // the missing, expired, and invalid transport-token cases.
97
+ function sendAuthChallenge(res) {
98
+ res.setHeader("WWW-Authenticate", bearerChallenge(PUBLIC_BASE));
99
+ res.status(401).json({
100
+ jsonrpc: "2.0",
101
+ error: { code: -32001, message: "Authorization required. Complete the OAuth connect." },
102
+ id: null,
103
+ });
104
+ }
105
+
92
106
  function captureRequestIdentity(sessionId, jwt) {
93
107
  if (!jwt) return;
94
108
  const ctx = sessionContexts[sessionId];
@@ -107,17 +121,22 @@ app.post("/mcp", async (req, res) => {
107
121
  const jwt = bearerOf(req);
108
122
 
109
123
  if (REQUIRE_AUTH && !jwt) {
110
- res.setHeader("WWW-Authenticate", bearerChallenge(PUBLIC_BASE));
111
- res.status(401).json({
112
- jsonrpc: "2.0",
113
- error: { code: -32001, message: "Authorization required. Complete the OAuth connect." },
114
- id: null,
115
- });
124
+ sendAuthChallenge(res);
116
125
  return;
117
126
  }
118
127
 
119
128
  if (transport) {
120
129
  captureRequestIdentity(sessionId, jwt);
130
+ // An expired/invalid transport token with no valid explicit login means the
131
+ // session holds no usable credential — challenge so the client re-runs OAuth,
132
+ // rather than proceeding and degrading to an in-band copy-paste login link
133
+ // (#286). Consult the identity, not the raw Bearer: an explicit in-session
134
+ // login stays authoritative over an expired transport token (#279/#280).
135
+ const ctx = sessionContexts[sessionId];
136
+ if (REQUIRE_AUTH && ctx && !ctx.identity.hasUsableCredential()) {
137
+ sendAuthChallenge(res);
138
+ return;
139
+ }
121
140
  await transport.handleRequest(req, res, req.body);
122
141
  return;
123
142
  }
@@ -135,6 +154,13 @@ app.post("/mcp", async (req, res) => {
135
154
  // id up front so it is also the trusted-server end-user id. (Distinct name from
136
155
  // the incoming `sessionId` header above.)
137
156
  const newSessionId = randomUUID();
157
+ const identity = createWebIdentity({ initialTransportToken: jwt });
158
+ // A brand-new session has no explicit login yet, so the transport Bearer is the
159
+ // only possible credential — challenge if it is expired or undecodable (#286).
160
+ if (REQUIRE_AUTH && !identity.hasUsableCredential()) {
161
+ sendAuthChallenge(res);
162
+ return;
163
+ }
138
164
  transport = new StreamableHTTPServerTransport({
139
165
  sessionIdGenerator: () => newSessionId,
140
166
  onsessioninitialized: (sid) => {
@@ -151,7 +177,7 @@ app.post("/mcp", async (req, res) => {
151
177
  }
152
178
  };
153
179
  const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
154
- const webCtx = makeWebContext(newSessionId, jwt);
180
+ const webCtx = makeWebContext(newSessionId, identity);
155
181
  sessionContexts[newSessionId] = webCtx;
156
182
  // QA session log for this MCP session (dark by default). Keyed by the session
157
183
  // id — the host connection boundary. No seed user_request here: current hosts