@productbrain/mcp 0.0.1-beta.945 → 0.0.1-beta.9450

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,18 +1,20 @@
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-VHL3Q5K6.js";
4
+ initFeatureFlags
5
+ } from "./chunk-BCYOMOD2.js";
11
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ apiKeysUrl,
9
+ bootstrapHttp,
10
+ forgetStreamLifecycle,
11
+ getKeyState,
12
12
  getPostHogClient,
13
+ hashKey,
13
14
  initAnalytics,
15
+ runWithAuth,
14
16
  shutdownAnalytics
15
- } from "./chunk-YMF3IQ5E.js";
17
+ } from "./chunk-DKJ4X2D7.js";
16
18
 
17
19
  // src/http.ts
18
20
  import { createHash, randomUUID as randomUUID2 } from "crypto";
@@ -111,6 +113,255 @@ function verifyRefreshToken(token) {
111
113
  return { apiKey: payload.k };
112
114
  }
113
115
 
116
+ // src/sessionCap.ts
117
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
118
+ if (cap < 1) {
119
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
120
+ }
121
+ const { ttlMs, graceMs } = opts;
122
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
123
+ const keyEntries = [];
124
+ for (const [id, session] of sessions2) {
125
+ if (session.keyHash === keyHash) {
126
+ keyEntries.push([id, session]);
127
+ }
128
+ }
129
+ const evictSet = /* @__PURE__ */ new Set();
130
+ for (const [id, session] of keyEntries) {
131
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
132
+ evictSet.add(id);
133
+ }
134
+ }
135
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
136
+ if (survivors.length >= cap) {
137
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
138
+ for (const [id] of idleSurvivors) {
139
+ if (survivors.length < cap) break;
140
+ evictSet.add(id);
141
+ survivors = survivors.filter(([sid]) => sid !== id);
142
+ }
143
+ }
144
+ const evict = Array.from(evictSet);
145
+ const admit = survivors.length < cap;
146
+ let retryAfterSeconds = 0;
147
+ let retryIsPoll = false;
148
+ if (!admit) {
149
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
150
+ if (graceBlockers.length > 0) {
151
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
152
+ const msRemaining = graceMs - (now - minLastAccess);
153
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
154
+ } else {
155
+ retryAfterSeconds = 5;
156
+ retryIsPoll = true;
157
+ }
158
+ }
159
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
160
+ }
161
+ function countKeySessions(sessions2, keyHash) {
162
+ let count = 0;
163
+ for (const session of sessions2.values()) {
164
+ if (session.keyHash === keyHash) count++;
165
+ }
166
+ return count;
167
+ }
168
+
169
+ // src/httpErrors.ts
170
+ function clampSeconds(s) {
171
+ return Math.max(0, Math.round(s));
172
+ }
173
+ function build(reason, message, p) {
174
+ const seconds = clampSeconds(p.retryAfterSeconds);
175
+ return {
176
+ status: 429,
177
+ headers: { "Retry-After": String(seconds) },
178
+ body: {
179
+ jsonrpc: "2.0",
180
+ id: p.id ?? null,
181
+ error: {
182
+ code: -32e3,
183
+ message,
184
+ data: {
185
+ reason,
186
+ retryAfterSeconds: seconds,
187
+ limit: p.limit,
188
+ current: p.current
189
+ }
190
+ }
191
+ }
192
+ };
193
+ }
194
+ function buildRateLimitError(p) {
195
+ const seconds = clampSeconds(p.retryAfterSeconds);
196
+ return build(
197
+ "rate_limited",
198
+ `Too many requests \u2014 retry after ${seconds} s.`,
199
+ p
200
+ );
201
+ }
202
+ function buildAuthLockoutError(p) {
203
+ const seconds = clampSeconds(p.retryAfterSeconds);
204
+ return build(
205
+ "auth_lockout",
206
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
207
+ p
208
+ );
209
+ }
210
+ function buildSessionCapError(p) {
211
+ const seconds = clampSeconds(p.retryAfterSeconds);
212
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
213
+ return build("session_cap", message, p);
214
+ }
215
+ function send429(res, built) {
216
+ if (res.headersSent) return;
217
+ for (const [k, v] of Object.entries(built.headers)) {
218
+ res.setHeader(k, v);
219
+ }
220
+ res.status(built.status).json(built.body);
221
+ }
222
+
223
+ // src/authLockout.ts
224
+ var AUTH_FAILURE_MAX = 10;
225
+ var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
226
+ var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
227
+ var MAX_AUTH_FAILURE_ENTRIES = 1e4;
228
+ function resolveBearerAuth(authorizationHeader, accessTokens2, now, accessTokenTtlMs) {
229
+ if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
230
+ return { status: "absent" };
231
+ }
232
+ if (!authorizationHeader.startsWith("Bearer ")) {
233
+ return { status: "rejected" };
234
+ }
235
+ const token = authorizationHeader.slice(7).trim();
236
+ if (token === "") {
237
+ return { status: "rejected" };
238
+ }
239
+ if (token.startsWith("pb_sk_")) {
240
+ return { status: "ok", apiKey: token };
241
+ }
242
+ if (token.startsWith("pb_at_")) {
243
+ const entry = accessTokens2.get(token);
244
+ if (!entry) return { status: "rejected" };
245
+ if (now - entry.createdAt > accessTokenTtlMs) {
246
+ accessTokens2.delete(token);
247
+ return { status: "rejected" };
248
+ }
249
+ return { status: "ok", apiKey: entry.apiKey };
250
+ }
251
+ return { status: "rejected" };
252
+ }
253
+ function checkAuthBlock(failures, ip, now) {
254
+ const rec = failures.get(ip);
255
+ if (!rec) return false;
256
+ return rec.blockedUntil > now;
257
+ }
258
+ function recordAuthFailure(failures, ip, now) {
259
+ const rec = failures.get(ip);
260
+ if (!rec) {
261
+ failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
262
+ return;
263
+ }
264
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
265
+ rec.count = 1;
266
+ rec.firstFailure = now;
267
+ rec.blockedUntil = 0;
268
+ } else {
269
+ rec.count++;
270
+ if (rec.count >= AUTH_FAILURE_MAX) {
271
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
272
+ }
273
+ }
274
+ }
275
+
276
+ // src/lib/oauthRedirectAllowlist.ts
277
+ var DEFAULT_OAUTH_REDIRECT_URIS = [
278
+ "https://claude.ai/api/mcp/auth_callback",
279
+ "https://claude.com/api/mcp/auth_callback",
280
+ "https://chatgpt.com/connector_platform_oauth_redirect"
281
+ ];
282
+ var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
283
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
284
+ var MAX_REDIRECT_URI_LENGTH = 2048;
285
+ var MAX_REGISTRATION_REDIRECTS = 10;
286
+ 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)";
287
+ function exactForm(url) {
288
+ return `${url.origin}${url.pathname}`;
289
+ }
290
+ function parseRedirectUriAllowlist(raw) {
291
+ if (!raw) return { uris: [], configError: null };
292
+ const uris = [];
293
+ for (const part of raw.split(",")) {
294
+ const trimmed = part.trim();
295
+ if (!trimmed) continue;
296
+ let url = null;
297
+ try {
298
+ url = trimmed.includes("://") ? new URL(trimmed) : null;
299
+ } catch {
300
+ url = null;
301
+ }
302
+ const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
303
+ if (!valid || url === null) {
304
+ return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
305
+ }
306
+ uris.push(exactForm(url));
307
+ }
308
+ return { uris, configError: null };
309
+ }
310
+ function resolveOAuthRedirectAllowlist(opts = {}) {
311
+ const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
312
+ return {
313
+ uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
314
+ configError: parsed.configError
315
+ };
316
+ }
317
+ function checkRedirectUri(redirectUri, allowlist) {
318
+ if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
319
+ if (typeof redirectUri !== "string" || redirectUri.length === 0) {
320
+ return "redirect_uri is required";
321
+ }
322
+ if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
323
+ return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
324
+ }
325
+ let url;
326
+ try {
327
+ url = new URL(redirectUri);
328
+ } catch {
329
+ return "redirect_uri is not a valid absolute URL";
330
+ }
331
+ if (url.username || url.password) return "redirect_uri must not contain credentials";
332
+ if (url.hash) return "redirect_uri must not contain a fragment";
333
+ const host = url.hostname.toLowerCase();
334
+ if (url.protocol === "http:") {
335
+ return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
336
+ }
337
+ if (url.protocol === "https:") {
338
+ if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
339
+ if (url.search) return "redirect_uri must not contain a query";
340
+ const exact = exactForm(url);
341
+ 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)";
342
+ }
343
+ return "redirect_uri scheme is not allowed (use https, or http on loopback)";
344
+ }
345
+ function checkRegistrationRedirects(redirectUris, allowlist) {
346
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
347
+ if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
348
+ return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
349
+ }
350
+ for (const uri of redirectUris) {
351
+ const reason = checkRedirectUri(uri, allowlist);
352
+ if (reason) return reason;
353
+ }
354
+ return null;
355
+ }
356
+
357
+ // src/lib/oauthAuthCode.ts
358
+ function pendingCodeRefusal(pending, redirectUri, now) {
359
+ if (!pending) return "unknown_code";
360
+ if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
361
+ if (now > pending.expiresAt) return "expired";
362
+ return null;
363
+ }
364
+
114
365
  // src/http.ts
115
366
  bootstrapHttp();
116
367
  initAnalytics();
@@ -125,6 +376,7 @@ var app = express();
125
376
  app.set("trust proxy", 1);
126
377
  app.use(express.json());
127
378
  var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
379
+ var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
128
380
  app.use((_req, res, next) => {
129
381
  const origin = _req.headers.origin;
130
382
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
@@ -135,7 +387,7 @@ app.use((_req, res, next) => {
135
387
  "Access-Control-Allow-Headers",
136
388
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
137
389
  );
138
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
390
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
139
391
  if (_req.method === "OPTIONS") {
140
392
  res.status(204).end();
141
393
  return;
@@ -187,11 +439,9 @@ app.post(
187
439
  return;
188
440
  }
189
441
  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
- });
442
+ const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
443
+ if (refusal) {
444
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
195
445
  return;
196
446
  }
197
447
  const clientId = `pb_client_${randomUUID2()}`;
@@ -609,7 +859,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
609
859
  <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
610
860
  <div class="err-actions">
611
861
  <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>
862
+ <a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
613
863
  </div>`;
614
864
  }
615
865
  var cmdScript = `
@@ -657,7 +907,7 @@ function authorizeFormPage(params) {
657
907
  <div class="hint" id="hint" hidden></div>
658
908
  <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
659
909
  </form>
660
- <div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
910
+ <div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
661
911
  </div>
662
912
 
663
913
  <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
@@ -796,6 +1046,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
796
1046
  }
797
1047
  app.get("/authorize", authLimiter, (req, res) => {
798
1048
  const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
1049
+ const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1050
+ if (refusal) {
1051
+ res.status(400).json({ error: "invalid_request", error_description: refusal });
1052
+ return;
1053
+ }
799
1054
  const cid = String(client_id ?? "");
800
1055
  const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
801
1056
  res.type("html").send(authorizeFormPage({
@@ -837,28 +1092,22 @@ app.post(
837
1092
  return;
838
1093
  }
839
1094
  if (!client_id) {
840
- res.status(400).json({
841
- error: "invalid_request",
842
- error_description: "client_id is required"
843
- });
1095
+ res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
1096
+ return;
1097
+ }
1098
+ const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1099
+ if (redirectRefusal) {
1100
+ res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
844
1101
  return;
845
1102
  }
846
1103
  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
- });
1104
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
1105
+ res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
860
1106
  return;
861
1107
  }
1108
+ registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
1109
+ process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
1110
+ `);
862
1111
  }
863
1112
  const client = registeredClients.get(client_id);
864
1113
  if (!client.redirect_uris.includes(redirect_uri)) {
@@ -875,6 +1124,7 @@ app.post(
875
1124
  const candidates = [primaryUrl, ...fallbackUrls];
876
1125
  let foundUrl;
877
1126
  let anyDefinitiveReject = false;
1127
+ let rateLimitedRetryAfter;
878
1128
  for (const url2 of candidates) {
879
1129
  let checkData = null;
880
1130
  try {
@@ -892,9 +1142,21 @@ app.post(
892
1142
  foundUrl = checkData.deploymentUrl ?? url2;
893
1143
  break;
894
1144
  }
1145
+ if (checkData.error === "rate_limited") {
1146
+ rateLimitedRetryAfter = checkData.retryAfterSeconds;
1147
+ continue;
1148
+ }
895
1149
  anyDefinitiveReject = true;
896
1150
  }
897
1151
  if (!foundUrl) {
1152
+ if (rateLimitedRetryAfter != null && !anyDefinitiveReject) {
1153
+ sendError(
1154
+ "Rate limited",
1155
+ `This API key is valid but temporarily rate limited. Try again in ${rateLimitedRetryAfter}s.`,
1156
+ 429
1157
+ );
1158
+ return;
1159
+ }
898
1160
  if (anyDefinitiveReject) {
899
1161
  sendError(
900
1162
  "Key not recognized",
@@ -963,7 +1225,13 @@ app.post(
963
1225
  return;
964
1226
  }
965
1227
  const pending = pendingCodes.get(code);
966
- if (!pending || pending.redirectUri !== redirect_uri) {
1228
+ const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
1229
+ if (codeRefusal === "expired") {
1230
+ pendingCodes.delete(code);
1231
+ res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
1232
+ return;
1233
+ }
1234
+ if (codeRefusal || !pending) {
967
1235
  res.status(400).json({ error: "invalid_grant" });
968
1236
  return;
969
1237
  }
@@ -985,85 +1253,63 @@ var mcpLimiter = rateLimit({
985
1253
  max: 120,
986
1254
  standardHeaders: true,
987
1255
  legacyHeaders: false,
988
- message: { error: "Too many requests. Try again later." }
1256
+ handler: (req, res) => {
1257
+ const r = req.rateLimit;
1258
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1259
+ send429(res, buildRateLimitError({
1260
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1261
+ limit: r?.limit ?? 120,
1262
+ current: r?.used ?? 0,
1263
+ id: req.body?.id ?? null
1264
+ // mirror the JSON-RPC id when a POST body is present
1265
+ }));
1266
+ }
989
1267
  });
990
1268
  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
1269
  app.get("/health", (_req, res) => {
1019
1270
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
1020
1271
  });
1021
1272
  var sessions = /* @__PURE__ */ new Map();
1022
1273
  var SESSION_TTL_MS = 30 * 60 * 1e3;
1023
1274
  var MAX_SESSIONS = 200;
1024
- var MAX_SESSIONS_PER_KEY = 5;
1275
+ var EVICT_GRACE_MS = 6e4;
1276
+ var MAX_SESSIONS_PER_KEY = Math.max(
1277
+ 1,
1278
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1279
+ );
1025
1280
  function evictStaleSessions() {
1026
1281
  const now = Date.now();
1027
1282
  for (const [id, entry] of sessions) {
1028
1283
  if (now - entry.lastAccess > SESSION_TTL_MS) {
1029
- logSessionLifecycle("session_deleted", id, "ttl");
1284
+ logSessionLifecycle(
1285
+ "session_deleted",
1286
+ id,
1287
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1288
+ );
1030
1289
  entry.transport.close().catch(() => {
1031
1290
  });
1032
1291
  sessions.delete(id);
1033
1292
  }
1034
1293
  }
1035
1294
  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++) {
1295
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1296
+ const overflow = sessions.size - MAX_SESSIONS;
1297
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
1040
1298
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
1041
1299
  sorted[i][1].transport.close().catch(() => {
1042
1300
  });
1043
1301
  sessions.delete(sorted[i][0]);
1044
1302
  }
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;
1303
+ if (sessions.size > MAX_SESSIONS) {
1304
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1305
+ process.stderr.write(
1306
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1307
+ `
1308
+ );
1062
1309
  }
1063
- return entry.apiKey;
1064
1310
  }
1065
- return null;
1066
1311
  }
1312
+ setInterval(evictStaleSessions, 6e4);
1067
1313
  function send401(req, res) {
1068
1314
  const base = baseUrl(req);
1069
1315
  res.status(401).set(
@@ -1086,21 +1332,25 @@ function logSessionLifecycle(event, sessionId, reason) {
1086
1332
  }
1087
1333
  app.post("/mcp", mcpLimiter, async (req, res) => {
1088
1334
  const reqIp = req.ip ?? "unknown";
1089
- if (checkAuthBlock(reqIp)) {
1090
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1335
+ const nowTs = Date.now();
1336
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1337
+ const rec = authFailures.get(reqIp);
1338
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1339
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1091
1340
  return;
1092
1341
  }
1093
- const apiKey = extractBearerKey(req);
1094
- if (!apiKey) {
1342
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1343
+ if (auth.status !== "ok") {
1095
1344
  logRequest("POST", "auth_fail");
1096
- recordAuthFailure(reqIp);
1345
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1097
1346
  send401(req, res);
1098
1347
  return;
1099
1348
  }
1349
+ const apiKey = auth.apiKey;
1100
1350
  const sessionId = req.headers["mcp-session-id"];
1101
1351
  const reqStart = Date.now();
1102
1352
  try {
1103
- await runWithAuth({ apiKey }, async () => {
1353
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1104
1354
  if (sessionId && sessions.has(sessionId)) {
1105
1355
  const entry = sessions.get(sessionId);
1106
1356
  if (entry.keyHash !== hashKey(apiKey)) {
@@ -1111,41 +1361,82 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1111
1361
  });
1112
1362
  return;
1113
1363
  }
1114
- entry.lastAccess = Date.now();
1115
- await entry.transport.handleRequest(req, res, req.body);
1116
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1364
+ try {
1365
+ entry.inFlight++;
1366
+ entry.lastAccess = Date.now();
1367
+ await entry.transport.handleRequest(req, res, req.body);
1368
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1369
+ } finally {
1370
+ entry.inFlight--;
1371
+ entry.lastAccess = Date.now();
1372
+ }
1117
1373
  } else if (!sessionId && isInitializeRequest(req.body)) {
1118
1374
  const keyH = hashKey(apiKey);
1119
- let keySessionCount = 0;
1120
- for (const entry of sessions.values()) {
1121
- if (entry.keyHash === keyH) keySessionCount++;
1122
- }
1123
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
1124
- res.status(429).json({
1125
- jsonrpc: "2.0",
1126
- error: { code: -32e3, message: "Too many sessions for this API key" },
1127
- id: null
1375
+ const plan = planKeyAdmission(
1376
+ sessions,
1377
+ keyH,
1378
+ MAX_SESSIONS_PER_KEY,
1379
+ Date.now(),
1380
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1381
+ );
1382
+ for (const sid2 of plan.evict) {
1383
+ const victim = sessions.get(sid2);
1384
+ sessions.delete(sid2);
1385
+ victim?.transport.close().catch(() => {
1128
1386
  });
1387
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1388
+ }
1389
+ if (!plan.admit) {
1390
+ send429(res, buildSessionCapError({
1391
+ retryAfterSeconds: plan.retryAfterSeconds,
1392
+ limit: MAX_SESSIONS_PER_KEY,
1393
+ current: countKeySessions(sessions, keyH),
1394
+ poll: plan.retryIsPoll,
1395
+ id: req.body?.id ?? null
1396
+ }));
1129
1397
  return;
1130
1398
  }
1399
+ const sid = randomUUID2();
1400
+ let initialized = false;
1131
1401
  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);
1402
+ sessionIdGenerator: () => sid,
1403
+ onsessioninitialized: (s) => {
1404
+ initialized = true;
1405
+ logSessionLifecycle("session_created", s);
1136
1406
  }
1407
+ // log only — no map write
1137
1408
  });
1409
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1410
+ sessions.set(sid, reserved);
1138
1411
  transport.onclose = () => {
1139
- const sid = transport.sessionId;
1140
- if (sid) {
1141
- logSessionLifecycle("session_deleted", sid, "onclose");
1142
- sessions.delete(sid);
1412
+ const s = transport.sessionId ?? sid;
1413
+ forgetStreamLifecycle(keyH, s);
1414
+ if (sessions.has(s)) {
1415
+ sessions.delete(s);
1416
+ logSessionLifecycle("session_deleted", s, "onclose");
1143
1417
  }
1144
1418
  };
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);
1419
+ try {
1420
+ const server = createProductBrainServer();
1421
+ await server.connect(transport);
1422
+ await transport.handleRequest(req, res, req.body);
1423
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1424
+ } catch (e) {
1425
+ transport.onclose = void 0;
1426
+ sessions.delete(sid);
1427
+ throw e;
1428
+ } finally {
1429
+ if (!initialized) {
1430
+ transport.onclose = void 0;
1431
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1432
+ } else {
1433
+ const e = sessions.get(sid);
1434
+ if (e) {
1435
+ e.inFlight--;
1436
+ e.lastAccess = Date.now();
1437
+ }
1438
+ }
1439
+ }
1149
1440
  } else if (sessionId) {
1150
1441
  process.stderr.write(
1151
1442
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
@@ -1181,17 +1472,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1181
1472
  });
1182
1473
  app.get("/mcp", mcpLimiter, async (req, res) => {
1183
1474
  const reqIp = req.ip ?? "unknown";
1184
- if (checkAuthBlock(reqIp)) {
1185
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1475
+ const nowTs = Date.now();
1476
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1477
+ const rec = authFailures.get(reqIp);
1478
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1479
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1186
1480
  return;
1187
1481
  }
1188
- const apiKey = extractBearerKey(req);
1189
- if (!apiKey) {
1482
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1483
+ if (auth.status !== "ok") {
1190
1484
  logRequest("GET", "auth_fail");
1191
- recordAuthFailure(reqIp);
1485
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1192
1486
  send401(req, res);
1193
1487
  return;
1194
1488
  }
1489
+ const apiKey = auth.apiKey;
1195
1490
  const sessionId = req.headers["mcp-session-id"];
1196
1491
  if (!sessionId) {
1197
1492
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1206,7 +1501,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1206
1501
  return;
1207
1502
  }
1208
1503
  try {
1209
- await runWithAuth({ apiKey }, async () => {
1504
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1210
1505
  const entry = sessions.get(sessionId);
1211
1506
  if (entry.keyHash !== hashKey(apiKey)) {
1212
1507
  res.status(403).json({
@@ -1226,17 +1521,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1226
1521
  });
1227
1522
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1228
1523
  const reqIp = req.ip ?? "unknown";
1229
- if (checkAuthBlock(reqIp)) {
1230
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1524
+ const nowTs = Date.now();
1525
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1526
+ const rec = authFailures.get(reqIp);
1527
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1528
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1231
1529
  return;
1232
1530
  }
1233
- const apiKey = extractBearerKey(req);
1234
- if (!apiKey) {
1531
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1532
+ if (auth.status !== "ok") {
1235
1533
  logRequest("DELETE", "auth_fail");
1236
- recordAuthFailure(reqIp);
1534
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1237
1535
  send401(req, res);
1238
1536
  return;
1239
1537
  }
1538
+ const apiKey = auth.apiKey;
1240
1539
  const sessionId = req.headers["mcp-session-id"];
1241
1540
  if (!sessionId) {
1242
1541
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1251,7 +1550,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
1251
1550
  return;
1252
1551
  }
1253
1552
  try {
1254
- await runWithAuth({ apiKey }, async () => {
1553
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1255
1554
  const entry = sessions.get(sessionId);
1256
1555
  if (entry.keyHash !== hashKey(apiKey)) {
1257
1556
  res.status(403).json({