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

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,23 @@
1
1
  import {
2
- DEFAULT_CLOUD_URL,
3
2
  SERVER_VERSION,
4
- bootstrapHttp,
5
3
  createProductBrainServer,
6
- getKeyState,
7
- hashKey,
8
- initFeatureFlags,
9
- runWithAuth
10
- } from "./chunk-VHL3Q5K6.js";
4
+ initFeatureFlags
5
+ } from "./chunk-NCTCZQHK.js";
11
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ apiKeysUrl,
9
+ bootstrapHttp,
10
+ forgetStreamLifecycle,
11
+ getKeyState,
12
12
  getPostHogClient,
13
+ hashKey,
13
14
  initAnalytics,
15
+ runWithAuth,
14
16
  shutdownAnalytics
15
- } from "./chunk-YMF3IQ5E.js";
17
+ } from "./chunk-Q77SFDBZ.js";
16
18
 
17
19
  // src/http.ts
18
- import { createHash, randomUUID as randomUUID2 } from "crypto";
20
+ import { createHash, randomUUID } from "crypto";
19
21
  import express from "express";
20
22
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
23
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -54,61 +56,252 @@ function appLogoMarkup(opts = {}) {
54
56
  return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
55
57
  }
56
58
 
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
- );
59
+ // src/sessionCap.ts
60
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
61
+ if (cap < 1) {
62
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
68
63
  }
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()
64
+ const { ttlMs, graceMs } = opts;
65
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
66
+ const keyEntries = [];
67
+ for (const [id, session] of sessions2) {
68
+ if (session.keyHash === keyHash) {
69
+ keyEntries.push([id, session]);
70
+ }
71
+ }
72
+ const evictSet = /* @__PURE__ */ new Set();
73
+ for (const [id, session] of keyEntries) {
74
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
75
+ evictSet.add(id);
76
+ }
77
+ }
78
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
79
+ if (survivors.length >= cap) {
80
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
81
+ for (const [id] of idleSurvivors) {
82
+ if (survivors.length < cap) break;
83
+ evictSet.add(id);
84
+ survivors = survivors.filter(([sid]) => sid !== id);
85
+ }
86
+ }
87
+ const evict = Array.from(evictSet);
88
+ const admit = survivors.length < cap;
89
+ let retryAfterSeconds = 0;
90
+ let retryIsPoll = false;
91
+ if (!admit) {
92
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
93
+ if (graceBlockers.length > 0) {
94
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
95
+ const msRemaining = graceMs - (now - minLastAccess);
96
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
97
+ } else {
98
+ retryAfterSeconds = 5;
99
+ retryIsPoll = true;
100
+ }
101
+ }
102
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
103
+ }
104
+ function countKeySessions(sessions2, keyHash) {
105
+ let count = 0;
106
+ for (const session of sessions2.values()) {
107
+ if (session.keyHash === keyHash) count++;
108
+ }
109
+ return count;
110
+ }
111
+
112
+ // src/httpErrors.ts
113
+ function clampSeconds(s) {
114
+ return Math.max(0, Math.round(s));
115
+ }
116
+ function build(reason, message, p) {
117
+ const seconds = clampSeconds(p.retryAfterSeconds);
118
+ return {
119
+ status: 429,
120
+ headers: { "Retry-After": String(seconds) },
121
+ body: {
122
+ jsonrpc: "2.0",
123
+ id: p.id ?? null,
124
+ error: {
125
+ code: -32e3,
126
+ message,
127
+ data: {
128
+ reason,
129
+ retryAfterSeconds: seconds,
130
+ limit: p.limit,
131
+ current: p.current
132
+ }
133
+ }
134
+ }
79
135
  };
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;
136
+ }
137
+ function buildRateLimitError(p) {
138
+ const seconds = clampSeconds(p.retryAfterSeconds);
139
+ return build(
140
+ "rate_limited",
141
+ `Too many requests \u2014 retry after ${seconds} s.`,
142
+ p
143
+ );
144
+ }
145
+ function buildAuthLockoutError(p) {
146
+ const seconds = clampSeconds(p.retryAfterSeconds);
147
+ return build(
148
+ "auth_lockout",
149
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
150
+ p
151
+ );
152
+ }
153
+ function buildSessionCapError(p) {
154
+ const seconds = clampSeconds(p.retryAfterSeconds);
155
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
156
+ return build("session_cap", message, p);
157
+ }
158
+ function send429(res, built) {
159
+ if (res.headersSent) return;
160
+ for (const [k, v] of Object.entries(built.headers)) {
161
+ res.setHeader(k, v);
162
+ }
163
+ res.status(built.status).json(built.body);
164
+ }
165
+
166
+ // src/authLockout.ts
167
+ var AUTH_FAILURE_MAX = 10;
168
+ var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
169
+ var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
170
+ var MAX_AUTH_FAILURE_ENTRIES = 1e4;
171
+ function resolveBearerAuth(authorizationHeader) {
172
+ if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
173
+ return { status: "absent" };
96
174
  }
97
- const expectedSig = sign(payloadB64);
98
- if (providedSig.length !== expectedSig.length) return null;
99
- if (!timingSafeEqual(providedSig, expectedSig)) return null;
100
- let payload;
175
+ if (!authorizationHeader.startsWith("Bearer ")) {
176
+ return { status: "rejected" };
177
+ }
178
+ const token = authorizationHeader.slice(7).trim();
179
+ if (token === "") {
180
+ return { status: "rejected" };
181
+ }
182
+ if (token.startsWith("pb_sk_")) {
183
+ return { status: "ok", apiKey: token };
184
+ }
185
+ return { status: "rejected" };
186
+ }
187
+ function checkAuthBlock(failures, ip, now) {
188
+ const rec = failures.get(ip);
189
+ if (!rec) return false;
190
+ return rec.blockedUntil > now;
191
+ }
192
+ function recordAuthFailure(failures, ip, now) {
193
+ const rec = failures.get(ip);
194
+ if (!rec) {
195
+ failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
196
+ return;
197
+ }
198
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
199
+ rec.count = 1;
200
+ rec.firstFailure = now;
201
+ rec.blockedUntil = 0;
202
+ } else {
203
+ rec.count++;
204
+ if (rec.count >= AUTH_FAILURE_MAX) {
205
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
206
+ }
207
+ }
208
+ }
209
+
210
+ // src/lib/oauthRedirectAllowlist.ts
211
+ var DEFAULT_OAUTH_REDIRECT_URIS = [
212
+ "https://claude.ai/api/mcp/auth_callback",
213
+ "https://claude.com/api/mcp/auth_callback",
214
+ "https://chatgpt.com/connector_platform_oauth_redirect"
215
+ ];
216
+ var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
217
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
218
+ var MAX_REDIRECT_URI_LENGTH = 2048;
219
+ var MAX_REGISTRATION_REDIRECTS = 10;
220
+ 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)";
221
+ function exactForm(url) {
222
+ return `${url.origin}${url.pathname}`;
223
+ }
224
+ function parseRedirectUriAllowlist(raw) {
225
+ if (!raw) return { uris: [], configError: null };
226
+ const uris = [];
227
+ for (const part of raw.split(",")) {
228
+ const trimmed = part.trim();
229
+ if (!trimmed) continue;
230
+ let url = null;
231
+ try {
232
+ url = trimmed.includes("://") ? new URL(trimmed) : null;
233
+ } catch {
234
+ url = null;
235
+ }
236
+ const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
237
+ if (!valid || url === null) {
238
+ return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
239
+ }
240
+ uris.push(exactForm(url));
241
+ }
242
+ return { uris, configError: null };
243
+ }
244
+ function resolveOAuthRedirectAllowlist(opts = {}) {
245
+ const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
246
+ return {
247
+ uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
248
+ configError: parsed.configError
249
+ };
250
+ }
251
+ function checkRedirectUri(redirectUri, allowlist) {
252
+ if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
253
+ if (typeof redirectUri !== "string" || redirectUri.length === 0) {
254
+ return "redirect_uri is required";
255
+ }
256
+ if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
257
+ return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
258
+ }
259
+ let url;
101
260
  try {
102
- const json = Buffer.from(payloadB64, "base64url").toString("utf8");
103
- payload = JSON.parse(json);
261
+ url = new URL(redirectUri);
104
262
  } catch {
105
- return null;
263
+ return "redirect_uri is not a valid absolute URL";
264
+ }
265
+ if (url.username || url.password) return "redirect_uri must not contain credentials";
266
+ if (url.hash) return "redirect_uri must not contain a fragment";
267
+ const host = url.hostname.toLowerCase();
268
+ if (url.protocol === "http:") {
269
+ return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
270
+ }
271
+ if (url.protocol === "https:") {
272
+ if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
273
+ if (url.search) return "redirect_uri must not contain a query";
274
+ const exact = exactForm(url);
275
+ 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)";
276
+ }
277
+ return "redirect_uri scheme is not allowed (use https, or http on loopback)";
278
+ }
279
+ function checkRegistrationRedirects(redirectUris, allowlist) {
280
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
281
+ if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
282
+ return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
106
283
  }
107
- if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
108
- return null;
284
+ for (const uri of redirectUris) {
285
+ const reason = checkRedirectUri(uri, allowlist);
286
+ if (reason) return reason;
109
287
  }
110
- if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
111
- return { apiKey: payload.k };
288
+ return null;
289
+ }
290
+
291
+ // src/lib/oauthAuthCode.ts
292
+ function pendingCodeRefusal(pending, redirectUri, now) {
293
+ if (!pending) return "unknown_code";
294
+ if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
295
+ if (now > pending.expiresAt) return "expired";
296
+ return null;
297
+ }
298
+ function tokenResponse(apiKey) {
299
+ return {
300
+ access_token: apiKey,
301
+ token_type: "Bearer",
302
+ // 1 year: validity is enforced by Convex on every call, not by this clock.
303
+ expires_in: 365 * 24 * 3600
304
+ };
112
305
  }
113
306
 
114
307
  // src/http.ts
@@ -125,6 +318,7 @@ var app = express();
125
318
  app.set("trust proxy", 1);
126
319
  app.use(express.json());
127
320
  var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
321
+ var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
128
322
  app.use((_req, res, next) => {
129
323
  const origin = _req.headers.origin;
130
324
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
@@ -135,7 +329,7 @@ app.use((_req, res, next) => {
135
329
  "Access-Control-Allow-Headers",
136
330
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
137
331
  );
138
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
332
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
139
333
  if (_req.method === "OPTIONS") {
140
334
  res.status(204).end();
141
335
  return;
@@ -159,7 +353,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
159
353
  token_endpoint: `${base}/oauth/token`,
160
354
  registration_endpoint: `${base}/register`,
161
355
  response_types_supported: ["code"],
162
- grant_types_supported: ["authorization_code", "refresh_token"],
356
+ grant_types_supported: ["authorization_code"],
357
+ // TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
163
358
  code_challenge_methods_supported: ["S256"],
164
359
  token_endpoint_auth_methods_supported: ["none"],
165
360
  scopes_supported: ["mcp:tools", "mcp:resources"]
@@ -187,14 +382,12 @@ app.post(
187
382
  return;
188
383
  }
189
384
  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
- });
385
+ const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
386
+ if (refusal) {
387
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
195
388
  return;
196
389
  }
197
- const clientId = `pb_client_${randomUUID2()}`;
390
+ const clientId = `pb_client_${randomUUID()}`;
198
391
  const client = {
199
392
  client_id: clientId,
200
393
  redirect_uris,
@@ -213,9 +406,6 @@ app.post(
213
406
  }
214
407
  );
215
408
  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
409
  setInterval(() => {
220
410
  const now = Date.now();
221
411
  for (const [code, auth] of pendingCodes) {
@@ -224,9 +414,6 @@ setInterval(() => {
224
414
  for (const [id, client] of registeredClients) {
225
415
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
226
416
  }
227
- for (const [token, entry] of accessTokens) {
228
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
229
- }
230
417
  for (const [ip, rec] of authFailures) {
231
418
  if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
232
419
  authFailures.delete(ip);
@@ -609,7 +796,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
609
796
  <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
610
797
  <div class="err-actions">
611
798
  <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>
799
+ <a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
613
800
  </div>`;
614
801
  }
615
802
  var cmdScript = `
@@ -657,7 +844,7 @@ function authorizeFormPage(params) {
657
844
  <div class="hint" id="hint" hidden></div>
658
845
  <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
659
846
  </form>
660
- <div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
847
+ <div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
661
848
  </div>
662
849
 
663
850
  <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
@@ -796,6 +983,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
796
983
  }
797
984
  app.get("/authorize", authLimiter, (req, res) => {
798
985
  const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
986
+ const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
987
+ if (refusal) {
988
+ res.status(400).json({ error: "invalid_request", error_description: refusal });
989
+ return;
990
+ }
799
991
  const cid = String(client_id ?? "");
800
992
  const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
801
993
  res.type("html").send(authorizeFormPage({
@@ -837,28 +1029,22 @@ app.post(
837
1029
  return;
838
1030
  }
839
1031
  if (!client_id) {
840
- res.status(400).json({
841
- error: "invalid_request",
842
- error_description: "client_id is required"
843
- });
1032
+ res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
1033
+ return;
1034
+ }
1035
+ const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1036
+ if (redirectRefusal) {
1037
+ res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
844
1038
  return;
845
1039
  }
846
1040
  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
- });
1041
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
1042
+ res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
860
1043
  return;
861
1044
  }
1045
+ registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
1046
+ process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
1047
+ `);
862
1048
  }
863
1049
  const client = registeredClients.get(client_id);
864
1050
  if (!client.redirect_uris.includes(redirect_uri)) {
@@ -875,6 +1061,7 @@ app.post(
875
1061
  const candidates = [primaryUrl, ...fallbackUrls];
876
1062
  let foundUrl;
877
1063
  let anyDefinitiveReject = false;
1064
+ let rateLimitedRetryAfter;
878
1065
  for (const url2 of candidates) {
879
1066
  let checkData = null;
880
1067
  try {
@@ -892,9 +1079,21 @@ app.post(
892
1079
  foundUrl = checkData.deploymentUrl ?? url2;
893
1080
  break;
894
1081
  }
1082
+ if (checkData.error === "rate_limited") {
1083
+ rateLimitedRetryAfter = checkData.retryAfterSeconds;
1084
+ continue;
1085
+ }
895
1086
  anyDefinitiveReject = true;
896
1087
  }
897
1088
  if (!foundUrl) {
1089
+ if (rateLimitedRetryAfter != null && !anyDefinitiveReject) {
1090
+ sendError(
1091
+ "Rate limited",
1092
+ `This API key is valid but temporarily rate limited. Try again in ${rateLimitedRetryAfter}s.`,
1093
+ 429
1094
+ );
1095
+ return;
1096
+ }
898
1097
  if (anyDefinitiveReject) {
899
1098
  sendError(
900
1099
  "Key not recognized",
@@ -910,7 +1109,7 @@ app.post(
910
1109
  } catch {
911
1110
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
912
1111
  }
913
- const code = randomUUID2();
1112
+ const code = randomUUID();
914
1113
  pendingCodes.set(code, {
915
1114
  apiKey: api_key,
916
1115
  codeChallenge: code_challenge,
@@ -929,41 +1128,25 @@ app.post(
929
1128
  }
930
1129
  }
931
1130
  );
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
1131
  app.post(
943
1132
  "/oauth/token",
944
1133
  authLimiter,
945
1134
  express.urlencoded({ extended: false }),
946
1135
  express.json(),
947
1136
  (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
- }
1137
+ const { grant_type, code, code_verifier, redirect_uri } = req.body;
961
1138
  if (grant_type !== "authorization_code") {
962
1139
  res.status(400).json({ error: "unsupported_grant_type" });
963
1140
  return;
964
1141
  }
965
1142
  const pending = pendingCodes.get(code);
966
- if (!pending || pending.redirectUri !== redirect_uri) {
1143
+ const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
1144
+ if (codeRefusal === "expired") {
1145
+ pendingCodes.delete(code);
1146
+ res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
1147
+ return;
1148
+ }
1149
+ if (codeRefusal || !pending) {
967
1150
  res.status(400).json({ error: "invalid_grant" });
968
1151
  return;
969
1152
  }
@@ -977,7 +1160,7 @@ app.post(
977
1160
  return;
978
1161
  }
979
1162
  pendingCodes.delete(code);
980
- res.json(issueTokens(pending.apiKey));
1163
+ res.json(tokenResponse(pending.apiKey));
981
1164
  }
982
1165
  );
983
1166
  var mcpLimiter = rateLimit({
@@ -985,85 +1168,63 @@ var mcpLimiter = rateLimit({
985
1168
  max: 120,
986
1169
  standardHeaders: true,
987
1170
  legacyHeaders: false,
988
- message: { error: "Too many requests. Try again later." }
1171
+ handler: (req, res) => {
1172
+ const r = req.rateLimit;
1173
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1174
+ send429(res, buildRateLimitError({
1175
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1176
+ limit: r?.limit ?? 120,
1177
+ current: r?.used ?? 0,
1178
+ id: req.body?.id ?? null
1179
+ // mirror the JSON-RPC id when a POST body is present
1180
+ }));
1181
+ }
989
1182
  });
990
1183
  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
1184
  app.get("/health", (_req, res) => {
1019
1185
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
1020
1186
  });
1021
1187
  var sessions = /* @__PURE__ */ new Map();
1022
1188
  var SESSION_TTL_MS = 30 * 60 * 1e3;
1023
1189
  var MAX_SESSIONS = 200;
1024
- var MAX_SESSIONS_PER_KEY = 5;
1190
+ var EVICT_GRACE_MS = 6e4;
1191
+ var MAX_SESSIONS_PER_KEY = Math.max(
1192
+ 1,
1193
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1194
+ );
1025
1195
  function evictStaleSessions() {
1026
1196
  const now = Date.now();
1027
1197
  for (const [id, entry] of sessions) {
1028
1198
  if (now - entry.lastAccess > SESSION_TTL_MS) {
1029
- logSessionLifecycle("session_deleted", id, "ttl");
1199
+ logSessionLifecycle(
1200
+ "session_deleted",
1201
+ id,
1202
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1203
+ );
1030
1204
  entry.transport.close().catch(() => {
1031
1205
  });
1032
1206
  sessions.delete(id);
1033
1207
  }
1034
1208
  }
1035
1209
  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++) {
1210
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1211
+ const overflow = sessions.size - MAX_SESSIONS;
1212
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
1040
1213
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
1041
1214
  sorted[i][1].transport.close().catch(() => {
1042
1215
  });
1043
1216
  sessions.delete(sorted[i][0]);
1044
1217
  }
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;
1218
+ if (sessions.size > MAX_SESSIONS) {
1219
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1220
+ process.stderr.write(
1221
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1222
+ `
1223
+ );
1062
1224
  }
1063
- return entry.apiKey;
1064
1225
  }
1065
- return null;
1066
1226
  }
1227
+ setInterval(evictStaleSessions, 6e4);
1067
1228
  function send401(req, res) {
1068
1229
  const base = baseUrl(req);
1069
1230
  res.status(401).set(
@@ -1086,21 +1247,25 @@ function logSessionLifecycle(event, sessionId, reason) {
1086
1247
  }
1087
1248
  app.post("/mcp", mcpLimiter, async (req, res) => {
1088
1249
  const reqIp = req.ip ?? "unknown";
1089
- if (checkAuthBlock(reqIp)) {
1090
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1250
+ const nowTs = Date.now();
1251
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1252
+ const rec = authFailures.get(reqIp);
1253
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1254
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1091
1255
  return;
1092
1256
  }
1093
- const apiKey = extractBearerKey(req);
1094
- if (!apiKey) {
1257
+ const auth = resolveBearerAuth(req.headers?.authorization);
1258
+ if (auth.status !== "ok") {
1095
1259
  logRequest("POST", "auth_fail");
1096
- recordAuthFailure(reqIp);
1260
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1097
1261
  send401(req, res);
1098
1262
  return;
1099
1263
  }
1264
+ const apiKey = auth.apiKey;
1100
1265
  const sessionId = req.headers["mcp-session-id"];
1101
1266
  const reqStart = Date.now();
1102
1267
  try {
1103
- await runWithAuth({ apiKey }, async () => {
1268
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1104
1269
  if (sessionId && sessions.has(sessionId)) {
1105
1270
  const entry = sessions.get(sessionId);
1106
1271
  if (entry.keyHash !== hashKey(apiKey)) {
@@ -1111,41 +1276,82 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1111
1276
  });
1112
1277
  return;
1113
1278
  }
1114
- entry.lastAccess = Date.now();
1115
- await entry.transport.handleRequest(req, res, req.body);
1116
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1279
+ try {
1280
+ entry.inFlight++;
1281
+ entry.lastAccess = Date.now();
1282
+ await entry.transport.handleRequest(req, res, req.body);
1283
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1284
+ } finally {
1285
+ entry.inFlight--;
1286
+ entry.lastAccess = Date.now();
1287
+ }
1117
1288
  } else if (!sessionId && isInitializeRequest(req.body)) {
1118
1289
  const keyH = hashKey(apiKey);
1119
- let keySessionCount = 0;
1120
- for (const entry of sessions.values()) {
1121
- if (entry.keyHash === keyH) keySessionCount++;
1122
- }
1123
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
1124
- res.status(429).json({
1125
- jsonrpc: "2.0",
1126
- error: { code: -32e3, message: "Too many sessions for this API key" },
1127
- id: null
1290
+ const plan = planKeyAdmission(
1291
+ sessions,
1292
+ keyH,
1293
+ MAX_SESSIONS_PER_KEY,
1294
+ Date.now(),
1295
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1296
+ );
1297
+ for (const sid2 of plan.evict) {
1298
+ const victim = sessions.get(sid2);
1299
+ sessions.delete(sid2);
1300
+ victim?.transport.close().catch(() => {
1128
1301
  });
1302
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1303
+ }
1304
+ if (!plan.admit) {
1305
+ send429(res, buildSessionCapError({
1306
+ retryAfterSeconds: plan.retryAfterSeconds,
1307
+ limit: MAX_SESSIONS_PER_KEY,
1308
+ current: countKeySessions(sessions, keyH),
1309
+ poll: plan.retryIsPoll,
1310
+ id: req.body?.id ?? null
1311
+ }));
1129
1312
  return;
1130
1313
  }
1314
+ const sid = randomUUID();
1315
+ let initialized = false;
1131
1316
  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);
1317
+ sessionIdGenerator: () => sid,
1318
+ onsessioninitialized: (s) => {
1319
+ initialized = true;
1320
+ logSessionLifecycle("session_created", s);
1136
1321
  }
1322
+ // log only — no map write
1137
1323
  });
1324
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1325
+ sessions.set(sid, reserved);
1138
1326
  transport.onclose = () => {
1139
- const sid = transport.sessionId;
1140
- if (sid) {
1141
- logSessionLifecycle("session_deleted", sid, "onclose");
1142
- sessions.delete(sid);
1327
+ const s = transport.sessionId ?? sid;
1328
+ forgetStreamLifecycle(keyH, s);
1329
+ if (sessions.has(s)) {
1330
+ sessions.delete(s);
1331
+ logSessionLifecycle("session_deleted", s, "onclose");
1143
1332
  }
1144
1333
  };
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);
1334
+ try {
1335
+ const server = createProductBrainServer();
1336
+ await server.connect(transport);
1337
+ await transport.handleRequest(req, res, req.body);
1338
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1339
+ } catch (e) {
1340
+ transport.onclose = void 0;
1341
+ sessions.delete(sid);
1342
+ throw e;
1343
+ } finally {
1344
+ if (!initialized) {
1345
+ transport.onclose = void 0;
1346
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1347
+ } else {
1348
+ const e = sessions.get(sid);
1349
+ if (e) {
1350
+ e.inFlight--;
1351
+ e.lastAccess = Date.now();
1352
+ }
1353
+ }
1354
+ }
1149
1355
  } else if (sessionId) {
1150
1356
  process.stderr.write(
1151
1357
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
@@ -1181,17 +1387,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1181
1387
  });
1182
1388
  app.get("/mcp", mcpLimiter, async (req, res) => {
1183
1389
  const reqIp = req.ip ?? "unknown";
1184
- if (checkAuthBlock(reqIp)) {
1185
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1390
+ const nowTs = Date.now();
1391
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1392
+ const rec = authFailures.get(reqIp);
1393
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1394
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1186
1395
  return;
1187
1396
  }
1188
- const apiKey = extractBearerKey(req);
1189
- if (!apiKey) {
1397
+ const auth = resolveBearerAuth(req.headers?.authorization);
1398
+ if (auth.status !== "ok") {
1190
1399
  logRequest("GET", "auth_fail");
1191
- recordAuthFailure(reqIp);
1400
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1192
1401
  send401(req, res);
1193
1402
  return;
1194
1403
  }
1404
+ const apiKey = auth.apiKey;
1195
1405
  const sessionId = req.headers["mcp-session-id"];
1196
1406
  if (!sessionId) {
1197
1407
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1206,7 +1416,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1206
1416
  return;
1207
1417
  }
1208
1418
  try {
1209
- await runWithAuth({ apiKey }, async () => {
1419
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1210
1420
  const entry = sessions.get(sessionId);
1211
1421
  if (entry.keyHash !== hashKey(apiKey)) {
1212
1422
  res.status(403).json({
@@ -1226,17 +1436,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1226
1436
  });
1227
1437
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1228
1438
  const reqIp = req.ip ?? "unknown";
1229
- if (checkAuthBlock(reqIp)) {
1230
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1439
+ const nowTs = Date.now();
1440
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1441
+ const rec = authFailures.get(reqIp);
1442
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1443
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1231
1444
  return;
1232
1445
  }
1233
- const apiKey = extractBearerKey(req);
1234
- if (!apiKey) {
1446
+ const auth = resolveBearerAuth(req.headers?.authorization);
1447
+ if (auth.status !== "ok") {
1235
1448
  logRequest("DELETE", "auth_fail");
1236
- recordAuthFailure(reqIp);
1449
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
1237
1450
  send401(req, res);
1238
1451
  return;
1239
1452
  }
1453
+ const apiKey = auth.apiKey;
1240
1454
  const sessionId = req.headers["mcp-session-id"];
1241
1455
  if (!sessionId) {
1242
1456
  res.status(400).send("Missing Mcp-Session-Id header");
@@ -1251,7 +1465,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
1251
1465
  return;
1252
1466
  }
1253
1467
  try {
1254
- await runWithAuth({ apiKey }, async () => {
1468
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
1255
1469
  const entry = sessions.get(sessionId);
1256
1470
  if (entry.keyHash !== hashKey(apiKey)) {
1257
1471
  res.status(403).json({