@productbrain/mcp 0.0.1-beta.951 → 0.0.1-beta.9520

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/dist/http.js CHANGED
@@ -1,21 +1,25 @@
1
1
  import {
2
- DEFAULT_CLOUD_URL,
3
2
  SERVER_VERSION,
4
- bootstrapHttp,
5
3
  createProductBrainServer,
6
- getKeyState,
7
- hashKey,
8
- initFeatureFlags,
9
- runWithAuth
10
- } from "./chunk-ZPK5CSCQ.js";
4
+ initFeatureFlags
5
+ } from "./chunk-HHH3YC56.js";
11
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ apiKeysUrl,
9
+ bootstrapHttp,
10
+ checkKeyAgainstCandidates,
11
+ forgetStreamLifecycle,
12
+ getKeyState,
12
13
  getPostHogClient,
14
+ hashKey,
13
15
  initAnalytics,
16
+ parseFallbackUrls,
17
+ runWithAuth,
14
18
  shutdownAnalytics
15
- } from "./chunk-YMF3IQ5E.js";
19
+ } from "./chunk-GXPFXLOF.js";
16
20
 
17
21
  // src/http.ts
18
- import { createHash, randomUUID as randomUUID2 } from "crypto";
22
+ import { createHash, randomUUID } from "crypto";
19
23
  import express from "express";
20
24
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
25
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -54,61 +58,260 @@ function appLogoMarkup(opts = {}) {
54
58
  return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
55
59
  }
56
60
 
57
- // src/lib/refresh-token.ts
58
- import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
59
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
60
- var PREFIX = "pb_rt_";
61
- var secret = (() => {
62
- const fromEnv = process.env.MCP_REFRESH_SECRET;
63
- if (fromEnv && fromEnv.length > 0) return Buffer.from(fromEnv, "utf8");
64
- if (process.env.NODE_ENV === "production") {
65
- console.warn(
66
- "[HTTP] WARNING MCP_REFRESH_SECRET not set \u2014 refresh tokens will not survive restart"
67
- );
61
+ // src/sessionCap.ts
62
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
63
+ if (cap < 1) {
64
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
68
65
  }
69
- return randomBytes(32);
70
- })();
71
- function sign(payloadB64) {
72
- return createHmac("sha256", secret).update(payloadB64).digest();
73
- }
74
- function signRefreshToken(apiKey) {
75
- const payload = {
76
- k: apiKey,
77
- i: Date.now(),
78
- j: randomUUID()
66
+ const { ttlMs, graceMs } = opts;
67
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
68
+ const keyEntries = [];
69
+ for (const [id, session] of sessions2) {
70
+ if (session.keyHash === keyHash) {
71
+ keyEntries.push([id, session]);
72
+ }
73
+ }
74
+ const evictSet = /* @__PURE__ */ new Set();
75
+ for (const [id, session] of keyEntries) {
76
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
77
+ evictSet.add(id);
78
+ }
79
+ }
80
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
81
+ if (survivors.length >= cap) {
82
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
83
+ for (const [id] of idleSurvivors) {
84
+ if (survivors.length < cap) break;
85
+ evictSet.add(id);
86
+ survivors = survivors.filter(([sid]) => sid !== id);
87
+ }
88
+ }
89
+ const evict = Array.from(evictSet);
90
+ const admit = survivors.length < cap;
91
+ let retryAfterSeconds = 0;
92
+ let retryIsPoll = false;
93
+ if (!admit) {
94
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
95
+ if (graceBlockers.length > 0) {
96
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
97
+ const msRemaining = graceMs - (now - minLastAccess);
98
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
99
+ } else {
100
+ retryAfterSeconds = 5;
101
+ retryIsPoll = true;
102
+ }
103
+ }
104
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
105
+ }
106
+ function countKeySessions(sessions2, keyHash) {
107
+ let count = 0;
108
+ for (const session of sessions2.values()) {
109
+ if (session.keyHash === keyHash) count++;
110
+ }
111
+ return count;
112
+ }
113
+
114
+ // src/httpErrors.ts
115
+ function clampSeconds(s) {
116
+ return Math.max(0, Math.round(s));
117
+ }
118
+ function build(reason, message, p) {
119
+ const seconds = clampSeconds(p.retryAfterSeconds);
120
+ return {
121
+ status: 429,
122
+ headers: { "Retry-After": String(seconds) },
123
+ body: {
124
+ jsonrpc: "2.0",
125
+ id: p.id ?? null,
126
+ error: {
127
+ code: -32e3,
128
+ message,
129
+ data: {
130
+ reason,
131
+ retryAfterSeconds: seconds,
132
+ limit: p.limit,
133
+ current: p.current
134
+ }
135
+ }
136
+ }
79
137
  };
80
- const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
81
- const sigB64 = sign(payloadB64).toString("base64url");
82
- return `${PREFIX}${payloadB64}.${sigB64}`;
83
- }
84
- function verifyRefreshToken(token) {
85
- if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
86
- const body = token.slice(PREFIX.length);
87
- const dot = body.indexOf(".");
88
- if (dot <= 0 || dot === body.length - 1) return null;
89
- const payloadB64 = body.slice(0, dot);
90
- const sigB64 = body.slice(dot + 1);
91
- let providedSig;
92
- try {
93
- providedSig = Buffer.from(sigB64, "base64url");
94
- } catch {
95
- return null;
138
+ }
139
+ function buildRateLimitError(p) {
140
+ const seconds = clampSeconds(p.retryAfterSeconds);
141
+ return build(
142
+ "rate_limited",
143
+ `Too many requests \u2014 retry after ${seconds} s.`,
144
+ p
145
+ );
146
+ }
147
+ function buildAuthLockoutError(p) {
148
+ const seconds = clampSeconds(p.retryAfterSeconds);
149
+ return build(
150
+ "auth_lockout",
151
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
152
+ p
153
+ );
154
+ }
155
+ function buildSessionCapError(p) {
156
+ const seconds = clampSeconds(p.retryAfterSeconds);
157
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
158
+ return build("session_cap", message, p);
159
+ }
160
+ function send429(res, built) {
161
+ if (res.headersSent) return;
162
+ for (const [k, v] of Object.entries(built.headers)) {
163
+ res.setHeader(k, v);
164
+ }
165
+ res.status(built.status).json(built.body);
166
+ }
167
+
168
+ // src/authLockout.ts
169
+ var AUTH_FAILURE_MAX = 10;
170
+ var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
171
+ var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
172
+ var MAX_AUTH_FAILURE_ENTRIES = 1e4;
173
+ function resolveBearerAuth(authorizationHeader) {
174
+ if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
175
+ return { status: "absent" };
176
+ }
177
+ if (!authorizationHeader.startsWith("Bearer ")) {
178
+ return { status: "rejected" };
179
+ }
180
+ const token = authorizationHeader.slice(7).trim();
181
+ if (token === "") {
182
+ return { status: "rejected" };
183
+ }
184
+ if (token.startsWith("pb_sk_")) {
185
+ return { status: "ok", apiKey: token };
96
186
  }
97
- const expectedSig = sign(payloadB64);
98
- if (providedSig.length !== expectedSig.length) return null;
99
- if (!timingSafeEqual(providedSig, expectedSig)) return null;
100
- let payload;
187
+ return { status: "rejected" };
188
+ }
189
+ function checkAuthBlock(failures, ip, now) {
190
+ const rec = failures.get(ip);
191
+ if (!rec) return false;
192
+ return rec.blockedUntil > now;
193
+ }
194
+ function recordAuthFailure(failures, ip, now) {
195
+ const rec = failures.get(ip);
196
+ if (!rec) {
197
+ failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
198
+ return;
199
+ }
200
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
201
+ rec.count = 1;
202
+ rec.firstFailure = now;
203
+ rec.blockedUntil = 0;
204
+ } else {
205
+ rec.count++;
206
+ if (rec.count >= AUTH_FAILURE_MAX) {
207
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
208
+ }
209
+ }
210
+ }
211
+ function initializeKeyGate(verdict, failures, ip, now) {
212
+ if (verdict === "ok" || verdict === "rate_limited") return "admit";
213
+ if (verdict === "rejected") {
214
+ recordAuthFailure(failures, ip, now);
215
+ return "unauthorized";
216
+ }
217
+ return "unavailable";
218
+ }
219
+
220
+ // src/lib/oauthRedirectAllowlist.ts
221
+ var DEFAULT_OAUTH_REDIRECT_URIS = [
222
+ "https://claude.ai/api/mcp/auth_callback",
223
+ "https://claude.com/api/mcp/auth_callback",
224
+ "https://chatgpt.com/connector_platform_oauth_redirect"
225
+ ];
226
+ var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
227
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
228
+ var MAX_REDIRECT_URI_LENGTH = 2048;
229
+ var MAX_REGISTRATION_REDIRECTS = 10;
230
+ var OPERATOR_ENTRY_FORM = "OAUTH_REDIRECT_HOST_ALLOWLIST entries must be full https redirect URIs such as https://partner.example/oauth/callback (no bare host, no query, no fragment)";
231
+ function exactForm(url) {
232
+ return `${url.origin}${url.pathname}`;
233
+ }
234
+ function parseRedirectUriAllowlist(raw) {
235
+ if (!raw) return { uris: [], configError: null };
236
+ const uris = [];
237
+ for (const part of raw.split(",")) {
238
+ const trimmed = part.trim();
239
+ if (!trimmed) continue;
240
+ let url = null;
241
+ try {
242
+ url = trimmed.includes("://") ? new URL(trimmed) : null;
243
+ } catch {
244
+ url = null;
245
+ }
246
+ const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
247
+ if (!valid || url === null) {
248
+ return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
249
+ }
250
+ uris.push(exactForm(url));
251
+ }
252
+ return { uris, configError: null };
253
+ }
254
+ function resolveOAuthRedirectAllowlist(opts = {}) {
255
+ const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
256
+ return {
257
+ uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
258
+ configError: parsed.configError
259
+ };
260
+ }
261
+ function checkRedirectUri(redirectUri, allowlist) {
262
+ if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
263
+ if (typeof redirectUri !== "string" || redirectUri.length === 0) {
264
+ return "redirect_uri is required";
265
+ }
266
+ if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
267
+ return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
268
+ }
269
+ let url;
101
270
  try {
102
- const json = Buffer.from(payloadB64, "base64url").toString("utf8");
103
- payload = JSON.parse(json);
271
+ url = new URL(redirectUri);
104
272
  } catch {
105
- return null;
273
+ return "redirect_uri is not a valid absolute URL";
274
+ }
275
+ if (url.username || url.password) return "redirect_uri must not contain credentials";
276
+ if (url.hash) return "redirect_uri must not contain a fragment";
277
+ const host = url.hostname.toLowerCase();
278
+ if (url.protocol === "http:") {
279
+ return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
280
+ }
281
+ if (url.protocol === "https:") {
282
+ if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
283
+ if (url.search) return "redirect_uri must not contain a query";
284
+ const exact = exactForm(url);
285
+ return allowlist.uris.includes(exact) || CHATGPT_CONNECTOR_CALLBACK.test(exact) ? null : "redirect_uri is not an allowed callback (an operator can add the full URI with OAUTH_REDIRECT_HOST_ALLOWLIST)";
106
286
  }
107
- if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
108
- return null;
287
+ return "redirect_uri scheme is not allowed (use https, or http on loopback)";
288
+ }
289
+ function checkRegistrationRedirects(redirectUris, allowlist) {
290
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
291
+ if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
292
+ return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
293
+ }
294
+ for (const uri of redirectUris) {
295
+ const reason = checkRedirectUri(uri, allowlist);
296
+ if (reason) return reason;
109
297
  }
110
- if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
111
- return { apiKey: payload.k };
298
+ return null;
299
+ }
300
+
301
+ // src/lib/oauthAuthCode.ts
302
+ function pendingCodeRefusal(pending, redirectUri, now) {
303
+ if (!pending) return "unknown_code";
304
+ if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
305
+ if (now > pending.expiresAt) return "expired";
306
+ return null;
307
+ }
308
+ function tokenResponse(apiKey) {
309
+ return {
310
+ access_token: apiKey,
311
+ token_type: "Bearer",
312
+ // 1 year: validity is enforced by Convex on every call, not by this clock.
313
+ expires_in: 365 * 24 * 3600
314
+ };
112
315
  }
113
316
 
114
317
  // src/http.ts
@@ -116,6 +319,10 @@ bootstrapHttp();
116
319
  initAnalytics();
117
320
  initFeatureFlags(getPostHogClient());
118
321
  var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
322
+ function keyCheckCandidates() {
323
+ const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
324
+ return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
325
+ }
119
326
  function baseUrl(req) {
120
327
  const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
121
328
  const host = req.headers.host ?? `localhost:${PORT}`;
@@ -125,6 +332,7 @@ var app = express();
125
332
  app.set("trust proxy", 1);
126
333
  app.use(express.json());
127
334
  var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
335
+ var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
128
336
  app.use((_req, res, next) => {
129
337
  const origin = _req.headers.origin;
130
338
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
@@ -135,7 +343,7 @@ app.use((_req, res, next) => {
135
343
  "Access-Control-Allow-Headers",
136
344
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
137
345
  );
138
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
346
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
139
347
  if (_req.method === "OPTIONS") {
140
348
  res.status(204).end();
141
349
  return;
@@ -159,7 +367,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
159
367
  token_endpoint: `${base}/oauth/token`,
160
368
  registration_endpoint: `${base}/register`,
161
369
  response_types_supported: ["code"],
162
- grant_types_supported: ["authorization_code", "refresh_token"],
370
+ grant_types_supported: ["authorization_code"],
371
+ // TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
163
372
  code_challenge_methods_supported: ["S256"],
164
373
  token_endpoint_auth_methods_supported: ["none"],
165
374
  scopes_supported: ["mcp:tools", "mcp:resources"]
@@ -187,14 +396,12 @@ app.post(
187
396
  return;
188
397
  }
189
398
  const { redirect_uris, client_name } = req.body;
190
- if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
191
- res.status(400).json({
192
- error: "invalid_client_metadata",
193
- error_description: "redirect_uris is required"
194
- });
399
+ const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
400
+ if (refusal) {
401
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
195
402
  return;
196
403
  }
197
- const clientId = `pb_client_${randomUUID2()}`;
404
+ const clientId = `pb_client_${randomUUID()}`;
198
405
  const client = {
199
406
  client_id: clientId,
200
407
  redirect_uris,
@@ -213,9 +420,6 @@ app.post(
213
420
  }
214
421
  );
215
422
  var pendingCodes = /* @__PURE__ */ new Map();
216
- var ACCESS_TOKEN_TTL = 3600;
217
- var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
218
- var accessTokens = /* @__PURE__ */ new Map();
219
423
  setInterval(() => {
220
424
  const now = Date.now();
221
425
  for (const [code, auth] of pendingCodes) {
@@ -224,9 +428,6 @@ setInterval(() => {
224
428
  for (const [id, client] of registeredClients) {
225
429
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
226
430
  }
227
- for (const [token, entry] of accessTokens) {
228
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
229
- }
230
431
  for (const [ip, rec] of authFailures) {
231
432
  if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
232
433
  authFailures.delete(ip);
@@ -609,7 +810,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
609
810
  <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
610
811
  <div class="err-actions">
611
812
  <a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>&larr; Try again</a>
612
- <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
813
+ <a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
613
814
  </div>`;
614
815
  }
615
816
  var cmdScript = `
@@ -657,7 +858,7 @@ function authorizeFormPage(params) {
657
858
  <div class="hint" id="hint" hidden></div>
658
859
  <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
659
860
  </form>
660
- <div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
861
+ <div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
661
862
  </div>
662
863
 
663
864
  <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
@@ -796,6 +997,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
796
997
  }
797
998
  app.get("/authorize", authLimiter, (req, res) => {
798
999
  const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
1000
+ const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1001
+ if (refusal) {
1002
+ res.status(400).json({ error: "invalid_request", error_description: refusal });
1003
+ return;
1004
+ }
799
1005
  const cid = String(client_id ?? "");
800
1006
  const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
801
1007
  res.type("html").send(authorizeFormPage({
@@ -837,28 +1043,22 @@ app.post(
837
1043
  return;
838
1044
  }
839
1045
  if (!client_id) {
840
- res.status(400).json({
841
- error: "invalid_request",
842
- error_description: "client_id is required"
843
- });
1046
+ res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
1047
+ return;
1048
+ }
1049
+ const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1050
+ if (redirectRefusal) {
1051
+ res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
844
1052
  return;
845
1053
  }
846
1054
  if (!registeredClients.has(client_id)) {
847
- if (typeof redirect_uri === "string" && redirect_uri.startsWith("https://")) {
848
- registeredClients.set(client_id, {
849
- client_id,
850
- redirect_uris: [redirect_uri],
851
- registeredAt: Date.now()
852
- });
853
- process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
854
- `);
855
- } else {
856
- res.status(400).json({
857
- error: "invalid_request",
858
- error_description: "Unknown client_id and redirect_uri is not a valid https URL"
859
- });
1055
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
1056
+ res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
860
1057
  return;
861
1058
  }
1059
+ registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
1060
+ process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
1061
+ `);
862
1062
  }
863
1063
  const client = registeredClients.get(client_id);
864
1064
  if (!client.redirect_uris.includes(redirect_uri)) {
@@ -869,48 +1069,28 @@ app.post(
869
1069
  return;
870
1070
  }
871
1071
  let workspaceName = "Your Workspace";
872
- try {
873
- const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
874
- const fallbackUrls = (process.env.CONVEX_FALLBACK_URLS ?? "").split(",").map((u) => u.trim().replace(/\/$/, "")).filter(Boolean);
875
- const candidates = [primaryUrl, ...fallbackUrls];
876
- let foundUrl;
877
- let anyDefinitiveReject = false;
878
- for (const url2 of candidates) {
879
- let checkData = null;
880
- try {
881
- const checkRes = await fetch(`${url2}/api/key-check`, {
882
- method: "POST",
883
- headers: { "Authorization": `Bearer ${api_key}`, "Content-Type": "application/json" },
884
- signal: AbortSignal.timeout(5e3)
885
- });
886
- checkData = await checkRes.json();
887
- } catch {
888
- continue;
889
- }
890
- if (checkData.ok) {
891
- if (checkData.workspaceName) workspaceName = checkData.workspaceName;
892
- foundUrl = checkData.deploymentUrl ?? url2;
893
- break;
894
- }
895
- anyDefinitiveReject = true;
896
- }
897
- if (!foundUrl) {
898
- if (anyDefinitiveReject) {
899
- sendError(
900
- "Key not recognized",
901
- "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
902
- 401
903
- );
904
- return;
905
- }
906
- process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
907
- } else {
908
- getKeyState(api_key).deploymentUrl = foundUrl;
909
- }
910
- } catch {
1072
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
1073
+ if (check.verdict === "ok") {
1074
+ if (check.workspaceName) workspaceName = check.workspaceName;
1075
+ getKeyState(api_key).deploymentUrl = check.deploymentUrl;
1076
+ } else if (check.verdict === "rate_limited") {
1077
+ sendError(
1078
+ "Rate limited",
1079
+ `This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
1080
+ 429
1081
+ );
1082
+ return;
1083
+ } else if (check.verdict === "rejected") {
1084
+ sendError(
1085
+ "Key not recognized",
1086
+ "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
1087
+ 401
1088
+ );
1089
+ return;
1090
+ } else {
911
1091
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
912
1092
  }
913
- const code = randomUUID2();
1093
+ const code = randomUUID();
914
1094
  pendingCodes.set(code, {
915
1095
  apiKey: api_key,
916
1096
  codeChallenge: code_challenge,
@@ -929,41 +1109,25 @@ app.post(
929
1109
  }
930
1110
  }
931
1111
  );
932
- function issueTokens(apiKey) {
933
- return {
934
- access_token: apiKey,
935
- token_type: "Bearer",
936
- // 1-year TTL: actual validity enforced by Convex, not by expiry clock.
937
- // Long TTL prevents unnecessary refresh cycles after restarts.
938
- expires_in: 365 * 24 * 3600,
939
- refresh_token: signRefreshToken(apiKey)
940
- };
941
- }
942
1112
  app.post(
943
1113
  "/oauth/token",
944
1114
  authLimiter,
945
1115
  express.urlencoded({ extended: false }),
946
1116
  express.json(),
947
1117
  (req, res) => {
948
- const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
949
- if (grant_type === "refresh_token") {
950
- const verified = verifyRefreshToken(refresh_token);
951
- if (!verified) {
952
- res.status(400).json({
953
- error: "invalid_grant",
954
- error_description: "Invalid or expired refresh token"
955
- });
956
- return;
957
- }
958
- res.json(issueTokens(verified.apiKey));
959
- return;
960
- }
1118
+ const { grant_type, code, code_verifier, redirect_uri } = req.body;
961
1119
  if (grant_type !== "authorization_code") {
962
1120
  res.status(400).json({ error: "unsupported_grant_type" });
963
1121
  return;
964
1122
  }
965
1123
  const pending = pendingCodes.get(code);
966
- if (!pending || pending.redirectUri !== redirect_uri) {
1124
+ const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
1125
+ if (codeRefusal === "expired") {
1126
+ pendingCodes.delete(code);
1127
+ res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
1128
+ return;
1129
+ }
1130
+ if (codeRefusal || !pending) {
967
1131
  res.status(400).json({ error: "invalid_grant" });
968
1132
  return;
969
1133
  }
@@ -977,7 +1141,7 @@ app.post(
977
1141
  return;
978
1142
  }
979
1143
  pendingCodes.delete(code);
980
- res.json(issueTokens(pending.apiKey));
1144
+ res.json(tokenResponse(pending.apiKey));
981
1145
  }
982
1146
  );
983
1147
  var mcpLimiter = rateLimit({
@@ -985,85 +1149,63 @@ var mcpLimiter = rateLimit({
985
1149
  max: 120,
986
1150
  standardHeaders: true,
987
1151
  legacyHeaders: false,
988
- message: { error: "Too many requests. Try again later." }
1152
+ handler: (req, res) => {
1153
+ const r = req.rateLimit;
1154
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1155
+ send429(res, buildRateLimitError({
1156
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1157
+ limit: r?.limit ?? 120,
1158
+ current: r?.used ?? 0,
1159
+ id: req.body?.id ?? null
1160
+ // mirror the JSON-RPC id when a POST body is present
1161
+ }));
1162
+ }
989
1163
  });
990
1164
  var authFailures = /* @__PURE__ */ new Map();
991
- var AUTH_FAILURE_MAX = 10;
992
- var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
993
- var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
994
- var MAX_AUTH_FAILURE_ENTRIES = 1e4;
995
- function checkAuthBlock(ip) {
996
- const rec = authFailures.get(ip);
997
- if (!rec) return false;
998
- return rec.blockedUntil > Date.now();
999
- }
1000
- function recordAuthFailure(ip) {
1001
- const now = Date.now();
1002
- const rec = authFailures.get(ip);
1003
- if (!rec) {
1004
- authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
1005
- return;
1006
- }
1007
- if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
1008
- rec.count = 1;
1009
- rec.firstFailure = now;
1010
- rec.blockedUntil = 0;
1011
- } else {
1012
- rec.count++;
1013
- if (rec.count >= AUTH_FAILURE_MAX) {
1014
- rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
1015
- }
1016
- }
1017
- }
1018
1165
  app.get("/health", (_req, res) => {
1019
1166
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
1020
1167
  });
1021
1168
  var sessions = /* @__PURE__ */ new Map();
1022
1169
  var SESSION_TTL_MS = 30 * 60 * 1e3;
1023
1170
  var MAX_SESSIONS = 200;
1024
- var MAX_SESSIONS_PER_KEY = 5;
1171
+ var EVICT_GRACE_MS = 6e4;
1172
+ var MAX_SESSIONS_PER_KEY = Math.max(
1173
+ 1,
1174
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1175
+ );
1025
1176
  function evictStaleSessions() {
1026
1177
  const now = Date.now();
1027
1178
  for (const [id, entry] of sessions) {
1028
1179
  if (now - entry.lastAccess > SESSION_TTL_MS) {
1029
- logSessionLifecycle("session_deleted", id, "ttl");
1180
+ logSessionLifecycle(
1181
+ "session_deleted",
1182
+ id,
1183
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1184
+ );
1030
1185
  entry.transport.close().catch(() => {
1031
1186
  });
1032
1187
  sessions.delete(id);
1033
1188
  }
1034
1189
  }
1035
1190
  if (sessions.size > MAX_SESSIONS) {
1036
- const sorted = [...sessions.entries()].sort(
1037
- (a, b) => a[1].lastAccess - b[1].lastAccess
1038
- );
1039
- for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
1191
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1192
+ const overflow = sessions.size - MAX_SESSIONS;
1193
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
1040
1194
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
1041
1195
  sorted[i][1].transport.close().catch(() => {
1042
1196
  });
1043
1197
  sessions.delete(sorted[i][0]);
1044
1198
  }
1045
- }
1046
- }
1047
- setInterval(evictStaleSessions, 6e4);
1048
- function extractBearerKey(req) {
1049
- const header = req.headers?.authorization;
1050
- if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
1051
- const token = header.slice(7).trim();
1052
- if (token.startsWith("pb_sk_")) {
1053
- return token;
1054
- }
1055
- if (token.startsWith("pb_at_")) {
1056
- const entry = accessTokens.get(token);
1057
- if (!entry) return null;
1058
- const now = Date.now();
1059
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
1060
- accessTokens.delete(token);
1061
- return null;
1199
+ if (sessions.size > MAX_SESSIONS) {
1200
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1201
+ process.stderr.write(
1202
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1203
+ `
1204
+ );
1062
1205
  }
1063
- return entry.apiKey;
1064
1206
  }
1065
- return null;
1066
1207
  }
1208
+ setInterval(evictStaleSessions, 6e4);
1067
1209
  function send401(req, res) {
1068
1210
  const base = baseUrl(req);
1069
1211
  res.status(401).set(
@@ -1086,21 +1228,25 @@ function logSessionLifecycle(event, sessionId, reason) {
1086
1228
  }
1087
1229
  app.post("/mcp", mcpLimiter, async (req, res) => {
1088
1230
  const reqIp = req.ip ?? "unknown";
1089
- if (checkAuthBlock(reqIp)) {
1090
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1231
+ const nowTs = Date.now();
1232
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1233
+ const rec = authFailures.get(reqIp);
1234
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1235
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1091
1236
  return;
1092
1237
  }
1093
- const apiKey = extractBearerKey(req);
1094
- if (!apiKey) {
1238
+ const auth = resolveBearerAuth(req.headers?.authorization);
1239
+ if (auth.status !== "ok") {
1095
1240
  logRequest("POST", "auth_fail");
1096
- recordAuthFailure(reqIp);
1241
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1097
1242
  send401(req, res);
1098
1243
  return;
1099
1244
  }
1245
+ const apiKey = auth.apiKey;
1100
1246
  const sessionId = req.headers["mcp-session-id"];
1101
1247
  const reqStart = Date.now();
1102
1248
  try {
1103
- await runWithAuth({ apiKey }, async () => {
1249
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1104
1250
  if (sessionId && sessions.has(sessionId)) {
1105
1251
  const entry = sessions.get(sessionId);
1106
1252
  if (entry.keyHash !== hashKey(apiKey)) {
@@ -1111,41 +1257,98 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1111
1257
  });
1112
1258
  return;
1113
1259
  }
1114
- entry.lastAccess = Date.now();
1115
- await entry.transport.handleRequest(req, res, req.body);
1116
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1260
+ try {
1261
+ entry.inFlight++;
1262
+ entry.lastAccess = Date.now();
1263
+ await entry.transport.handleRequest(req, res, req.body);
1264
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1265
+ } finally {
1266
+ entry.inFlight--;
1267
+ entry.lastAccess = Date.now();
1268
+ }
1117
1269
  } else if (!sessionId && isInitializeRequest(req.body)) {
1118
- const keyH = hashKey(apiKey);
1119
- let keySessionCount = 0;
1120
- for (const entry of sessions.values()) {
1121
- if (entry.keyHash === keyH) keySessionCount++;
1270
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
1271
+ const gate = initializeKeyGate(check.verdict, authFailures, reqIp, Date.now());
1272
+ if (gate === "unauthorized") {
1273
+ logRequest("POST", "auth_fail");
1274
+ send401(req, res);
1275
+ return;
1122
1276
  }
1123
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
1124
- res.status(429).json({
1277
+ if (gate === "unavailable") {
1278
+ res.status(503).json({
1125
1279
  jsonrpc: "2.0",
1126
- error: { code: -32e3, message: "Too many sessions for this API key" },
1127
- id: null
1280
+ error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
1281
+ id: req.body?.id ?? null
1128
1282
  });
1129
1283
  return;
1130
1284
  }
1285
+ if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
1286
+ const keyH = hashKey(apiKey);
1287
+ const plan = planKeyAdmission(
1288
+ sessions,
1289
+ keyH,
1290
+ MAX_SESSIONS_PER_KEY,
1291
+ Date.now(),
1292
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1293
+ );
1294
+ for (const sid2 of plan.evict) {
1295
+ const victim = sessions.get(sid2);
1296
+ sessions.delete(sid2);
1297
+ victim?.transport.close().catch(() => {
1298
+ });
1299
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1300
+ }
1301
+ if (!plan.admit) {
1302
+ send429(res, buildSessionCapError({
1303
+ retryAfterSeconds: plan.retryAfterSeconds,
1304
+ limit: MAX_SESSIONS_PER_KEY,
1305
+ current: countKeySessions(sessions, keyH),
1306
+ poll: plan.retryIsPoll,
1307
+ id: req.body?.id ?? null
1308
+ }));
1309
+ return;
1310
+ }
1311
+ const sid = randomUUID();
1312
+ let initialized = false;
1131
1313
  const transport = new StreamableHTTPServerTransport({
1132
- sessionIdGenerator: () => randomUUID2(),
1133
- onsessioninitialized: (sid) => {
1134
- sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
1135
- logSessionLifecycle("session_created", sid);
1314
+ sessionIdGenerator: () => sid,
1315
+ onsessioninitialized: (s) => {
1316
+ initialized = true;
1317
+ logSessionLifecycle("session_created", s);
1136
1318
  }
1319
+ // log only — no map write
1137
1320
  });
1321
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1322
+ sessions.set(sid, reserved);
1138
1323
  transport.onclose = () => {
1139
- const sid = transport.sessionId;
1140
- if (sid) {
1141
- logSessionLifecycle("session_deleted", sid, "onclose");
1142
- sessions.delete(sid);
1324
+ const s = transport.sessionId ?? sid;
1325
+ forgetStreamLifecycle(keyH, s);
1326
+ if (sessions.has(s)) {
1327
+ sessions.delete(s);
1328
+ logSessionLifecycle("session_deleted", s, "onclose");
1143
1329
  }
1144
1330
  };
1145
- const server = createProductBrainServer();
1146
- await server.connect(transport);
1147
- await transport.handleRequest(req, res, req.body);
1148
- logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1331
+ try {
1332
+ const server = createProductBrainServer();
1333
+ await server.connect(transport);
1334
+ await transport.handleRequest(req, res, req.body);
1335
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1336
+ } catch (e) {
1337
+ transport.onclose = void 0;
1338
+ sessions.delete(sid);
1339
+ throw e;
1340
+ } finally {
1341
+ if (!initialized) {
1342
+ transport.onclose = void 0;
1343
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1344
+ } else {
1345
+ const e = sessions.get(sid);
1346
+ if (e) {
1347
+ e.inFlight--;
1348
+ e.lastAccess = Date.now();
1349
+ }
1350
+ }
1351
+ }
1149
1352
  } else if (sessionId) {
1150
1353
  process.stderr.write(
1151
1354
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
@@ -1181,17 +1384,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1181
1384
  });
1182
1385
  app.get("/mcp", mcpLimiter, async (req, res) => {
1183
1386
  const reqIp = req.ip ?? "unknown";
1184
- if (checkAuthBlock(reqIp)) {
1185
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1387
+ const nowTs = Date.now();
1388
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1389
+ const rec = authFailures.get(reqIp);
1390
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1391
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1186
1392
  return;
1187
1393
  }
1188
- const apiKey = extractBearerKey(req);
1189
- if (!apiKey) {
1394
+ const auth = resolveBearerAuth(req.headers?.authorization);
1395
+ if (auth.status !== "ok") {
1190
1396
  logRequest("GET", "auth_fail");
1191
- recordAuthFailure(reqIp);
1397
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1192
1398
  send401(req, res);
1193
1399
  return;
1194
1400
  }
1401
+ const apiKey = auth.apiKey;
1195
1402
  const sessionId = req.headers["mcp-session-id"];
1196
1403
  if (!sessionId) {
1197
1404
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1206,7 +1413,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1206
1413
  return;
1207
1414
  }
1208
1415
  try {
1209
- await runWithAuth({ apiKey }, async () => {
1416
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1210
1417
  const entry = sessions.get(sessionId);
1211
1418
  if (entry.keyHash !== hashKey(apiKey)) {
1212
1419
  res.status(403).json({
@@ -1226,17 +1433,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1226
1433
  });
1227
1434
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1228
1435
  const reqIp = req.ip ?? "unknown";
1229
- if (checkAuthBlock(reqIp)) {
1230
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1436
+ const nowTs = Date.now();
1437
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1438
+ const rec = authFailures.get(reqIp);
1439
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1440
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1231
1441
  return;
1232
1442
  }
1233
- const apiKey = extractBearerKey(req);
1234
- if (!apiKey) {
1443
+ const auth = resolveBearerAuth(req.headers?.authorization);
1444
+ if (auth.status !== "ok") {
1235
1445
  logRequest("DELETE", "auth_fail");
1236
- recordAuthFailure(reqIp);
1446
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1237
1447
  send401(req, res);
1238
1448
  return;
1239
1449
  }
1450
+ const apiKey = auth.apiKey;
1240
1451
  const sessionId = req.headers["mcp-session-id"];
1241
1452
  if (!sessionId) {
1242
1453
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1251,7 +1462,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
1251
1462
  return;
1252
1463
  }
1253
1464
  try {
1254
- await runWithAuth({ apiKey }, async () => {
1465
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1255
1466
  const entry = sessions.get(sessionId);
1256
1467
  if (entry.keyHash !== hashKey(apiKey)) {
1257
1468
  res.status(403).json({