@productbrain/mcp 0.0.1-beta.989 → 0.0.1-beta.9900

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-JJJO3SDX.js";
4
+ initFeatureFlags
5
+ } from "./chunk-EL3KRIDU.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-ME7NLZOZ.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,292 @@ 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;
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 rejected(presented) {
174
+ return { status: "rejected", credentialId: hashKey(presented) };
175
+ }
176
+ function resolveBearerAuth(authorizationHeader) {
177
+ if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
178
+ return { status: "absent" };
179
+ }
180
+ if (!authorizationHeader.startsWith("Bearer ")) {
181
+ return rejected(authorizationHeader.trim());
182
+ }
183
+ const token = authorizationHeader.slice(7).trim();
184
+ if (!token.startsWith("pb_sk_")) return rejected(token);
185
+ return { status: "ok", apiKey: token, credentialId: hashKey(token) };
186
+ }
187
+ function resolvePastedKey(apiKey) {
188
+ if (apiKey === "") return { status: "absent" };
189
+ if (!apiKey.startsWith("pb_sk_")) return rejected(apiKey);
190
+ return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
191
+ }
192
+ function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
193
+ if (secFetchSite === "cross-site") return true;
194
+ if (typeof origin !== "string" || origin === "") return false;
92
195
  try {
93
- providedSig = Buffer.from(sigB64, "base64url");
196
+ return new URL(origin).origin !== new URL(ownOrigin).origin;
94
197
  } catch {
95
- return null;
198
+ return true;
96
199
  }
97
- const expectedSig = sign(payloadB64);
98
- if (providedSig.length !== expectedSig.length) return null;
99
- if (!timingSafeEqual(providedSig, expectedSig)) return null;
100
- let payload;
200
+ }
201
+ function checkAuthBlock(failures, credentialId, now) {
202
+ const rec = failures.get(credentialId);
203
+ if (!rec) return false;
204
+ return rec.blockedUntil > now;
205
+ }
206
+ function authLockoutError(failures, auth, now, id = null) {
207
+ if (auth.status === "absent") return null;
208
+ const rec = failures.get(auth.credentialId);
209
+ if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
210
+ const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
211
+ return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
212
+ }
213
+ function recordAuthFailure(failures, credentialId, now) {
214
+ const rec = failures.get(credentialId);
215
+ if (!rec) {
216
+ if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
217
+ const oldest = failures.keys().next().value;
218
+ if (oldest !== void 0) failures.delete(oldest);
219
+ }
220
+ failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
221
+ return;
222
+ }
223
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
224
+ rec.count = 1;
225
+ rec.firstFailure = now;
226
+ rec.blockedUntil = 0;
227
+ } else {
228
+ rec.count++;
229
+ if (rec.count >= AUTH_FAILURE_MAX) {
230
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
231
+ }
232
+ }
233
+ }
234
+ function keyVerdictGate(verdict, failures, credentialId, now) {
235
+ if (verdict === "ok" || verdict === "rate_limited") return "admit";
236
+ if (verdict === "rejected") {
237
+ recordAuthFailure(failures, credentialId, now);
238
+ return "unauthorized";
239
+ }
240
+ return "unavailable";
241
+ }
242
+
243
+ // src/lib/oauthRedirectAllowlist.ts
244
+ var DEFAULT_OAUTH_REDIRECT_URIS = [
245
+ "https://claude.ai/api/mcp/auth_callback",
246
+ "https://claude.com/api/mcp/auth_callback",
247
+ "https://chatgpt.com/connector_platform_oauth_redirect"
248
+ ];
249
+ var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
250
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
251
+ var MAX_REDIRECT_URI_LENGTH = 2048;
252
+ var MAX_REGISTRATION_REDIRECTS = 10;
253
+ 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)";
254
+ function exactForm(url) {
255
+ return `${url.origin}${url.pathname}`;
256
+ }
257
+ function parseRedirectUriAllowlist(raw) {
258
+ if (!raw) return { uris: [], configError: null };
259
+ const uris = [];
260
+ for (const part of raw.split(",")) {
261
+ const trimmed = part.trim();
262
+ if (!trimmed) continue;
263
+ let url = null;
264
+ try {
265
+ url = trimmed.includes("://") ? new URL(trimmed) : null;
266
+ } catch {
267
+ url = null;
268
+ }
269
+ const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
270
+ if (!valid || url === null) {
271
+ return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
272
+ }
273
+ uris.push(exactForm(url));
274
+ }
275
+ return { uris, configError: null };
276
+ }
277
+ function resolveOAuthRedirectAllowlist(opts = {}) {
278
+ const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
279
+ return {
280
+ uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
281
+ configError: parsed.configError
282
+ };
283
+ }
284
+ function checkRedirectUri(redirectUri, allowlist) {
285
+ if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
286
+ if (typeof redirectUri !== "string" || redirectUri.length === 0) {
287
+ return "redirect_uri is required";
288
+ }
289
+ if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
290
+ return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
291
+ }
292
+ let url;
101
293
  try {
102
- const json = Buffer.from(payloadB64, "base64url").toString("utf8");
103
- payload = JSON.parse(json);
294
+ url = new URL(redirectUri);
104
295
  } catch {
105
- return null;
296
+ return "redirect_uri is not a valid absolute URL";
297
+ }
298
+ if (url.username || url.password) return "redirect_uri must not contain credentials";
299
+ if (url.hash) return "redirect_uri must not contain a fragment";
300
+ const host = url.hostname.toLowerCase();
301
+ if (url.protocol === "http:") {
302
+ return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
303
+ }
304
+ if (url.protocol === "https:") {
305
+ if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
306
+ if (url.search) return "redirect_uri must not contain a query";
307
+ const exact = exactForm(url);
308
+ 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
309
  }
107
- if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
108
- return null;
310
+ return "redirect_uri scheme is not allowed (use https, or http on loopback)";
311
+ }
312
+ function checkRegistrationRedirects(redirectUris, allowlist) {
313
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
314
+ if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
315
+ return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
316
+ }
317
+ for (const uri of redirectUris) {
318
+ const reason = checkRedirectUri(uri, allowlist);
319
+ if (reason) return reason;
320
+ }
321
+ return null;
322
+ }
323
+
324
+ // src/lib/oauthAuthCode.ts
325
+ function normalisePastedKey(raw) {
326
+ if (typeof raw !== "string") return "";
327
+ const trimmed = raw.trim();
328
+ const first = trimmed[0];
329
+ if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
330
+ return trimmed.slice(1, -1).trim();
109
331
  }
110
- if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
111
- return { apiKey: payload.k };
332
+ return trimmed;
333
+ }
334
+ function pendingCodeRefusal(pending, redirectUri, now) {
335
+ if (!pending) return "unknown_code";
336
+ if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
337
+ if (now > pending.expiresAt) return "expired";
338
+ return null;
339
+ }
340
+ function tokenResponse(apiKey) {
341
+ return {
342
+ access_token: apiKey,
343
+ token_type: "Bearer",
344
+ // 1 year: validity is enforced by Convex on every call, not by this clock.
345
+ expires_in: 365 * 24 * 3600
346
+ };
112
347
  }
113
348
 
114
349
  // src/http.ts
@@ -116,6 +351,10 @@ bootstrapHttp();
116
351
  initAnalytics();
117
352
  initFeatureFlags(getPostHogClient());
118
353
  var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
354
+ function keyCheckCandidates() {
355
+ const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
356
+ return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
357
+ }
119
358
  function baseUrl(req) {
120
359
  const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
121
360
  const host = req.headers.host ?? `localhost:${PORT}`;
@@ -125,6 +364,7 @@ var app = express();
125
364
  app.set("trust proxy", 1);
126
365
  app.use(express.json());
127
366
  var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
367
+ var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
128
368
  app.use((_req, res, next) => {
129
369
  const origin = _req.headers.origin;
130
370
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
@@ -135,7 +375,7 @@ app.use((_req, res, next) => {
135
375
  "Access-Control-Allow-Headers",
136
376
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
137
377
  );
138
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
378
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
139
379
  if (_req.method === "OPTIONS") {
140
380
  res.status(204).end();
141
381
  return;
@@ -159,7 +399,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
159
399
  token_endpoint: `${base}/oauth/token`,
160
400
  registration_endpoint: `${base}/register`,
161
401
  response_types_supported: ["code"],
162
- grant_types_supported: ["authorization_code", "refresh_token"],
402
+ grant_types_supported: ["authorization_code"],
403
+ // TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
163
404
  code_challenge_methods_supported: ["S256"],
164
405
  token_endpoint_auth_methods_supported: ["none"],
165
406
  scopes_supported: ["mcp:tools", "mcp:resources"]
@@ -187,14 +428,12 @@ app.post(
187
428
  return;
188
429
  }
189
430
  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
- });
431
+ const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
432
+ if (refusal) {
433
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
195
434
  return;
196
435
  }
197
- const clientId = `pb_client_${randomUUID2()}`;
436
+ const clientId = `pb_client_${randomUUID()}`;
198
437
  const client = {
199
438
  client_id: clientId,
200
439
  redirect_uris,
@@ -213,9 +452,6 @@ app.post(
213
452
  }
214
453
  );
215
454
  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
455
  setInterval(() => {
220
456
  const now = Date.now();
221
457
  for (const [code, auth] of pendingCodes) {
@@ -224,18 +460,9 @@ setInterval(() => {
224
460
  for (const [id, client] of registeredClients) {
225
461
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
226
462
  }
227
- for (const [token, entry] of accessTokens) {
228
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
229
- }
230
- for (const [ip, rec] of authFailures) {
463
+ for (const [credentialId, rec] of authFailures) {
231
464
  if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
232
- authFailures.delete(ip);
233
- }
234
- }
235
- if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
236
- const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
237
- for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
238
- authFailures.delete(sorted[i][0]);
465
+ authFailures.delete(credentialId);
239
466
  }
240
467
  }
241
468
  }, 6e4);
@@ -609,7 +836,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
609
836
  <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
610
837
  <div class="err-actions">
611
838
  <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>
839
+ <a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
613
840
  </div>`;
614
841
  }
615
842
  var cmdScript = `
@@ -657,7 +884,7 @@ function authorizeFormPage(params) {
657
884
  <div class="hint" id="hint" hidden></div>
658
885
  <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
659
886
  </form>
660
- <div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
887
+ <div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
661
888
  </div>
662
889
 
663
890
  <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
@@ -738,7 +965,6 @@ ${cmdScript}
738
965
  e.preventDefault();
739
966
  var v=k.value.trim();
740
967
  if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
741
- if(v.indexOf('pb_sk_')!==0){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Key must start with pb_sk_';hint.removeAttribute('hidden');return}
742
968
  sb.disabled=true;bt.textContent='Verifying';
743
969
  show(pVerify);
744
970
 
@@ -796,6 +1022,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
796
1022
  }
797
1023
  app.get("/authorize", authLimiter, (req, res) => {
798
1024
  const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
1025
+ const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1026
+ if (refusal) {
1027
+ res.status(400).json({ error: "invalid_request", error_description: refusal });
1028
+ return;
1029
+ }
799
1030
  const cid = String(client_id ?? "");
800
1031
  const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
801
1032
  res.type("html").send(authorizeFormPage({
@@ -812,7 +1043,8 @@ app.post(
812
1043
  authLimiter,
813
1044
  express.urlencoded({ extended: false }),
814
1045
  async (req, res) => {
815
- const { api_key, redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
1046
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
1047
+ const api_key = normalisePastedKey(req.body.api_key);
816
1048
  const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
817
1049
  const retryParams = new URLSearchParams({
818
1050
  redirect_uri: redirect_uri ?? "",
@@ -829,36 +1061,41 @@ app.post(
829
1061
  res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
830
1062
  }
831
1063
  }
832
- if (!api_key?.startsWith("pb_sk_")) {
833
- sendError(
834
- "Invalid key format",
835
- "API keys start with <code>pb_sk_</code>. Check your key and try again."
836
- );
1064
+ if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
1065
+ sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
1066
+ return;
1067
+ }
1068
+ const nowTs = Date.now();
1069
+ const pasted = resolvePastedKey(api_key);
1070
+ const lockout = authLockoutError(authFailures, pasted, nowTs);
1071
+ if (lockout) {
1072
+ const retryAfter = lockout.headers["Retry-After"];
1073
+ res.setHeader("Retry-After", retryAfter);
1074
+ sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
1075
+ return;
1076
+ }
1077
+ if (pasted.status !== "ok") {
1078
+ if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
1079
+ sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
837
1080
  return;
838
1081
  }
839
1082
  if (!client_id) {
840
- res.status(400).json({
841
- error: "invalid_request",
842
- error_description: "client_id is required"
843
- });
1083
+ res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
1084
+ return;
1085
+ }
1086
+ const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1087
+ if (redirectRefusal) {
1088
+ res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
844
1089
  return;
845
1090
  }
846
1091
  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
- });
1092
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
1093
+ res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
860
1094
  return;
861
1095
  }
1096
+ registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
1097
+ process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
1098
+ `);
862
1099
  }
863
1100
  const client = registeredClients.get(client_id);
864
1101
  if (!client.redirect_uris.includes(redirect_uri)) {
@@ -869,48 +1106,25 @@ app.post(
869
1106
  return;
870
1107
  }
871
1108
  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 {
1109
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
1110
+ const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
1111
+ if (check.verdict === "ok") {
1112
+ if (check.workspaceName) workspaceName = check.workspaceName;
1113
+ getKeyState(api_key).deploymentUrl = check.deploymentUrl;
1114
+ } else if (check.verdict === "rate_limited") {
1115
+ sendError(
1116
+ "Rate limited",
1117
+ `This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
1118
+ 429
1119
+ );
1120
+ return;
1121
+ } else if (gate === "unauthorized") {
1122
+ sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
1123
+ return;
1124
+ } else {
911
1125
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
912
1126
  }
913
- const code = randomUUID2();
1127
+ const code = randomUUID();
914
1128
  pendingCodes.set(code, {
915
1129
  apiKey: api_key,
916
1130
  codeChallenge: code_challenge,
@@ -929,41 +1143,25 @@ app.post(
929
1143
  }
930
1144
  }
931
1145
  );
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
1146
  app.post(
943
1147
  "/oauth/token",
944
1148
  authLimiter,
945
1149
  express.urlencoded({ extended: false }),
946
1150
  express.json(),
947
1151
  (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
- }
1152
+ const { grant_type, code, code_verifier, redirect_uri } = req.body;
961
1153
  if (grant_type !== "authorization_code") {
962
1154
  res.status(400).json({ error: "unsupported_grant_type" });
963
1155
  return;
964
1156
  }
965
1157
  const pending = pendingCodes.get(code);
966
- if (!pending || pending.redirectUri !== redirect_uri) {
1158
+ const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
1159
+ if (codeRefusal === "expired") {
1160
+ pendingCodes.delete(code);
1161
+ res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
1162
+ return;
1163
+ }
1164
+ if (codeRefusal || !pending) {
967
1165
  res.status(400).json({ error: "invalid_grant" });
968
1166
  return;
969
1167
  }
@@ -977,7 +1175,7 @@ app.post(
977
1175
  return;
978
1176
  }
979
1177
  pendingCodes.delete(code);
980
- res.json(issueTokens(pending.apiKey));
1178
+ res.json(tokenResponse(pending.apiKey));
981
1179
  }
982
1180
  );
983
1181
  var mcpLimiter = rateLimit({
@@ -985,85 +1183,63 @@ var mcpLimiter = rateLimit({
985
1183
  max: 120,
986
1184
  standardHeaders: true,
987
1185
  legacyHeaders: false,
988
- message: { error: "Too many requests. Try again later." }
1186
+ handler: (req, res) => {
1187
+ const r = req.rateLimit;
1188
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1189
+ send429(res, buildRateLimitError({
1190
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1191
+ limit: r?.limit ?? 120,
1192
+ current: r?.used ?? 0,
1193
+ id: req.body?.id ?? null
1194
+ // mirror the JSON-RPC id when a POST body is present
1195
+ }));
1196
+ }
989
1197
  });
990
1198
  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
1199
  app.get("/health", (_req, res) => {
1019
1200
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
1020
1201
  });
1021
1202
  var sessions = /* @__PURE__ */ new Map();
1022
1203
  var SESSION_TTL_MS = 30 * 60 * 1e3;
1023
1204
  var MAX_SESSIONS = 200;
1024
- var MAX_SESSIONS_PER_KEY = 5;
1205
+ var EVICT_GRACE_MS = 6e4;
1206
+ var MAX_SESSIONS_PER_KEY = Math.max(
1207
+ 1,
1208
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1209
+ );
1025
1210
  function evictStaleSessions() {
1026
1211
  const now = Date.now();
1027
1212
  for (const [id, entry] of sessions) {
1028
1213
  if (now - entry.lastAccess > SESSION_TTL_MS) {
1029
- logSessionLifecycle("session_deleted", id, "ttl");
1214
+ logSessionLifecycle(
1215
+ "session_deleted",
1216
+ id,
1217
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1218
+ );
1030
1219
  entry.transport.close().catch(() => {
1031
1220
  });
1032
1221
  sessions.delete(id);
1033
1222
  }
1034
1223
  }
1035
1224
  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++) {
1225
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1226
+ const overflow = sessions.size - MAX_SESSIONS;
1227
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
1040
1228
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
1041
1229
  sorted[i][1].transport.close().catch(() => {
1042
1230
  });
1043
1231
  sessions.delete(sorted[i][0]);
1044
1232
  }
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;
1233
+ if (sessions.size > MAX_SESSIONS) {
1234
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1235
+ process.stderr.write(
1236
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1237
+ `
1238
+ );
1062
1239
  }
1063
- return entry.apiKey;
1064
1240
  }
1065
- return null;
1066
1241
  }
1242
+ setInterval(evictStaleSessions, 6e4);
1067
1243
  function send401(req, res) {
1068
1244
  const base = baseUrl(req);
1069
1245
  res.status(401).set(
@@ -1085,22 +1261,24 @@ function logSessionLifecycle(event, sessionId, reason) {
1085
1261
  `);
1086
1262
  }
1087
1263
  app.post("/mcp", mcpLimiter, async (req, res) => {
1088
- const reqIp = req.ip ?? "unknown";
1089
- if (checkAuthBlock(reqIp)) {
1090
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1264
+ const nowTs = Date.now();
1265
+ const auth = resolveBearerAuth(req.headers?.authorization);
1266
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1267
+ if (lockout) {
1268
+ send429(res, lockout);
1091
1269
  return;
1092
1270
  }
1093
- const apiKey = extractBearerKey(req);
1094
- if (!apiKey) {
1271
+ if (auth.status !== "ok") {
1095
1272
  logRequest("POST", "auth_fail");
1096
- recordAuthFailure(reqIp);
1273
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
1097
1274
  send401(req, res);
1098
1275
  return;
1099
1276
  }
1277
+ const apiKey = auth.apiKey;
1100
1278
  const sessionId = req.headers["mcp-session-id"];
1101
1279
  const reqStart = Date.now();
1102
1280
  try {
1103
- await runWithAuth({ apiKey }, async () => {
1281
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1104
1282
  if (sessionId && sessions.has(sessionId)) {
1105
1283
  const entry = sessions.get(sessionId);
1106
1284
  if (entry.keyHash !== hashKey(apiKey)) {
@@ -1111,41 +1289,98 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1111
1289
  });
1112
1290
  return;
1113
1291
  }
1114
- entry.lastAccess = Date.now();
1115
- await entry.transport.handleRequest(req, res, req.body);
1116
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1292
+ try {
1293
+ entry.inFlight++;
1294
+ entry.lastAccess = Date.now();
1295
+ await entry.transport.handleRequest(req, res, req.body);
1296
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1297
+ } finally {
1298
+ entry.inFlight--;
1299
+ entry.lastAccess = Date.now();
1300
+ }
1117
1301
  } 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++;
1302
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
1303
+ const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
1304
+ if (gate === "unauthorized") {
1305
+ logRequest("POST", "auth_fail");
1306
+ send401(req, res);
1307
+ return;
1122
1308
  }
1123
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
1124
- res.status(429).json({
1309
+ if (gate === "unavailable") {
1310
+ res.status(503).json({
1125
1311
  jsonrpc: "2.0",
1126
- error: { code: -32e3, message: "Too many sessions for this API key" },
1127
- id: null
1312
+ error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
1313
+ id: req.body?.id ?? null
1314
+ });
1315
+ return;
1316
+ }
1317
+ if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
1318
+ const keyH = hashKey(apiKey);
1319
+ const plan = planKeyAdmission(
1320
+ sessions,
1321
+ keyH,
1322
+ MAX_SESSIONS_PER_KEY,
1323
+ Date.now(),
1324
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1325
+ );
1326
+ for (const sid2 of plan.evict) {
1327
+ const victim = sessions.get(sid2);
1328
+ sessions.delete(sid2);
1329
+ victim?.transport.close().catch(() => {
1128
1330
  });
1331
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1332
+ }
1333
+ if (!plan.admit) {
1334
+ send429(res, buildSessionCapError({
1335
+ retryAfterSeconds: plan.retryAfterSeconds,
1336
+ limit: MAX_SESSIONS_PER_KEY,
1337
+ current: countKeySessions(sessions, keyH),
1338
+ poll: plan.retryIsPoll,
1339
+ id: req.body?.id ?? null
1340
+ }));
1129
1341
  return;
1130
1342
  }
1343
+ const sid = randomUUID();
1344
+ let initialized = false;
1131
1345
  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);
1346
+ sessionIdGenerator: () => sid,
1347
+ onsessioninitialized: (s) => {
1348
+ initialized = true;
1349
+ logSessionLifecycle("session_created", s);
1136
1350
  }
1351
+ // log only — no map write
1137
1352
  });
1353
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1354
+ sessions.set(sid, reserved);
1138
1355
  transport.onclose = () => {
1139
- const sid = transport.sessionId;
1140
- if (sid) {
1141
- logSessionLifecycle("session_deleted", sid, "onclose");
1142
- sessions.delete(sid);
1356
+ const s = transport.sessionId ?? sid;
1357
+ forgetStreamLifecycle(keyH, s);
1358
+ if (sessions.has(s)) {
1359
+ sessions.delete(s);
1360
+ logSessionLifecycle("session_deleted", s, "onclose");
1143
1361
  }
1144
1362
  };
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);
1363
+ try {
1364
+ const server = createProductBrainServer();
1365
+ await server.connect(transport);
1366
+ await transport.handleRequest(req, res, req.body);
1367
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1368
+ } catch (e) {
1369
+ transport.onclose = void 0;
1370
+ sessions.delete(sid);
1371
+ throw e;
1372
+ } finally {
1373
+ if (!initialized) {
1374
+ transport.onclose = void 0;
1375
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1376
+ } else {
1377
+ const e = sessions.get(sid);
1378
+ if (e) {
1379
+ e.inFlight--;
1380
+ e.lastAccess = Date.now();
1381
+ }
1382
+ }
1383
+ }
1149
1384
  } else if (sessionId) {
1150
1385
  process.stderr.write(
1151
1386
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
@@ -1180,18 +1415,20 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1180
1415
  }
1181
1416
  });
1182
1417
  app.get("/mcp", mcpLimiter, async (req, res) => {
1183
- const reqIp = req.ip ?? "unknown";
1184
- if (checkAuthBlock(reqIp)) {
1185
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1418
+ const nowTs = Date.now();
1419
+ const auth = resolveBearerAuth(req.headers?.authorization);
1420
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1421
+ if (lockout) {
1422
+ send429(res, lockout);
1186
1423
  return;
1187
1424
  }
1188
- const apiKey = extractBearerKey(req);
1189
- if (!apiKey) {
1425
+ if (auth.status !== "ok") {
1190
1426
  logRequest("GET", "auth_fail");
1191
- recordAuthFailure(reqIp);
1427
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
1192
1428
  send401(req, res);
1193
1429
  return;
1194
1430
  }
1431
+ const apiKey = auth.apiKey;
1195
1432
  const sessionId = req.headers["mcp-session-id"];
1196
1433
  if (!sessionId) {
1197
1434
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1206,7 +1443,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1206
1443
  return;
1207
1444
  }
1208
1445
  try {
1209
- await runWithAuth({ apiKey }, async () => {
1446
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1210
1447
  const entry = sessions.get(sessionId);
1211
1448
  if (entry.keyHash !== hashKey(apiKey)) {
1212
1449
  res.status(403).json({
@@ -1225,18 +1462,20 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1225
1462
  }
1226
1463
  });
1227
1464
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1228
- const reqIp = req.ip ?? "unknown";
1229
- if (checkAuthBlock(reqIp)) {
1230
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1465
+ const nowTs = Date.now();
1466
+ const auth = resolveBearerAuth(req.headers?.authorization);
1467
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1468
+ if (lockout) {
1469
+ send429(res, lockout);
1231
1470
  return;
1232
1471
  }
1233
- const apiKey = extractBearerKey(req);
1234
- if (!apiKey) {
1472
+ if (auth.status !== "ok") {
1235
1473
  logRequest("DELETE", "auth_fail");
1236
- recordAuthFailure(reqIp);
1474
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
1237
1475
  send401(req, res);
1238
1476
  return;
1239
1477
  }
1478
+ const apiKey = auth.apiKey;
1240
1479
  const sessionId = req.headers["mcp-session-id"];
1241
1480
  if (!sessionId) {
1242
1481
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1251,7 +1490,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
1251
1490
  return;
1252
1491
  }
1253
1492
  try {
1254
- await runWithAuth({ apiKey }, async () => {
1493
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1255
1494
  const entry = sessions.get(sessionId);
1256
1495
  if (entry.keyHash !== hashKey(apiKey)) {
1257
1496
  res.status(403).json({