@productbrain/mcp 0.0.1-beta.18 → 0.0.1-beta.181

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,15 +1,18 @@
1
1
  import {
2
+ DEFAULT_CLOUD_URL,
2
3
  SERVER_VERSION,
3
- createProductBrainServer
4
- } from "./chunk-TUNNDDD7.js";
5
- import {
6
4
  bootstrapHttp,
5
+ createProductBrainServer,
6
+ getKeyState,
7
+ hashKey,
8
+ initFeatureFlags,
7
9
  runWithAuth
8
- } from "./chunk-47LO6K2R.js";
10
+ } from "./chunk-XPFSARXP.js";
9
11
  import {
12
+ getPostHogClient,
10
13
  initAnalytics,
11
14
  shutdownAnalytics
12
- } from "./chunk-XBMI6QHR.js";
15
+ } from "./chunk-YMF3IQ5E.js";
13
16
 
14
17
  // src/http.ts
15
18
  import { createHash, randomUUID } from "crypto";
@@ -19,19 +22,21 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
22
  import rateLimit from "express-rate-limit";
20
23
  bootstrapHttp();
21
24
  initAnalytics();
22
- var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3000", 10);
25
+ initFeatureFlags(getPostHogClient());
26
+ var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
23
27
  function baseUrl(req) {
24
28
  const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
25
29
  const host = req.headers.host ?? `localhost:${PORT}`;
26
30
  return `${proto}://${host}`;
27
31
  }
28
32
  var app = express();
33
+ app.set("trust proxy", 1);
29
34
  app.use(express.json());
30
35
  var ALLOWED_ORIGINS = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean);
31
36
  app.use((_req, res, next) => {
32
37
  const origin = _req.headers.origin;
33
- if (!ALLOWED_ORIGINS || origin && ALLOWED_ORIGINS.includes(origin)) {
34
- res.setHeader("Access-Control-Allow-Origin", origin ?? "*");
38
+ if (ALLOWED_ORIGINS && origin && ALLOWED_ORIGINS.includes(origin)) {
39
+ res.setHeader("Access-Control-Allow-Origin", origin);
35
40
  }
36
41
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
37
42
  res.setHeader(
@@ -62,17 +67,33 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
62
67
  token_endpoint: `${base}/oauth/token`,
63
68
  registration_endpoint: `${base}/register`,
64
69
  response_types_supported: ["code"],
65
- grant_types_supported: ["authorization_code"],
70
+ grant_types_supported: ["authorization_code", "refresh_token"],
66
71
  code_challenge_methods_supported: ["S256"],
67
72
  token_endpoint_auth_methods_supported: ["none"],
68
73
  scopes_supported: ["mcp:tools", "mcp:resources"]
69
74
  });
70
75
  });
76
+ var authLimiter = rateLimit({
77
+ windowMs: 6e4,
78
+ max: 20,
79
+ standardHeaders: true,
80
+ legacyHeaders: false,
81
+ message: { error: "Too many auth requests. Try again later." }
82
+ });
71
83
  var registeredClients = /* @__PURE__ */ new Map();
84
+ var MAX_REGISTERED_CLIENTS = 500;
72
85
  app.post(
73
86
  "/register",
87
+ authLimiter,
74
88
  express.json(),
75
89
  (req, res) => {
90
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
91
+ res.status(503).json({
92
+ error: "server_error",
93
+ error_description: "Registration limit reached. Try again later."
94
+ });
95
+ return;
96
+ }
76
97
  const { redirect_uris, client_name } = req.body;
77
98
  if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
78
99
  res.status(400).json({
@@ -100,6 +121,13 @@ app.post(
100
121
  }
101
122
  );
102
123
  var pendingCodes = /* @__PURE__ */ new Map();
124
+ var ACCESS_TOKEN_TTL = 3600;
125
+ var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
126
+ var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
127
+ var refreshTokens = /* @__PURE__ */ new Map();
128
+ var MAX_REFRESH_TOKENS = 2e3;
129
+ var MAX_REFRESH_TOKENS_PER_KEY = 20;
130
+ var accessTokens = /* @__PURE__ */ new Map();
103
131
  setInterval(() => {
104
132
  const now = Date.now();
105
133
  for (const [code, auth] of pendingCodes) {
@@ -108,63 +136,258 @@ setInterval(() => {
108
136
  for (const [id, client] of registeredClients) {
109
137
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
110
138
  }
139
+ for (const [token, entry] of refreshTokens) {
140
+ if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
141
+ }
142
+ if (refreshTokens.size > MAX_REFRESH_TOKENS) {
143
+ const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
144
+ for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
145
+ refreshTokens.delete(sorted[i][0]);
146
+ }
147
+ }
148
+ for (const [token, entry] of accessTokens) {
149
+ if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
150
+ }
151
+ for (const [ip, rec] of authFailures) {
152
+ if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
153
+ authFailures.delete(ip);
154
+ }
155
+ }
156
+ if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
157
+ const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
158
+ for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
159
+ authFailures.delete(sorted[i][0]);
160
+ }
161
+ }
111
162
  }, 6e4);
112
163
  function esc(s) {
113
164
  return String(s ?? "").replace(
114
- /[&"<>]/g,
115
- (c) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[c]
165
+ /[&"'<>]/g,
166
+ (c) => ({ "&": "&amp;", '"': "&quot;", "'": "&#39;", "<": "&lt;", ">": "&gt;" })[c]
116
167
  );
117
168
  }
118
- app.get("/authorize", (req, res) => {
119
- const { redirect_uri, code_challenge, code_challenge_method, state } = req.query;
120
- res.type("html").send(`<!DOCTYPE html>
169
+ function authPageShell(title, bodyContent, headExtra = "") {
170
+ return `<!DOCTYPE html>
121
171
  <html lang="en"><head>
122
172
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
123
- <title>Authorize \u2014 Product Brain</title>
173
+ <title>${title} \u2014 Product Brain</title>
174
+ ${headExtra}
124
175
  <style>
176
+ :root{--bg:#0d0c10;--surface:#18161e;--border:#2d2840;--border-focus:#7c3aed;--text:#e8e4f0;--muted:#7b7590;--accent:#7c3aed;--accent-h:#6d28d9;--err:#f87171;--ok:#34d399}
125
177
  *{margin:0;padding:0;box-sizing:border-box}
126
- body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#e5e5e5;
127
- display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
128
- .card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:2rem;max-width:400px;width:100%}
129
- h1{font-size:1.2rem;margin-bottom:.25rem}
130
- .sub{color:#999;font-size:.85rem;margin-bottom:1.5rem}
131
- label{display:block;font-size:.85rem;margin-bottom:.4rem;color:#ccc}
132
- input[type=password]{width:100%;padding:.6rem .75rem;background:#111;border:1px solid #444;
133
- border-radius:8px;color:#e5e5e5;font:.85rem/1.4 monospace}
134
- input:focus{outline:none;border-color:#7c3aed}
135
- button{width:100%;padding:.6rem;background:#7c3aed;color:#fff;border:none;
136
- border-radius:8px;font-size:.85rem;cursor:pointer;margin-top:1rem}
137
- button:hover{background:#6d28d9}
138
- .err{color:#ef4444;font-size:.8rem;margin-top:.5rem;display:none}
139
- </style></head><body>
140
- <div class="card">
141
- <h1>Product Brain</h1>
142
- <p class="sub">Enter your API key to connect Claude to your workspace.</p>
143
- <form method="POST" action="/authorize">
178
+ body{font-family:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem}
179
+ body::before{content:'';position:fixed;inset:0;background-image:radial-gradient(circle,#3d3560 1px,transparent 1px);background-size:28px 28px;opacity:.22;pointer-events:none;z-index:0}
180
+ .card{position:relative;z-index:1;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:2rem 2rem 1.75rem;max-width:380px;width:100%}
181
+ </style>
182
+ </head><body>
183
+ <div class="card">${bodyContent}</div>
184
+ </body></html>`;
185
+ }
186
+ function authorizeFormPage(params) {
187
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
188
+ const body = `
189
+ <style>
190
+ .brand{display:flex;align-items:center;gap:.55rem;margin-bottom:1.5rem}
191
+ .brand-icon{width:30px;height:30px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:1rem;flex-shrink:0}
192
+ .brand-name{font-size:.88rem;font-weight:600;letter-spacing:-.01em}
193
+ h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.35rem;line-height:1.3}
194
+ .sub{font-size:.8rem;color:var(--muted);margin-bottom:1.75rem;line-height:1.55}
195
+ label{display:block;font-size:.72rem;font-weight:600;color:#a89fc4;margin-bottom:.4rem;letter-spacing:.04em;text-transform:uppercase}
196
+ .inp-wrap{position:relative}
197
+ input[type=password]{width:100%;padding:.65rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font:.875rem/1.4 'JetBrains Mono','Fira Code',ui-monospace,monospace;outline:none;transition:border-color .15s,box-shadow .15s}
198
+ input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,58,237,.15)}
199
+ input.err{border-color:var(--err);box-shadow:0 0 0 3px rgba(248,113,113,.12)}
200
+ .field-row{display:flex;align-items:center;justify-content:space-between;margin-top:.4rem}
201
+ .hint{font-size:.73rem;color:var(--muted)}
202
+ .hint code{font-size:.72rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
203
+ .get-key{font-size:.73rem;color:var(--accent);text-decoration:none;opacity:.85;transition:opacity .1s}
204
+ .get-key:hover{opacity:1;text-decoration:underline}
205
+ .err-msg{font-size:.73rem;color:var(--err);margin-top:.35rem;display:none}
206
+ .err-msg.show{display:block}
207
+ .btn{width:100%;padding:.7rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.88rem;font-weight:600;cursor:pointer;margin-top:1.25rem;letter-spacing:-.01em;transition:background .15s,transform .1s;display:flex;align-items:center;justify-content:center;gap:.5rem}
208
+ .btn:hover{background:var(--accent-h)}
209
+ .btn:active{transform:scale(.98)}
210
+ .btn:disabled{opacity:.6;cursor:not-allowed;transform:none}
211
+ .spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:none}
212
+ @keyframes spin{to{transform:rotate(360deg)}}
213
+ .divider{height:1px;background:var(--border);margin:1.5rem -2rem}
214
+ .footer{font-size:.7rem;color:var(--muted);text-align:center;padding-top:.5rem;line-height:1.6}
215
+ .footer a{color:var(--muted);text-decoration:underline}
216
+ </style>
217
+ <div class="brand"><div class="brand-icon">&#11041;</div><span class="brand-name">Product Brain</span></div>
218
+ <h1>Connect to Claude</h1>
219
+ <p class="sub">Enter your API key to give Claude access to your workspace.</p>
220
+ <form method="POST" action="/authorize" id="f">
144
221
  <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
145
222
  <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
146
223
  <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
147
224
  <input type="hidden" name="state" value="${esc(state)}">
225
+ <input type="hidden" name="client_id" value="${esc(client_id)}">
148
226
  <label for="k">API Key</label>
149
- <input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus>
150
- <p class="err" id="e">Key must start with pb_sk_</p>
151
- <button type="submit">Authorize</button>
227
+ <div class="inp-wrap">
228
+ <input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus autocomplete="off">
229
+ </div>
230
+ <div class="field-row">
231
+ <span class="hint">Starts with <code>pb_sk_</code></span>
232
+ <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="get-key">No key? Get one &rarr;</a>
233
+ </div>
234
+ <p class="err-msg" id="em">Key must start with <code>pb_sk_</code></p>
235
+ <button type="submit" class="btn" id="sb">
236
+ <span id="bt">Authorize</span>
237
+ <div class="spinner" id="sp"></div>
238
+ </button>
152
239
  </form>
240
+ <div class="divider"></div>
241
+ <p class="footer">Product Brain gives Claude access to your knowledge graph.<br><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">Learn more</a></p>
242
+ <script>
243
+ var f=document.getElementById('f'),k=document.getElementById('k'),em=document.getElementById('em'),sb=document.getElementById('sb'),bt=document.getElementById('bt'),sp=document.getElementById('sp');
244
+ k.addEventListener('input',function(){var v=k.value.trim();if(v&&!v.startsWith('pb_sk_')){k.classList.add('err');em.classList.add('show')}else{k.classList.remove('err');em.classList.remove('show')}});
245
+ f.addEventListener('submit',function(e){var v=k.value.trim();if(!v.startsWith('pb_sk_')){e.preventDefault();k.classList.add('err');em.classList.add('show');k.focus();return}sb.disabled=true;bt.textContent='Verifying\u2026';sp.style.display='block'});
246
+ </script>`;
247
+ return authPageShell("Connect to Claude", body);
248
+ }
249
+ function authorizeSuccessPage(workspaceName, redirectUrl) {
250
+ const safeUrl = JSON.stringify(redirectUrl).replace(/<\/script>/gi, "<\\/script>");
251
+ const body = `
252
+ <style>
253
+ .card{text-align:center;padding:2.5rem 2rem}
254
+ .ring{width:60px;height:60px;background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.25);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.4rem;animation:pop .5s cubic-bezier(.175,.885,.32,1.275) .15s both}
255
+ @keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
256
+ svg{animation:draw .6s ease .5s both}
257
+ @keyframes draw{from{stroke-dashoffset:30}to{stroke-dashoffset:0}}
258
+ h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.3rem}
259
+ .ws{font-size:.8rem;color:var(--ok);font-weight:500;margin-bottom:1.5rem}
260
+ .redirect-msg{font-size:.78rem;color:var(--muted);margin-bottom:1.4rem}
261
+ .dots::after{content:'';animation:dots 1.4s steps(4,end) infinite}
262
+ @keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
263
+ .continue{display:inline-flex;align-items:center;gap:.4rem;padding:.6rem 1.2rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.84rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
264
+ .continue:hover{background:var(--accent-h)}
265
+ @keyframes fadein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
266
+ .card{animation:fadein .35s ease}
267
+ </style>
268
+ <div class="ring">
269
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="30"><polyline points="20 6 9 17 4 12"/></svg>
153
270
  </div>
154
- <script>document.querySelector("form").onsubmit=function(e){
155
- if(!document.getElementById("k").value.startsWith("pb_sk_")){
156
- e.preventDefault();document.getElementById("e").style.display="block"}}</script>
157
- </body></html>`);
271
+ <h1>Claude is connected</h1>
272
+ <p class="ws">${esc(workspaceName)}</p>
273
+ <p class="redirect-msg">Returning to Claude<span class="dots"></span></p>
274
+ <a href="${esc(redirectUrl)}" class="continue">Continue to Claude &rarr;</a>
275
+ <script>setTimeout(function(){window.location.href=${safeUrl}},1800)</script>`;
276
+ return authPageShell("Connected", body);
277
+ }
278
+ function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
279
+ const body = `
280
+ <style>
281
+ .card{text-align:center;padding:2.25rem 2rem}
282
+ .x-ring{width:52px;height:52px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.25rem;font-size:1.4rem;animation:shake .4s ease .1s}
283
+ @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
284
+ h1{font-size:1.2rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem;color:var(--err)}
285
+ .detail{font-size:.8rem;color:var(--muted);line-height:1.6;margin-bottom:1.5rem}
286
+ .detail code{font-size:.75rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
287
+ .actions{display:flex;gap:.75rem;justify-content:center;flex-wrap:wrap}
288
+ .btn-retry{display:inline-flex;align-items:center;padding:.55rem 1.1rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.82rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
289
+ .btn-retry:hover{background:var(--accent-h)}
290
+ .btn-key{display:inline-flex;align-items:center;padding:.55rem 1rem;background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:10px;font-size:.82rem;text-decoration:none;transition:border-color .15s,color .15s}
291
+ .btn-key:hover{border-color:var(--accent);color:var(--text)}
292
+ </style>
293
+ <div class="x-ring">&#10005;</div>
294
+ <h1>${esc(title)}</h1>
295
+ <p class="detail">${trustedDetailHtml}</p>
296
+ <div class="actions">
297
+ <a href="${esc(retryUrl)}" class="btn-retry">&larr; Try again</a>
298
+ <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-key">Get an API key &rarr;</a>
299
+ </div>`;
300
+ return authPageShell("Connection error", body);
301
+ }
302
+ app.get("/authorize", authLimiter, (req, res) => {
303
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
304
+ res.type("html").send(authorizeFormPage({
305
+ redirect_uri: String(redirect_uri ?? ""),
306
+ code_challenge: String(code_challenge ?? ""),
307
+ code_challenge_method: String(code_challenge_method ?? "S256"),
308
+ state: String(state ?? ""),
309
+ client_id: String(client_id ?? "")
310
+ }));
158
311
  });
159
312
  app.post(
160
313
  "/authorize",
314
+ authLimiter,
161
315
  express.urlencoded({ extended: false }),
162
- (req, res) => {
163
- const { api_key, redirect_uri, code_challenge, state } = req.body;
316
+ async (req, res) => {
317
+ const { api_key, redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
318
+ const retryParams = new URLSearchParams({
319
+ redirect_uri: redirect_uri ?? "",
320
+ code_challenge: code_challenge ?? "",
321
+ code_challenge_method: code_challenge_method ?? "S256",
322
+ ...state ? { state } : {},
323
+ ...client_id ? { client_id } : {}
324
+ }).toString();
325
+ const retryUrl = `/authorize?${retryParams}`;
164
326
  if (!api_key?.startsWith("pb_sk_")) {
165
- res.status(400).send("Invalid API key");
327
+ res.type("html").send(authorizeErrorPage(
328
+ "Invalid key format",
329
+ "API keys start with <code>pb_sk_</code>. Check your key and try again.",
330
+ retryUrl
331
+ ));
332
+ return;
333
+ }
334
+ if (!client_id || !registeredClients.has(client_id)) {
335
+ res.status(400).json({
336
+ error: "invalid_request",
337
+ error_description: "Unknown or missing client_id"
338
+ });
166
339
  return;
167
340
  }
341
+ const client = registeredClients.get(client_id);
342
+ if (!client.redirect_uris.includes(redirect_uri)) {
343
+ res.status(400).json({
344
+ error: "invalid_request",
345
+ error_description: "redirect_uri does not match any registered redirect for this client"
346
+ });
347
+ return;
348
+ }
349
+ let workspaceName = "Your Workspace";
350
+ try {
351
+ const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
352
+ const fallbackUrls = (process.env.CONVEX_FALLBACK_URLS ?? "").split(",").map((u) => u.trim().replace(/\/$/, "")).filter(Boolean);
353
+ const candidates = [primaryUrl, ...fallbackUrls];
354
+ let foundUrl;
355
+ let anyDefinitiveReject = false;
356
+ for (const url2 of candidates) {
357
+ let checkData = null;
358
+ try {
359
+ const checkRes = await fetch(`${url2}/api/key-check`, {
360
+ method: "POST",
361
+ headers: { "Authorization": `Bearer ${api_key}`, "Content-Type": "application/json" },
362
+ signal: AbortSignal.timeout(5e3)
363
+ });
364
+ checkData = await checkRes.json();
365
+ } catch {
366
+ continue;
367
+ }
368
+ if (checkData.ok) {
369
+ if (checkData.workspaceName) workspaceName = checkData.workspaceName;
370
+ foundUrl = checkData.deploymentUrl ?? url2;
371
+ break;
372
+ }
373
+ anyDefinitiveReject = true;
374
+ }
375
+ if (!foundUrl) {
376
+ if (anyDefinitiveReject) {
377
+ res.type("html").send(authorizeErrorPage(
378
+ "Key not recognized",
379
+ "This API key wasn't found in Product Brain. Check your API Keys in Studio and try again.",
380
+ retryUrl
381
+ ));
382
+ return;
383
+ }
384
+ process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
385
+ } else {
386
+ getKeyState(api_key).deploymentUrl = foundUrl;
387
+ }
388
+ } catch {
389
+ process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
390
+ }
168
391
  const code = randomUUID();
169
392
  pendingCodes.set(code, {
170
393
  apiKey: api_key,
@@ -175,15 +398,72 @@ app.post(
175
398
  const url = new URL(redirect_uri);
176
399
  url.searchParams.set("code", code);
177
400
  if (state) url.searchParams.set("state", state);
178
- res.redirect(302, url.toString());
401
+ const redirectUrl = url.toString();
402
+ res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl));
179
403
  }
180
404
  );
405
+ function issueTokens(apiKey) {
406
+ const now = Date.now();
407
+ const refreshToken = `pb_rt_${randomUUID()}`;
408
+ let perKeyCount = 0;
409
+ let oldestKeyForApiKey = null;
410
+ let oldestAtForApiKey = Infinity;
411
+ for (const [k, v] of refreshTokens) {
412
+ if (v.apiKey === apiKey) {
413
+ perKeyCount++;
414
+ if (v.createdAt < oldestAtForApiKey) {
415
+ oldestAtForApiKey = v.createdAt;
416
+ oldestKeyForApiKey = k;
417
+ }
418
+ }
419
+ }
420
+ if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
421
+ refreshTokens.delete(oldestKeyForApiKey);
422
+ }
423
+ if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
424
+ let oldestKey = null;
425
+ let oldestAt = Infinity;
426
+ for (const [k, v] of refreshTokens) {
427
+ if (v.createdAt < oldestAt) {
428
+ oldestAt = v.createdAt;
429
+ oldestKey = k;
430
+ }
431
+ }
432
+ if (oldestKey) refreshTokens.delete(oldestKey);
433
+ }
434
+ refreshTokens.set(refreshToken, { apiKey, createdAt: now });
435
+ return {
436
+ access_token: apiKey,
437
+ token_type: "Bearer",
438
+ // 1-year TTL: actual validity enforced by Convex, not by expiry clock.
439
+ // Long TTL prevents unnecessary refresh cycles after restarts.
440
+ expires_in: 365 * 24 * 3600,
441
+ refresh_token: refreshToken
442
+ };
443
+ }
181
444
  app.post(
182
445
  "/oauth/token",
446
+ authLimiter,
183
447
  express.urlencoded({ extended: false }),
184
448
  express.json(),
185
449
  (req, res) => {
186
- const { grant_type, code, code_verifier, redirect_uri } = req.body;
450
+ const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
451
+ if (grant_type === "refresh_token") {
452
+ const entry = refreshTokens.get(refresh_token);
453
+ if (!entry) {
454
+ res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
455
+ return;
456
+ }
457
+ if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
458
+ refreshTokens.delete(refresh_token);
459
+ res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
460
+ return;
461
+ }
462
+ const apiKey = entry.apiKey;
463
+ refreshTokens.delete(refresh_token);
464
+ res.json(issueTokens(apiKey));
465
+ return;
466
+ }
187
467
  if (grant_type !== "authorization_code") {
188
468
  res.status(400).json({ error: "unsupported_grant_type" });
189
469
  return;
@@ -203,11 +483,7 @@ app.post(
203
483
  return;
204
484
  }
205
485
  pendingCodes.delete(code);
206
- res.json({
207
- access_token: pending.apiKey,
208
- token_type: "Bearer",
209
- expires_in: 3600
210
- });
486
+ res.json(issueTokens(pending.apiKey));
211
487
  }
212
488
  );
213
489
  var mcpLimiter = rateLimit({
@@ -217,16 +493,46 @@ var mcpLimiter = rateLimit({
217
493
  legacyHeaders: false,
218
494
  message: { error: "Too many requests. Try again later." }
219
495
  });
496
+ var authFailures = /* @__PURE__ */ new Map();
497
+ var AUTH_FAILURE_MAX = 10;
498
+ var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
499
+ var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
500
+ var MAX_AUTH_FAILURE_ENTRIES = 1e4;
501
+ function checkAuthBlock(ip) {
502
+ const rec = authFailures.get(ip);
503
+ if (!rec) return false;
504
+ return rec.blockedUntil > Date.now();
505
+ }
506
+ function recordAuthFailure(ip) {
507
+ const now = Date.now();
508
+ const rec = authFailures.get(ip);
509
+ if (!rec) {
510
+ authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
511
+ return;
512
+ }
513
+ if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
514
+ rec.count = 1;
515
+ rec.firstFailure = now;
516
+ rec.blockedUntil = 0;
517
+ } else {
518
+ rec.count++;
519
+ if (rec.count >= AUTH_FAILURE_MAX) {
520
+ rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
521
+ }
522
+ }
523
+ }
220
524
  app.get("/health", (_req, res) => {
221
525
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
222
526
  });
223
527
  var sessions = /* @__PURE__ */ new Map();
224
528
  var SESSION_TTL_MS = 30 * 60 * 1e3;
225
529
  var MAX_SESSIONS = 200;
530
+ var MAX_SESSIONS_PER_KEY = 5;
226
531
  function evictStaleSessions() {
227
532
  const now = Date.now();
228
533
  for (const [id, entry] of sessions) {
229
534
  if (now - entry.lastAccess > SESSION_TTL_MS) {
535
+ logSessionLifecycle("session_deleted", id, "ttl");
230
536
  entry.transport.close().catch(() => {
231
537
  });
232
538
  sessions.delete(id);
@@ -237,6 +543,7 @@ function evictStaleSessions() {
237
543
  (a, b) => a[1].lastAccess - b[1].lastAccess
238
544
  );
239
545
  for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
546
+ logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
240
547
  sorted[i][1].transport.close().catch(() => {
241
548
  });
242
549
  sessions.delete(sorted[i][0]);
@@ -248,7 +555,20 @@ function extractBearerKey(req) {
248
555
  const header = req.headers?.authorization;
249
556
  if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
250
557
  const token = header.slice(7).trim();
251
- return token.startsWith("pb_sk_") ? token : null;
558
+ if (token.startsWith("pb_sk_")) {
559
+ return token;
560
+ }
561
+ if (token.startsWith("pb_at_")) {
562
+ const entry = accessTokens.get(token);
563
+ if (!entry) return null;
564
+ const now = Date.now();
565
+ if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
566
+ accessTokens.delete(token);
567
+ return null;
568
+ }
569
+ return entry.apiKey;
570
+ }
571
+ return null;
252
572
  }
253
573
  function send401(req, res) {
254
574
  const base = baseUrl(req);
@@ -257,43 +577,86 @@ function send401(req, res) {
257
577
  `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`
258
578
  ).json({ error: "unauthorized" });
259
579
  }
260
- function logRequest(method, outcome, sessionId) {
580
+ function logRequest(method, outcome, sessionId, durationMs) {
261
581
  const ts = (/* @__PURE__ */ new Date()).toISOString();
262
582
  const sid = sessionId ? ` session=${sessionId}` : "";
263
- process.stderr.write(`[HTTP] ${ts} ${method} ${outcome}${sid}
583
+ const dur = durationMs != null ? ` duration=${durationMs}ms` : "";
584
+ process.stderr.write(`[HTTP] ${ts} ${method} ${outcome}${sid}${dur}
585
+ `);
586
+ }
587
+ function logSessionLifecycle(event, sessionId, reason) {
588
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
589
+ const r = reason ? ` reason=${reason}` : "";
590
+ process.stderr.write(`[HTTP] ${ts} ${event} session=${sessionId}${r}
264
591
  `);
265
592
  }
266
593
  app.post("/mcp", mcpLimiter, async (req, res) => {
594
+ const reqIp = req.ip ?? "unknown";
595
+ if (checkAuthBlock(reqIp)) {
596
+ res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
597
+ return;
598
+ }
267
599
  const apiKey = extractBearerKey(req);
268
600
  if (!apiKey) {
269
601
  logRequest("POST", "auth_fail");
602
+ recordAuthFailure(reqIp);
270
603
  send401(req, res);
271
604
  return;
272
605
  }
273
606
  const sessionId = req.headers["mcp-session-id"];
607
+ const reqStart = Date.now();
274
608
  try {
275
609
  await runWithAuth({ apiKey }, async () => {
276
610
  if (sessionId && sessions.has(sessionId)) {
277
611
  const entry = sessions.get(sessionId);
612
+ if (entry.keyHash !== hashKey(apiKey)) {
613
+ res.status(403).json({
614
+ jsonrpc: "2.0",
615
+ error: { code: -32e3, message: "Session key mismatch" },
616
+ id: null
617
+ });
618
+ return;
619
+ }
278
620
  entry.lastAccess = Date.now();
279
621
  await entry.transport.handleRequest(req, res, req.body);
280
- logRequest("POST", "ok", sessionId);
622
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
281
623
  } else if (!sessionId && isInitializeRequest(req.body)) {
624
+ const keyH = hashKey(apiKey);
625
+ let keySessionCount = 0;
626
+ for (const entry of sessions.values()) {
627
+ if (entry.keyHash === keyH) keySessionCount++;
628
+ }
629
+ if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
630
+ res.status(429).json({
631
+ jsonrpc: "2.0",
632
+ error: { code: -32e3, message: "Too many sessions for this API key" },
633
+ id: null
634
+ });
635
+ return;
636
+ }
282
637
  const transport = new StreamableHTTPServerTransport({
283
638
  sessionIdGenerator: () => randomUUID(),
284
639
  onsessioninitialized: (sid) => {
285
- sessions.set(sid, { transport, lastAccess: Date.now() });
286
- logRequest("POST", "ok", sid);
640
+ sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
641
+ logSessionLifecycle("session_created", sid);
287
642
  }
288
643
  });
289
644
  transport.onclose = () => {
290
645
  const sid = transport.sessionId;
291
- if (sid) sessions.delete(sid);
646
+ if (sid) {
647
+ logSessionLifecycle("session_deleted", sid, "onclose");
648
+ sessions.delete(sid);
649
+ }
292
650
  };
293
651
  const server = createProductBrainServer();
294
652
  await server.connect(transport);
295
653
  await transport.handleRequest(req, res, req.body);
654
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
296
655
  } else {
656
+ process.stderr.write(
657
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
658
+ `
659
+ );
297
660
  res.status(400).json({
298
661
  jsonrpc: "2.0",
299
662
  error: { code: -32e3, message: "Bad Request: no valid session ID provided" },
@@ -302,7 +665,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
302
665
  }
303
666
  });
304
667
  } catch (err) {
305
- logRequest("POST", "error", sessionId);
668
+ logRequest("POST", "error", sessionId, Date.now() - reqStart);
306
669
  if (!res.headersSent) {
307
670
  res.status(500).json({
308
671
  jsonrpc: "2.0",
@@ -313,9 +676,15 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
313
676
  }
314
677
  });
315
678
  app.get("/mcp", mcpLimiter, async (req, res) => {
679
+ const reqIp = req.ip ?? "unknown";
680
+ if (checkAuthBlock(reqIp)) {
681
+ res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
682
+ return;
683
+ }
316
684
  const apiKey = extractBearerKey(req);
317
685
  if (!apiKey) {
318
686
  logRequest("GET", "auth_fail");
687
+ recordAuthFailure(reqIp);
319
688
  send401(req, res);
320
689
  return;
321
690
  }
@@ -327,6 +696,14 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
327
696
  try {
328
697
  await runWithAuth({ apiKey }, async () => {
329
698
  const entry = sessions.get(sessionId);
699
+ if (entry.keyHash !== hashKey(apiKey)) {
700
+ res.status(403).json({
701
+ jsonrpc: "2.0",
702
+ error: { code: -32e3, message: "Session key mismatch" },
703
+ id: null
704
+ });
705
+ return;
706
+ }
330
707
  entry.lastAccess = Date.now();
331
708
  await entry.transport.handleRequest(req, res);
332
709
  logRequest("GET", "ok", sessionId);
@@ -336,9 +713,15 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
336
713
  }
337
714
  });
338
715
  app.delete("/mcp", mcpLimiter, async (req, res) => {
716
+ const reqIp = req.ip ?? "unknown";
717
+ if (checkAuthBlock(reqIp)) {
718
+ res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
719
+ return;
720
+ }
339
721
  const apiKey = extractBearerKey(req);
340
722
  if (!apiKey) {
341
723
  logRequest("DELETE", "auth_fail");
724
+ recordAuthFailure(reqIp);
342
725
  send401(req, res);
343
726
  return;
344
727
  }
@@ -350,6 +733,14 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
350
733
  try {
351
734
  await runWithAuth({ apiKey }, async () => {
352
735
  const entry = sessions.get(sessionId);
736
+ if (entry.keyHash !== hashKey(apiKey)) {
737
+ res.status(403).json({
738
+ jsonrpc: "2.0",
739
+ error: { code: -32e3, message: "Session key mismatch" },
740
+ id: null
741
+ });
742
+ return;
743
+ }
353
744
  await entry.transport.handleRequest(req, res);
354
745
  logRequest("DELETE", "ok", sessionId);
355
746
  });
@@ -357,18 +748,40 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
357
748
  logRequest("DELETE", "error", sessionId);
358
749
  }
359
750
  });
360
- app.listen(PORT, "0.0.0.0", () => {
361
- console.log(`Product Brain MCP HTTP server v${SERVER_VERSION} listening on port ${PORT}`);
751
+ process.on("unhandledRejection", (reason) => {
752
+ const msg = reason instanceof Error ? reason.message : String(reason);
753
+ console.error(`[MCP HTTP] Unhandled rejection: ${msg}`);
362
754
  });
755
+ process.on("uncaughtException", (err) => {
756
+ console.error(`[MCP HTTP] Uncaught exception: ${err.stack ?? err.message}`);
757
+ gracefulShutdown();
758
+ });
759
+ var shuttingDown = false;
363
760
  async function gracefulShutdown() {
761
+ if (shuttingDown) return;
762
+ shuttingDown = true;
763
+ setTimeout(() => process.exit(1), 3e3).unref();
364
764
  console.log("Shutting down...");
365
765
  for (const [, entry] of sessions) {
366
766
  await entry.transport.close().catch(() => {
367
767
  });
368
768
  }
369
- await shutdownAnalytics();
769
+ try {
770
+ await shutdownAnalytics();
771
+ } catch {
772
+ }
370
773
  process.exit(0);
371
774
  }
775
+ var LISTEN_HOST = "0.0.0.0";
776
+ var httpServer = app.listen(PORT, LISTEN_HOST, () => {
777
+ console.log(
778
+ `Product Brain MCP HTTP server v${SERVER_VERSION} listening on ${LISTEN_HOST}:${PORT}`
779
+ );
780
+ });
781
+ httpServer.on("error", (err) => {
782
+ console.error(`[MCP HTTP] Server error: ${err.message}`);
783
+ process.exit(1);
784
+ });
372
785
  process.on("SIGINT", gracefulShutdown);
373
786
  process.on("SIGTERM", gracefulShutdown);
374
787
  //# sourceMappingURL=http.js.map