@productbrain/mcp 0.0.1-beta.973 → 0.0.1-beta.9754

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