@productbrain/mcp 0.0.1-beta.190 → 0.0.1-beta.1900

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,21 @@
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-ZF6N62QQ.js";
4
+ initFeatureFlags
5
+ } from "./chunk-ZKW7JZUF.js";
11
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ bootstrapHttp,
9
+ getKeyState,
12
10
  getPostHogClient,
11
+ hashKey,
13
12
  initAnalytics,
13
+ runWithAuth,
14
14
  shutdownAnalytics
15
- } from "./chunk-YMF3IQ5E.js";
15
+ } from "./chunk-6JNK5DIZ.js";
16
16
 
17
17
  // src/http.ts
18
- import { createHash, randomUUID } from "crypto";
18
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
19
19
  import express from "express";
20
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -54,6 +54,223 @@ function appLogoMarkup(opts = {}) {
54
54
  return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
55
55
  }
56
56
 
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
+ );
68
+ }
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()
79
+ };
80
+ const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
81
+ const sigB64 = sign(payloadB64).toString("base64url");
82
+ return `${PREFIX}${payloadB64}.${sigB64}`;
83
+ }
84
+ function verifyRefreshToken(token) {
85
+ if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
86
+ const body = token.slice(PREFIX.length);
87
+ const dot = body.indexOf(".");
88
+ if (dot <= 0 || dot === body.length - 1) return null;
89
+ const payloadB64 = body.slice(0, dot);
90
+ const sigB64 = body.slice(dot + 1);
91
+ let providedSig;
92
+ try {
93
+ providedSig = Buffer.from(sigB64, "base64url");
94
+ } catch {
95
+ return null;
96
+ }
97
+ const expectedSig = sign(payloadB64);
98
+ if (providedSig.length !== expectedSig.length) return null;
99
+ if (!timingSafeEqual(providedSig, expectedSig)) return null;
100
+ let payload;
101
+ try {
102
+ const json = Buffer.from(payloadB64, "base64url").toString("utf8");
103
+ payload = JSON.parse(json);
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
108
+ return null;
109
+ }
110
+ if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
111
+ return { apiKey: payload.k };
112
+ }
113
+
114
+ // src/sessionCap.ts
115
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
116
+ if (cap < 1) {
117
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
118
+ }
119
+ const { ttlMs, graceMs } = opts;
120
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
121
+ const keyEntries = [];
122
+ for (const [id, session] of sessions2) {
123
+ if (session.keyHash === keyHash) {
124
+ keyEntries.push([id, session]);
125
+ }
126
+ }
127
+ const evictSet = /* @__PURE__ */ new Set();
128
+ for (const [id, session] of keyEntries) {
129
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
130
+ evictSet.add(id);
131
+ }
132
+ }
133
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
134
+ if (survivors.length >= cap) {
135
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
136
+ for (const [id] of idleSurvivors) {
137
+ if (survivors.length < cap) break;
138
+ evictSet.add(id);
139
+ survivors = survivors.filter(([sid]) => sid !== id);
140
+ }
141
+ }
142
+ const evict = Array.from(evictSet);
143
+ const admit = survivors.length < cap;
144
+ let retryAfterSeconds = 0;
145
+ let retryIsPoll = false;
146
+ if (!admit) {
147
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
148
+ if (graceBlockers.length > 0) {
149
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
150
+ const msRemaining = graceMs - (now - minLastAccess);
151
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
152
+ } else {
153
+ retryAfterSeconds = 5;
154
+ retryIsPoll = true;
155
+ }
156
+ }
157
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
158
+ }
159
+ function countKeySessions(sessions2, keyHash) {
160
+ let count = 0;
161
+ for (const session of sessions2.values()) {
162
+ if (session.keyHash === keyHash) count++;
163
+ }
164
+ return count;
165
+ }
166
+
167
+ // src/httpErrors.ts
168
+ function clampSeconds(s) {
169
+ return Math.max(0, Math.round(s));
170
+ }
171
+ function build(reason, message, p) {
172
+ const seconds = clampSeconds(p.retryAfterSeconds);
173
+ return {
174
+ status: 429,
175
+ headers: { "Retry-After": String(seconds) },
176
+ body: {
177
+ jsonrpc: "2.0",
178
+ id: p.id ?? null,
179
+ error: {
180
+ code: -32e3,
181
+ message,
182
+ data: {
183
+ reason,
184
+ retryAfterSeconds: seconds,
185
+ limit: p.limit,
186
+ current: p.current
187
+ }
188
+ }
189
+ }
190
+ };
191
+ }
192
+ function buildRateLimitError(p) {
193
+ const seconds = clampSeconds(p.retryAfterSeconds);
194
+ return build(
195
+ "rate_limited",
196
+ `Too many requests \u2014 retry after ${seconds} s.`,
197
+ p
198
+ );
199
+ }
200
+ function buildAuthLockoutError(p) {
201
+ const seconds = clampSeconds(p.retryAfterSeconds);
202
+ return build(
203
+ "auth_lockout",
204
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
205
+ p
206
+ );
207
+ }
208
+ function buildSessionCapError(p) {
209
+ const seconds = clampSeconds(p.retryAfterSeconds);
210
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
211
+ return build("session_cap", message, p);
212
+ }
213
+ function send429(res, built) {
214
+ if (res.headersSent) return;
215
+ for (const [k, v] of Object.entries(built.headers)) {
216
+ res.setHeader(k, v);
217
+ }
218
+ res.status(built.status).json(built.body);
219
+ }
220
+
221
+ // src/authLockout.ts
222
+ var AUTH_FAILURE_MAX = 10;
223
+ var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
224
+ var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
225
+ var MAX_AUTH_FAILURE_ENTRIES = 1e4;
226
+ function resolveBearerAuth(authorizationHeader, accessTokens2, now, accessTokenTtlMs) {
227
+ if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
228
+ return { status: "absent" };
229
+ }
230
+ if (!authorizationHeader.startsWith("Bearer ")) {
231
+ return { status: "rejected" };
232
+ }
233
+ const token = authorizationHeader.slice(7).trim();
234
+ if (token === "") {
235
+ return { status: "rejected" };
236
+ }
237
+ if (token.startsWith("pb_sk_")) {
238
+ return { status: "ok", apiKey: token };
239
+ }
240
+ if (token.startsWith("pb_at_")) {
241
+ const entry = accessTokens2.get(token);
242
+ if (!entry) return { status: "rejected" };
243
+ if (now - entry.createdAt > accessTokenTtlMs) {
244
+ accessTokens2.delete(token);
245
+ return { status: "rejected" };
246
+ }
247
+ return { status: "ok", apiKey: entry.apiKey };
248
+ }
249
+ return { status: "rejected" };
250
+ }
251
+ function checkAuthBlock(failures, ip, now) {
252
+ const rec = failures.get(ip);
253
+ if (!rec) return false;
254
+ return rec.blockedUntil > now;
255
+ }
256
+ function recordAuthFailure(failures, ip, now) {
257
+ const rec = failures.get(ip);
258
+ if (!rec) {
259
+ failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
260
+ return;
261
+ }
262
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
263
+ rec.count = 1;
264
+ rec.firstFailure = now;
265
+ rec.blockedUntil = 0;
266
+ } else {
267
+ rec.count++;
268
+ if (rec.count >= AUTH_FAILURE_MAX) {
269
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
270
+ }
271
+ }
272
+ }
273
+
57
274
  // src/http.ts
58
275
  bootstrapHttp();
59
276
  initAnalytics();
@@ -78,7 +295,7 @@ app.use((_req, res, next) => {
78
295
  "Access-Control-Allow-Headers",
79
296
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
80
297
  );
81
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
298
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
82
299
  if (_req.method === "OPTIONS") {
83
300
  res.status(204).end();
84
301
  return;
@@ -137,7 +354,7 @@ app.post(
137
354
  });
138
355
  return;
139
356
  }
140
- const clientId = `pb_client_${randomUUID()}`;
357
+ const clientId = `pb_client_${randomUUID2()}`;
141
358
  const client = {
142
359
  client_id: clientId,
143
360
  redirect_uris,
@@ -158,10 +375,6 @@ app.post(
158
375
  var pendingCodes = /* @__PURE__ */ new Map();
159
376
  var ACCESS_TOKEN_TTL = 3600;
160
377
  var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
161
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
162
- var refreshTokens = /* @__PURE__ */ new Map();
163
- var MAX_REFRESH_TOKENS = 2e3;
164
- var MAX_REFRESH_TOKENS_PER_KEY = 20;
165
378
  var accessTokens = /* @__PURE__ */ new Map();
166
379
  setInterval(() => {
167
380
  const now = Date.now();
@@ -171,15 +384,6 @@ setInterval(() => {
171
384
  for (const [id, client] of registeredClients) {
172
385
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
173
386
  }
174
- for (const [token, entry] of refreshTokens) {
175
- if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
176
- }
177
- if (refreshTokens.size > MAX_REFRESH_TOKENS) {
178
- const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
179
- for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
180
- refreshTokens.delete(sorted[i][0]);
181
- }
182
- }
183
387
  for (const [token, entry] of accessTokens) {
184
388
  if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
185
389
  }
@@ -415,12 +619,22 @@ ${appLogoStyles}
415
619
  color:var(--fg-bright);opacity:0;
416
620
  }
417
621
  .panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
418
- .ok-sentence{
419
- margin:20px auto 0;font-size:16px;line-height:1.6;color:var(--fg2);
622
+ .ok-lead{
623
+ margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
420
624
  opacity:0;
421
- display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;
422
625
  }
423
- .panel:not([hidden]) .ok-sentence{animation:rise 600ms ease-out 520ms forwards}
626
+ .panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
627
+ .ok-phrase-row{
628
+ margin-top:14px;display:flex;align-items:center;justify-content:center;
629
+ opacity:0;
630
+ }
631
+ .panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
632
+ .cmd.is-copied .cmd-quote-part{display:none}
633
+ .success-cta-wrap{
634
+ margin-top:48px;width:100%;opacity:0;
635
+ }
636
+ .panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
637
+ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
424
638
 
425
639
  .cmd{
426
640
  display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
@@ -441,16 +655,6 @@ ${appLogoStyles}
441
655
  .cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
442
656
  .cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
443
657
 
444
- .return-link{
445
- display:block;margin:36px auto 0;width:fit-content;
446
- font-family:var(--font-mono);font-size:11px;letter-spacing:0.18em;
447
- text-transform:uppercase;color:var(--fg4);text-decoration:none;
448
- border-bottom:1px dotted currentColor;padding-bottom:2px;
449
- opacity:0;transition:color 140ms;
450
- }
451
- .panel:not([hidden]) .return-link{animation:rise 600ms ease-out 760ms forwards}
452
- .return-link:hover{color:var(--fg2)}
453
-
454
658
  /* error specifics */
455
659
  .err-title{
456
660
  font-family:var(--font-display);font-weight:600;
@@ -514,7 +718,7 @@ ${appLogoStyles}
514
718
  }
515
719
  function providerDisplayName(clientName) {
516
720
  const name = (clientName ?? "").trim();
517
- if (!name) return "your agent";
721
+ if (!name) return "your assistant";
518
722
  return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
519
723
  }
520
724
  function successPanelInner(workspaceName, redirectUrl, providerName) {
@@ -536,18 +740,19 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
536
740
  <div class="orb-core"><div class="orb-dot"></div></div>
537
741
  </div>
538
742
  <div class="eyebrow success"><span class="dot"></span>Connected</div>
539
- <h1 class="ok-title">Product Brain is live.</h1>
540
- <p class="ok-sentence">
541
- <span>Then say</span>
542
- <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy 'Start PB' and return to ${esc(providerName)}">
543
- <span data-cmd-text>Start PB</span>
743
+ <h1 class="ok-title">Product Brain is Live</h1>
744
+ <p class="ok-lead">Return to your assistant, then say</p>
745
+ <div class="ok-phrase-row">
746
+ <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy &quot;Start PB&quot; and return to ${esc(providerName)}">
747
+ <span class="cmd-quote-part" aria-hidden="true">&ldquo;</span><span data-cmd-text>Start PB</span><span class="cmd-quote-part" aria-hidden="true">&rdquo;</span>
544
748
  <span class="cmd-icon" aria-hidden="true">
545
749
  <svg data-cmd-svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>
546
750
  </span>
547
751
  </button>
548
- <span>in <span data-provider>${esc(providerName)}</span>.</span>
549
- </p>
550
- <a class="return-link" href="${esc(redirectUrl)}" data-return-link>Return to <span data-provider>${esc(providerName)}</span> &rarr;</a>
752
+ </div>
753
+ <div class="success-cta-wrap">
754
+ <a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
755
+ </div>
551
756
  <!-- workspace name retained as data hook for tests, hidden from view -->
552
757
  <span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
553
758
  }
@@ -579,7 +784,7 @@ var cmdScript = `
579
784
  pill.classList.add('is-copied');
580
785
  if(textEl)textEl.textContent='Copied';
581
786
  if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
582
- setTimeout(function(){if(redirectUrl)window.location.href=redirectUrl},900);
787
+ setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
583
788
  };
584
789
  try{
585
790
  if(navigator.clipboard&&navigator.clipboard.writeText){
@@ -677,7 +882,7 @@ ${cmdScript}
677
882
  function showSuccess(workspaceName,redirectUrl,providerName){
678
883
  var tpl=document.getElementById('tpl-connected');
679
884
  var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
680
- var safeProv=String(providerName||'your agent').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
885
+ var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
681
886
  var safeUrl=String(redirectUrl||'').replace(/"/g,'&quot;').replace(/[<>]/g,function(c){return{'<':'&lt;','>':'&gt;'}[c]});
682
887
  var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
683
888
  pOk.innerHTML=html;
@@ -865,7 +1070,7 @@ app.post(
865
1070
  } catch {
866
1071
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
867
1072
  }
868
- const code = randomUUID();
1073
+ const code = randomUUID2();
869
1074
  pendingCodes.set(code, {
870
1075
  apiKey: api_key,
871
1076
  codeChallenge: code_challenge,
@@ -885,42 +1090,13 @@ app.post(
885
1090
  }
886
1091
  );
887
1092
  function issueTokens(apiKey) {
888
- const now = Date.now();
889
- const refreshToken = `pb_rt_${randomUUID()}`;
890
- let perKeyCount = 0;
891
- let oldestKeyForApiKey = null;
892
- let oldestAtForApiKey = Infinity;
893
- for (const [k, v] of refreshTokens) {
894
- if (v.apiKey === apiKey) {
895
- perKeyCount++;
896
- if (v.createdAt < oldestAtForApiKey) {
897
- oldestAtForApiKey = v.createdAt;
898
- oldestKeyForApiKey = k;
899
- }
900
- }
901
- }
902
- if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
903
- refreshTokens.delete(oldestKeyForApiKey);
904
- }
905
- if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
906
- let oldestKey = null;
907
- let oldestAt = Infinity;
908
- for (const [k, v] of refreshTokens) {
909
- if (v.createdAt < oldestAt) {
910
- oldestAt = v.createdAt;
911
- oldestKey = k;
912
- }
913
- }
914
- if (oldestKey) refreshTokens.delete(oldestKey);
915
- }
916
- refreshTokens.set(refreshToken, { apiKey, createdAt: now });
917
1093
  return {
918
1094
  access_token: apiKey,
919
1095
  token_type: "Bearer",
920
1096
  // 1-year TTL: actual validity enforced by Convex, not by expiry clock.
921
1097
  // Long TTL prevents unnecessary refresh cycles after restarts.
922
1098
  expires_in: 365 * 24 * 3600,
923
- refresh_token: refreshToken
1099
+ refresh_token: signRefreshToken(apiKey)
924
1100
  };
925
1101
  }
926
1102
  app.post(
@@ -931,19 +1107,15 @@ app.post(
931
1107
  (req, res) => {
932
1108
  const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
933
1109
  if (grant_type === "refresh_token") {
934
- const entry = refreshTokens.get(refresh_token);
935
- if (!entry) {
936
- res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
937
- return;
938
- }
939
- if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
940
- refreshTokens.delete(refresh_token);
941
- res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
1110
+ const verified = verifyRefreshToken(refresh_token);
1111
+ if (!verified) {
1112
+ res.status(400).json({
1113
+ error: "invalid_grant",
1114
+ error_description: "Invalid or expired refresh token"
1115
+ });
942
1116
  return;
943
1117
  }
944
- const apiKey = entry.apiKey;
945
- refreshTokens.delete(refresh_token);
946
- res.json(issueTokens(apiKey));
1118
+ res.json(issueTokens(verified.apiKey));
947
1119
  return;
948
1120
  }
949
1121
  if (grant_type !== "authorization_code") {
@@ -973,85 +1145,63 @@ var mcpLimiter = rateLimit({
973
1145
  max: 120,
974
1146
  standardHeaders: true,
975
1147
  legacyHeaders: false,
976
- message: { error: "Too many requests. Try again later." }
1148
+ handler: (req, res) => {
1149
+ const r = req.rateLimit;
1150
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1151
+ send429(res, buildRateLimitError({
1152
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1153
+ limit: r?.limit ?? 120,
1154
+ current: r?.used ?? 0,
1155
+ id: req.body?.id ?? null
1156
+ // mirror the JSON-RPC id when a POST body is present
1157
+ }));
1158
+ }
977
1159
  });
978
1160
  var authFailures = /* @__PURE__ */ new Map();
979
- var AUTH_FAILURE_MAX = 10;
980
- var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
981
- var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
982
- var MAX_AUTH_FAILURE_ENTRIES = 1e4;
983
- function checkAuthBlock(ip) {
984
- const rec = authFailures.get(ip);
985
- if (!rec) return false;
986
- return rec.blockedUntil > Date.now();
987
- }
988
- function recordAuthFailure(ip) {
989
- const now = Date.now();
990
- const rec = authFailures.get(ip);
991
- if (!rec) {
992
- authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
993
- return;
994
- }
995
- if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
996
- rec.count = 1;
997
- rec.firstFailure = now;
998
- rec.blockedUntil = 0;
999
- } else {
1000
- rec.count++;
1001
- if (rec.count >= AUTH_FAILURE_MAX) {
1002
- rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
1003
- }
1004
- }
1005
- }
1006
1161
  app.get("/health", (_req, res) => {
1007
1162
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
1008
1163
  });
1009
1164
  var sessions = /* @__PURE__ */ new Map();
1010
1165
  var SESSION_TTL_MS = 30 * 60 * 1e3;
1011
1166
  var MAX_SESSIONS = 200;
1012
- var MAX_SESSIONS_PER_KEY = 5;
1167
+ var EVICT_GRACE_MS = 6e4;
1168
+ var MAX_SESSIONS_PER_KEY = Math.max(
1169
+ 1,
1170
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1171
+ );
1013
1172
  function evictStaleSessions() {
1014
1173
  const now = Date.now();
1015
1174
  for (const [id, entry] of sessions) {
1016
1175
  if (now - entry.lastAccess > SESSION_TTL_MS) {
1017
- logSessionLifecycle("session_deleted", id, "ttl");
1176
+ logSessionLifecycle(
1177
+ "session_deleted",
1178
+ id,
1179
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1180
+ );
1018
1181
  entry.transport.close().catch(() => {
1019
1182
  });
1020
1183
  sessions.delete(id);
1021
1184
  }
1022
1185
  }
1023
1186
  if (sessions.size > MAX_SESSIONS) {
1024
- const sorted = [...sessions.entries()].sort(
1025
- (a, b) => a[1].lastAccess - b[1].lastAccess
1026
- );
1027
- for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
1187
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1188
+ const overflow = sessions.size - MAX_SESSIONS;
1189
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
1028
1190
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
1029
1191
  sorted[i][1].transport.close().catch(() => {
1030
1192
  });
1031
1193
  sessions.delete(sorted[i][0]);
1032
1194
  }
1033
- }
1034
- }
1035
- setInterval(evictStaleSessions, 6e4);
1036
- function extractBearerKey(req) {
1037
- const header = req.headers?.authorization;
1038
- if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
1039
- const token = header.slice(7).trim();
1040
- if (token.startsWith("pb_sk_")) {
1041
- return token;
1042
- }
1043
- if (token.startsWith("pb_at_")) {
1044
- const entry = accessTokens.get(token);
1045
- if (!entry) return null;
1046
- const now = Date.now();
1047
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
1048
- accessTokens.delete(token);
1049
- return null;
1195
+ if (sessions.size > MAX_SESSIONS) {
1196
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1197
+ process.stderr.write(
1198
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1199
+ `
1200
+ );
1050
1201
  }
1051
- return entry.apiKey;
1052
1202
  }
1053
- return null;
1054
1203
  }
1204
+ setInterval(evictStaleSessions, 6e4);
1055
1205
  function send401(req, res) {
1056
1206
  const base = baseUrl(req);
1057
1207
  res.status(401).set(
@@ -1074,17 +1224,21 @@ function logSessionLifecycle(event, sessionId, reason) {
1074
1224
  }
1075
1225
  app.post("/mcp", mcpLimiter, async (req, res) => {
1076
1226
  const reqIp = req.ip ?? "unknown";
1077
- if (checkAuthBlock(reqIp)) {
1078
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1227
+ const nowTs = Date.now();
1228
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1229
+ const rec = authFailures.get(reqIp);
1230
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1231
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1079
1232
  return;
1080
1233
  }
1081
- const apiKey = extractBearerKey(req);
1082
- if (!apiKey) {
1234
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1235
+ if (auth.status !== "ok") {
1083
1236
  logRequest("POST", "auth_fail");
1084
- recordAuthFailure(reqIp);
1237
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1085
1238
  send401(req, res);
1086
1239
  return;
1087
1240
  }
1241
+ const apiKey = auth.apiKey;
1088
1242
  const sessionId = req.headers["mcp-session-id"];
1089
1243
  const reqStart = Date.now();
1090
1244
  try {
@@ -1099,41 +1253,91 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1099
1253
  });
1100
1254
  return;
1101
1255
  }
1102
- entry.lastAccess = Date.now();
1103
- await entry.transport.handleRequest(req, res, req.body);
1104
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1256
+ try {
1257
+ entry.inFlight++;
1258
+ entry.lastAccess = Date.now();
1259
+ await entry.transport.handleRequest(req, res, req.body);
1260
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1261
+ } finally {
1262
+ entry.inFlight--;
1263
+ entry.lastAccess = Date.now();
1264
+ }
1105
1265
  } else if (!sessionId && isInitializeRequest(req.body)) {
1106
1266
  const keyH = hashKey(apiKey);
1107
- let keySessionCount = 0;
1108
- for (const entry of sessions.values()) {
1109
- if (entry.keyHash === keyH) keySessionCount++;
1110
- }
1111
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
1112
- res.status(429).json({
1113
- jsonrpc: "2.0",
1114
- error: { code: -32e3, message: "Too many sessions for this API key" },
1115
- id: null
1267
+ const plan = planKeyAdmission(
1268
+ sessions,
1269
+ keyH,
1270
+ MAX_SESSIONS_PER_KEY,
1271
+ Date.now(),
1272
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1273
+ );
1274
+ for (const sid2 of plan.evict) {
1275
+ const victim = sessions.get(sid2);
1276
+ sessions.delete(sid2);
1277
+ victim?.transport.close().catch(() => {
1116
1278
  });
1279
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1280
+ }
1281
+ if (!plan.admit) {
1282
+ send429(res, buildSessionCapError({
1283
+ retryAfterSeconds: plan.retryAfterSeconds,
1284
+ limit: MAX_SESSIONS_PER_KEY,
1285
+ current: countKeySessions(sessions, keyH),
1286
+ poll: plan.retryIsPoll,
1287
+ id: req.body?.id ?? null
1288
+ }));
1117
1289
  return;
1118
1290
  }
1291
+ const sid = randomUUID2();
1292
+ let initialized = false;
1119
1293
  const transport = new StreamableHTTPServerTransport({
1120
- sessionIdGenerator: () => randomUUID(),
1121
- onsessioninitialized: (sid) => {
1122
- sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
1123
- logSessionLifecycle("session_created", sid);
1294
+ sessionIdGenerator: () => sid,
1295
+ onsessioninitialized: (s) => {
1296
+ initialized = true;
1297
+ logSessionLifecycle("session_created", s);
1124
1298
  }
1299
+ // log only — no map write
1125
1300
  });
1301
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1302
+ sessions.set(sid, reserved);
1126
1303
  transport.onclose = () => {
1127
- const sid = transport.sessionId;
1128
- if (sid) {
1129
- logSessionLifecycle("session_deleted", sid, "onclose");
1130
- sessions.delete(sid);
1304
+ const s = transport.sessionId ?? sid;
1305
+ if (sessions.has(s)) {
1306
+ sessions.delete(s);
1307
+ logSessionLifecycle("session_deleted", s, "onclose");
1131
1308
  }
1132
1309
  };
1133
- const server = createProductBrainServer();
1134
- await server.connect(transport);
1135
- await transport.handleRequest(req, res, req.body);
1136
- logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1310
+ try {
1311
+ const server = createProductBrainServer();
1312
+ await server.connect(transport);
1313
+ await transport.handleRequest(req, res, req.body);
1314
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1315
+ } catch (e) {
1316
+ transport.onclose = void 0;
1317
+ sessions.delete(sid);
1318
+ throw e;
1319
+ } finally {
1320
+ if (!initialized) {
1321
+ transport.onclose = void 0;
1322
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1323
+ } else {
1324
+ const e = sessions.get(sid);
1325
+ if (e) {
1326
+ e.inFlight--;
1327
+ e.lastAccess = Date.now();
1328
+ }
1329
+ }
1330
+ }
1331
+ } else if (sessionId) {
1332
+ process.stderr.write(
1333
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
1334
+ `
1335
+ );
1336
+ res.status(404).json({
1337
+ jsonrpc: "2.0",
1338
+ error: { code: -32001, message: "Session not found \u2014 re-initialise" },
1339
+ id: null
1340
+ });
1137
1341
  } else {
1138
1342
  process.stderr.write(
1139
1343
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
@@ -1159,20 +1363,32 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1159
1363
  });
1160
1364
  app.get("/mcp", mcpLimiter, async (req, res) => {
1161
1365
  const reqIp = req.ip ?? "unknown";
1162
- if (checkAuthBlock(reqIp)) {
1163
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1366
+ const nowTs = Date.now();
1367
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1368
+ const rec = authFailures.get(reqIp);
1369
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1370
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1164
1371
  return;
1165
1372
  }
1166
- const apiKey = extractBearerKey(req);
1167
- if (!apiKey) {
1373
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1374
+ if (auth.status !== "ok") {
1168
1375
  logRequest("GET", "auth_fail");
1169
- recordAuthFailure(reqIp);
1376
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1170
1377
  send401(req, res);
1171
1378
  return;
1172
1379
  }
1380
+ const apiKey = auth.apiKey;
1173
1381
  const sessionId = req.headers["mcp-session-id"];
1174
- if (!sessionId || !sessions.has(sessionId)) {
1175
- res.status(400).send("Invalid or missing session ID");
1382
+ if (!sessionId) {
1383
+ res.status(400).send("Missing Mcp-Session-Id header");
1384
+ return;
1385
+ }
1386
+ if (!sessions.has(sessionId)) {
1387
+ process.stderr.write(
1388
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
1389
+ `
1390
+ );
1391
+ res.status(404).send("Session not found \u2014 re-initialise");
1176
1392
  return;
1177
1393
  }
1178
1394
  try {
@@ -1196,20 +1412,32 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1196
1412
  });
1197
1413
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1198
1414
  const reqIp = req.ip ?? "unknown";
1199
- if (checkAuthBlock(reqIp)) {
1200
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1415
+ const nowTs = Date.now();
1416
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1417
+ const rec = authFailures.get(reqIp);
1418
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1419
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1201
1420
  return;
1202
1421
  }
1203
- const apiKey = extractBearerKey(req);
1204
- if (!apiKey) {
1422
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1423
+ if (auth.status !== "ok") {
1205
1424
  logRequest("DELETE", "auth_fail");
1206
- recordAuthFailure(reqIp);
1425
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1207
1426
  send401(req, res);
1208
1427
  return;
1209
1428
  }
1429
+ const apiKey = auth.apiKey;
1210
1430
  const sessionId = req.headers["mcp-session-id"];
1211
- if (!sessionId || !sessions.has(sessionId)) {
1212
- res.status(400).send("Invalid or missing session ID");
1431
+ if (!sessionId) {
1432
+ res.status(400).send("Missing Mcp-Session-Id header");
1433
+ return;
1434
+ }
1435
+ if (!sessions.has(sessionId)) {
1436
+ process.stderr.write(
1437
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
1438
+ `
1439
+ );
1440
+ res.status(404).send("Session not found \u2014 re-initialise");
1213
1441
  return;
1214
1442
  }
1215
1443
  try {