@productbrain/mcp 0.0.1-beta.186 → 0.0.1-beta.1867
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/chunk-6JNK5DIZ.js +1052 -0
- package/dist/chunk-6JNK5DIZ.js.map +1 -0
- package/dist/{chunk-XPFSARXP.js → chunk-ZKW7JZUF.js} +2493 -3422
- package/dist/chunk-ZKW7JZUF.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +471 -227
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-XWKTPL7V.js} +18 -10
- package/dist/setup-XWKTPL7V.js.map +1 -0
- package/package.json +2 -2
- package/dist/chunk-XPFSARXP.js.map +0 -1
- package/dist/chunk-YMF3IQ5E.js +0 -465
- package/dist/chunk-YMF3IQ5E.js.map +0 -1
- package/dist/setup-RYYXRDPB.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,25 +1,277 @@
|
|
|
1
1
|
import {
|
|
2
|
-
DEFAULT_CLOUD_URL,
|
|
3
2
|
SERVER_VERSION,
|
|
4
|
-
bootstrapHttp,
|
|
5
3
|
createProductBrainServer,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
initFeatureFlags,
|
|
9
|
-
runWithAuth
|
|
10
|
-
} from "./chunk-XPFSARXP.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-
|
|
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";
|
|
22
22
|
import rateLimit from "express-rate-limit";
|
|
23
|
+
|
|
24
|
+
// src/brand/logo-markup.ts
|
|
25
|
+
var SIZE_CLASSES = {
|
|
26
|
+
sm: "pb-logo--sm",
|
|
27
|
+
md: "pb-logo--md"
|
|
28
|
+
};
|
|
29
|
+
var appLogoStyles = `
|
|
30
|
+
.pb-logo{display:inline-flex;align-items:center;gap:8px;color:inherit}
|
|
31
|
+
.pb-logo__mark{
|
|
32
|
+
border-radius:4px;background:#1c1e24;
|
|
33
|
+
border:1px solid rgba(255,255,255,0.06);
|
|
34
|
+
display:inline-grid;place-items:center;flex-shrink:0;
|
|
35
|
+
}
|
|
36
|
+
.pb-logo__core{border-radius:50%;background:var(--accent,#c9b99a)}
|
|
37
|
+
.pb-logo__name{
|
|
38
|
+
font-family:var(--font-mono,"IBM Plex Mono",ui-monospace,monospace);
|
|
39
|
+
font-weight:500;text-transform:uppercase;
|
|
40
|
+
letter-spacing:0.22em;color:var(--fg4,#6a6560);
|
|
41
|
+
}
|
|
42
|
+
.pb-logo--sm .pb-logo__mark{width:16px;height:16px}
|
|
43
|
+
.pb-logo--sm .pb-logo__core{width:5px;height:5px}
|
|
44
|
+
.pb-logo--sm .pb-logo__name{font-size:10.5px}
|
|
45
|
+
.pb-logo--md .pb-logo__mark{width:24px;height:24px;border-radius:6px}
|
|
46
|
+
.pb-logo--md .pb-logo__core{width:8px;height:8px}
|
|
47
|
+
.pb-logo--md .pb-logo__name{font-size:13px}
|
|
48
|
+
`;
|
|
49
|
+
function appLogoMarkup(opts = {}) {
|
|
50
|
+
const size = opts.size ?? "sm";
|
|
51
|
+
const showWordmark = opts.showWordmark ?? true;
|
|
52
|
+
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
53
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">Product Brain</span>` : "";
|
|
54
|
+
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
|
+
}
|
|
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
|
+
|
|
274
|
+
// src/http.ts
|
|
23
275
|
bootstrapHttp();
|
|
24
276
|
initAnalytics();
|
|
25
277
|
initFeatureFlags(getPostHogClient());
|
|
@@ -43,7 +295,7 @@ app.use((_req, res, next) => {
|
|
|
43
295
|
"Access-Control-Allow-Headers",
|
|
44
296
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
45
297
|
);
|
|
46
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
298
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
47
299
|
if (_req.method === "OPTIONS") {
|
|
48
300
|
res.status(204).end();
|
|
49
301
|
return;
|
|
@@ -102,7 +354,7 @@ app.post(
|
|
|
102
354
|
});
|
|
103
355
|
return;
|
|
104
356
|
}
|
|
105
|
-
const clientId = `pb_client_${
|
|
357
|
+
const clientId = `pb_client_${randomUUID2()}`;
|
|
106
358
|
const client = {
|
|
107
359
|
client_id: clientId,
|
|
108
360
|
redirect_uris,
|
|
@@ -123,10 +375,6 @@ app.post(
|
|
|
123
375
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
124
376
|
var ACCESS_TOKEN_TTL = 3600;
|
|
125
377
|
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
378
|
var accessTokens = /* @__PURE__ */ new Map();
|
|
131
379
|
setInterval(() => {
|
|
132
380
|
const now = Date.now();
|
|
@@ -136,15 +384,6 @@ setInterval(() => {
|
|
|
136
384
|
for (const [id, client] of registeredClients) {
|
|
137
385
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
138
386
|
}
|
|
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
387
|
for (const [token, entry] of accessTokens) {
|
|
149
388
|
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
150
389
|
}
|
|
@@ -203,19 +442,8 @@ body::before{
|
|
|
203
442
|
background:radial-gradient(900px 600px at 50% 50%,rgba(228,224,216,0.025),transparent 60%);
|
|
204
443
|
pointer-events:none;z-index:0;
|
|
205
444
|
}
|
|
206
|
-
.top-mark{
|
|
207
|
-
|
|
208
|
-
z-index:5;opacity:0.7;
|
|
209
|
-
}
|
|
210
|
-
.top-mark .m{
|
|
211
|
-
width:16px;height:16px;border-radius:4px;background:#1c1e24;
|
|
212
|
-
border:1px solid rgba(255,255,255,0.06);display:inline-grid;place-items:center;
|
|
213
|
-
}
|
|
214
|
-
.top-mark .m .core{width:5px;height:5px;border-radius:50%;background:var(--accent)}
|
|
215
|
-
.top-mark .name{
|
|
216
|
-
font-family:var(--font-mono);font-size:10.5px;letter-spacing:0.22em;
|
|
217
|
-
text-transform:uppercase;color:var(--fg4);font-weight:500;
|
|
218
|
-
}
|
|
445
|
+
.top-mark{position:fixed;top:22px;left:24px;z-index:5;opacity:0.7}
|
|
446
|
+
${appLogoStyles}
|
|
219
447
|
.stage{
|
|
220
448
|
width:100%;max-width:460px;text-align:center;
|
|
221
449
|
position:relative;z-index:1;
|
|
@@ -391,48 +619,42 @@ body::before{
|
|
|
391
619
|
color:var(--fg-bright);opacity:0;
|
|
392
620
|
}
|
|
393
621
|
.panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
|
|
394
|
-
.ok-
|
|
395
|
-
margin-
|
|
396
|
-
opacity:0;
|
|
622
|
+
.ok-lead{
|
|
623
|
+
margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
|
|
624
|
+
opacity:0;
|
|
397
625
|
}
|
|
398
|
-
.panel:not([hidden]) .ok-
|
|
399
|
-
.ok-
|
|
400
|
-
margin-top:
|
|
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;
|
|
401
629
|
opacity:0;
|
|
402
630
|
}
|
|
403
|
-
.panel:not([hidden]) .ok-
|
|
404
|
-
.
|
|
405
|
-
|
|
406
|
-
|
|
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;
|
|
407
635
|
}
|
|
408
|
-
.panel:not([hidden]) .
|
|
409
|
-
.
|
|
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}
|
|
410
638
|
|
|
411
639
|
.cmd{
|
|
412
|
-
display:inline-flex;align-items:center;gap:8px;
|
|
413
|
-
font-family:var(--font-mono);font-size:
|
|
414
|
-
background:rgba(
|
|
415
|
-
padding:
|
|
640
|
+
display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
|
|
641
|
+
font-family:var(--font-mono);font-size:15px;font-weight:500;color:var(--accent);
|
|
642
|
+
background:rgba(201,185,154,0.08);border:1px solid rgba(201,185,154,0.30);
|
|
643
|
+
padding:5px 10px 5px 12px;border-radius:6px;letter-spacing:0.02em;
|
|
416
644
|
cursor:pointer;user-select:none;
|
|
417
645
|
transition:background 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out;
|
|
418
646
|
}
|
|
419
|
-
.cmd:hover{background:rgba(
|
|
420
|
-
.cmd.is-copied{color:var(--green);border-color:rgba(74,222,128,0.
|
|
647
|
+
.cmd:hover{background:rgba(201,185,154,0.14);border-color:rgba(201,185,154,0.50);color:#dcc9a4}
|
|
648
|
+
.cmd.is-copied{color:var(--green);border-color:rgba(74,222,128,0.35);background:rgba(74,222,128,0.08)}
|
|
421
649
|
.cmd .cmd-icon{
|
|
422
|
-
width:12px;height:12px;color:
|
|
423
|
-
|
|
650
|
+
width:12px;height:12px;color:currentColor;opacity:0.75;
|
|
651
|
+
display:inline-grid;place-items:center;
|
|
652
|
+
transition:opacity 140ms;
|
|
424
653
|
}
|
|
425
|
-
.cmd
|
|
654
|
+
.cmd:hover .cmd-icon{opacity:1}
|
|
655
|
+
.cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
|
|
426
656
|
.cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
|
427
657
|
|
|
428
|
-
.return-link{
|
|
429
|
-
font-family:var(--font-mono);font-size:11px;letter-spacing:0.18em;
|
|
430
|
-
text-transform:uppercase;color:var(--fg3);text-decoration:none;
|
|
431
|
-
border-bottom:1px dotted currentColor;padding-bottom:1px;
|
|
432
|
-
transition:color 140ms;
|
|
433
|
-
}
|
|
434
|
-
.return-link:hover{color:var(--fg1)}
|
|
435
|
-
|
|
436
658
|
/* error specifics */
|
|
437
659
|
.err-title{
|
|
438
660
|
font-family:var(--font-display);font-weight:600;
|
|
@@ -490,16 +712,13 @@ body::before{
|
|
|
490
712
|
}
|
|
491
713
|
</style>
|
|
492
714
|
</head><body>
|
|
493
|
-
<div class="top-mark">
|
|
494
|
-
<span class="m"><span class="core"></span></span>
|
|
495
|
-
<span class="name">Product Brain</span>
|
|
496
|
-
</div>
|
|
715
|
+
<div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
|
|
497
716
|
<div class="stage">${bodyContent}</div>
|
|
498
717
|
</body></html>`;
|
|
499
718
|
}
|
|
500
719
|
function providerDisplayName(clientName) {
|
|
501
720
|
const name = (clientName ?? "").trim();
|
|
502
|
-
if (!name) return "your
|
|
721
|
+
if (!name) return "your assistant";
|
|
503
722
|
return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
|
|
504
723
|
}
|
|
505
724
|
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
@@ -521,20 +740,21 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
|
521
740
|
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
522
741
|
</div>
|
|
523
742
|
<div class="eyebrow success"><span class="dot"></span>Connected</div>
|
|
524
|
-
<h1 class="ok-title">Product Brain is
|
|
525
|
-
<p class="ok-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
<
|
|
529
|
-
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy 'Start PB' and return to ${esc(providerName)}">
|
|
530
|
-
<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 "Start PB" and return to ${esc(providerName)}">
|
|
747
|
+
<span class="cmd-quote-part" aria-hidden="true">“</span><span data-cmd-text>Start PB</span><span class="cmd-quote-part" aria-hidden="true">”</span>
|
|
531
748
|
<span class="cmd-icon" aria-hidden="true">
|
|
532
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>
|
|
533
750
|
</span>
|
|
534
751
|
</button>
|
|
535
|
-
<a class="return-link" href="${esc(redirectUrl)}" data-return-link>Return to <span data-provider>${esc(providerName)}</span> →</a>
|
|
536
752
|
</div>
|
|
537
|
-
<
|
|
753
|
+
<div class="success-cta-wrap">
|
|
754
|
+
<a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
|
|
755
|
+
</div>
|
|
756
|
+
<!-- workspace name retained as data hook for tests, hidden from view -->
|
|
757
|
+
<span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
|
|
538
758
|
}
|
|
539
759
|
function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
540
760
|
return `
|
|
@@ -564,7 +784,7 @@ var cmdScript = `
|
|
|
564
784
|
pill.classList.add('is-copied');
|
|
565
785
|
if(textEl)textEl.textContent='Copied';
|
|
566
786
|
if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
|
|
567
|
-
setTimeout(function(){if(redirectUrl)window.location.
|
|
787
|
+
setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
|
|
568
788
|
};
|
|
569
789
|
try{
|
|
570
790
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
@@ -594,7 +814,7 @@ function authorizeFormPage(params) {
|
|
|
594
814
|
<div class="input-wrap" id="iw">
|
|
595
815
|
<input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
|
|
596
816
|
</div>
|
|
597
|
-
<div class="hint" id="hint"
|
|
817
|
+
<div class="hint" id="hint" hidden></div>
|
|
598
818
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
599
819
|
</form>
|
|
600
820
|
<div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
@@ -639,7 +859,8 @@ ${cmdScript}
|
|
|
639
859
|
sb.disabled=!k.value.trim();
|
|
640
860
|
iw.classList.remove('has-error');
|
|
641
861
|
hint.classList.remove('is-error');
|
|
642
|
-
hint.textContent='
|
|
862
|
+
hint.textContent='';
|
|
863
|
+
hint.setAttribute('hidden','');
|
|
643
864
|
}
|
|
644
865
|
k.addEventListener('input',syncInput);
|
|
645
866
|
k.addEventListener('keydown',function(e){
|
|
@@ -661,7 +882,7 @@ ${cmdScript}
|
|
|
661
882
|
function showSuccess(workspaceName,redirectUrl,providerName){
|
|
662
883
|
var tpl=document.getElementById('tpl-connected');
|
|
663
884
|
var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
664
|
-
var safeProv=String(providerName||'your
|
|
885
|
+
var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
665
886
|
var safeUrl=String(redirectUrl||'').replace(/"/g,'"').replace(/[<>]/g,function(c){return{'<':'<','>':'>'}[c]});
|
|
666
887
|
var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
|
|
667
888
|
pOk.innerHTML=html;
|
|
@@ -676,16 +897,16 @@ ${cmdScript}
|
|
|
676
897
|
f.addEventListener('submit',function(e){
|
|
677
898
|
e.preventDefault();
|
|
678
899
|
var v=k.value.trim();
|
|
679
|
-
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';return}
|
|
680
|
-
if(v.indexOf('pb_sk_')!==0){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Key must start with pb_sk_';return}
|
|
900
|
+
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
|
|
901
|
+
if(v.indexOf('pb_sk_')!==0){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Key must start with pb_sk_';hint.removeAttribute('hidden');return}
|
|
681
902
|
sb.disabled=true;bt.textContent='Verifying';
|
|
682
903
|
show(pVerify);
|
|
683
904
|
|
|
684
905
|
var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
|
|
685
906
|
var i=0;verifySub.textContent=steps[0];
|
|
686
|
-
var ti=setInterval(function(){i++;if(i>=steps.length){clearInterval(ti);return}verifySub.textContent=steps[i]},
|
|
907
|
+
var ti=setInterval(function(){i++;if(i>=steps.length){clearInterval(ti);return}verifySub.textContent=steps[i]},900);
|
|
687
908
|
|
|
688
|
-
var minDelay=new Promise(function(r){setTimeout(r,
|
|
909
|
+
var minDelay=new Promise(function(r){setTimeout(r,2800)});
|
|
689
910
|
var fd=new FormData(f);
|
|
690
911
|
var body=new URLSearchParams();
|
|
691
912
|
fd.forEach(function(val,key){body.append(key,String(val))});
|
|
@@ -837,7 +1058,7 @@ app.post(
|
|
|
837
1058
|
if (anyDefinitiveReject) {
|
|
838
1059
|
sendError(
|
|
839
1060
|
"Key not recognized",
|
|
840
|
-
"This API key wasn't found in Product Brain. Check your API Keys in
|
|
1061
|
+
"This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
|
|
841
1062
|
401
|
|
842
1063
|
);
|
|
843
1064
|
return;
|
|
@@ -849,7 +1070,7 @@ app.post(
|
|
|
849
1070
|
} catch {
|
|
850
1071
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
851
1072
|
}
|
|
852
|
-
const code =
|
|
1073
|
+
const code = randomUUID2();
|
|
853
1074
|
pendingCodes.set(code, {
|
|
854
1075
|
apiKey: api_key,
|
|
855
1076
|
codeChallenge: code_challenge,
|
|
@@ -869,42 +1090,13 @@ app.post(
|
|
|
869
1090
|
}
|
|
870
1091
|
);
|
|
871
1092
|
function issueTokens(apiKey) {
|
|
872
|
-
const now = Date.now();
|
|
873
|
-
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
874
|
-
let perKeyCount = 0;
|
|
875
|
-
let oldestKeyForApiKey = null;
|
|
876
|
-
let oldestAtForApiKey = Infinity;
|
|
877
|
-
for (const [k, v] of refreshTokens) {
|
|
878
|
-
if (v.apiKey === apiKey) {
|
|
879
|
-
perKeyCount++;
|
|
880
|
-
if (v.createdAt < oldestAtForApiKey) {
|
|
881
|
-
oldestAtForApiKey = v.createdAt;
|
|
882
|
-
oldestKeyForApiKey = k;
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
|
|
887
|
-
refreshTokens.delete(oldestKeyForApiKey);
|
|
888
|
-
}
|
|
889
|
-
if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
|
|
890
|
-
let oldestKey = null;
|
|
891
|
-
let oldestAt = Infinity;
|
|
892
|
-
for (const [k, v] of refreshTokens) {
|
|
893
|
-
if (v.createdAt < oldestAt) {
|
|
894
|
-
oldestAt = v.createdAt;
|
|
895
|
-
oldestKey = k;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
if (oldestKey) refreshTokens.delete(oldestKey);
|
|
899
|
-
}
|
|
900
|
-
refreshTokens.set(refreshToken, { apiKey, createdAt: now });
|
|
901
1093
|
return {
|
|
902
1094
|
access_token: apiKey,
|
|
903
1095
|
token_type: "Bearer",
|
|
904
1096
|
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
905
1097
|
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
906
1098
|
expires_in: 365 * 24 * 3600,
|
|
907
|
-
refresh_token:
|
|
1099
|
+
refresh_token: signRefreshToken(apiKey)
|
|
908
1100
|
};
|
|
909
1101
|
}
|
|
910
1102
|
app.post(
|
|
@@ -915,19 +1107,15 @@ app.post(
|
|
|
915
1107
|
(req, res) => {
|
|
916
1108
|
const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
|
|
917
1109
|
if (grant_type === "refresh_token") {
|
|
918
|
-
const
|
|
919
|
-
if (!
|
|
920
|
-
res.status(400).json({
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
refreshTokens.delete(refresh_token);
|
|
925
|
-
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
|
+
});
|
|
926
1116
|
return;
|
|
927
1117
|
}
|
|
928
|
-
|
|
929
|
-
refreshTokens.delete(refresh_token);
|
|
930
|
-
res.json(issueTokens(apiKey));
|
|
1118
|
+
res.json(issueTokens(verified.apiKey));
|
|
931
1119
|
return;
|
|
932
1120
|
}
|
|
933
1121
|
if (grant_type !== "authorization_code") {
|
|
@@ -957,85 +1145,63 @@ var mcpLimiter = rateLimit({
|
|
|
957
1145
|
max: 120,
|
|
958
1146
|
standardHeaders: true,
|
|
959
1147
|
legacyHeaders: false,
|
|
960
|
-
|
|
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
|
+
}
|
|
961
1159
|
});
|
|
962
1160
|
var authFailures = /* @__PURE__ */ new Map();
|
|
963
|
-
var AUTH_FAILURE_MAX = 10;
|
|
964
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
965
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
966
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
967
|
-
function checkAuthBlock(ip) {
|
|
968
|
-
const rec = authFailures.get(ip);
|
|
969
|
-
if (!rec) return false;
|
|
970
|
-
return rec.blockedUntil > Date.now();
|
|
971
|
-
}
|
|
972
|
-
function recordAuthFailure(ip) {
|
|
973
|
-
const now = Date.now();
|
|
974
|
-
const rec = authFailures.get(ip);
|
|
975
|
-
if (!rec) {
|
|
976
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
980
|
-
rec.count = 1;
|
|
981
|
-
rec.firstFailure = now;
|
|
982
|
-
rec.blockedUntil = 0;
|
|
983
|
-
} else {
|
|
984
|
-
rec.count++;
|
|
985
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
986
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
1161
|
app.get("/health", (_req, res) => {
|
|
991
1162
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
992
1163
|
});
|
|
993
1164
|
var sessions = /* @__PURE__ */ new Map();
|
|
994
1165
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
995
1166
|
var MAX_SESSIONS = 200;
|
|
996
|
-
var
|
|
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
|
+
);
|
|
997
1172
|
function evictStaleSessions() {
|
|
998
1173
|
const now = Date.now();
|
|
999
1174
|
for (const [id, entry] of sessions) {
|
|
1000
1175
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1001
|
-
logSessionLifecycle(
|
|
1176
|
+
logSessionLifecycle(
|
|
1177
|
+
"session_deleted",
|
|
1178
|
+
id,
|
|
1179
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1180
|
+
);
|
|
1002
1181
|
entry.transport.close().catch(() => {
|
|
1003
1182
|
});
|
|
1004
1183
|
sessions.delete(id);
|
|
1005
1184
|
}
|
|
1006
1185
|
}
|
|
1007
1186
|
if (sessions.size > MAX_SESSIONS) {
|
|
1008
|
-
const sorted = [...sessions.entries()].sort(
|
|
1009
|
-
|
|
1010
|
-
);
|
|
1011
|
-
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++) {
|
|
1012
1190
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1013
1191
|
sorted[i][1].transport.close().catch(() => {
|
|
1014
1192
|
});
|
|
1015
1193
|
sessions.delete(sorted[i][0]);
|
|
1016
1194
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const token = header.slice(7).trim();
|
|
1024
|
-
if (token.startsWith("pb_sk_")) {
|
|
1025
|
-
return token;
|
|
1026
|
-
}
|
|
1027
|
-
if (token.startsWith("pb_at_")) {
|
|
1028
|
-
const entry = accessTokens.get(token);
|
|
1029
|
-
if (!entry) return null;
|
|
1030
|
-
const now = Date.now();
|
|
1031
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1032
|
-
accessTokens.delete(token);
|
|
1033
|
-
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
|
+
);
|
|
1034
1201
|
}
|
|
1035
|
-
return entry.apiKey;
|
|
1036
1202
|
}
|
|
1037
|
-
return null;
|
|
1038
1203
|
}
|
|
1204
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1039
1205
|
function send401(req, res) {
|
|
1040
1206
|
const base = baseUrl(req);
|
|
1041
1207
|
res.status(401).set(
|
|
@@ -1058,17 +1224,21 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1058
1224
|
}
|
|
1059
1225
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1060
1226
|
const reqIp = req.ip ?? "unknown";
|
|
1061
|
-
|
|
1062
|
-
|
|
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 }));
|
|
1063
1232
|
return;
|
|
1064
1233
|
}
|
|
1065
|
-
const
|
|
1066
|
-
if (
|
|
1234
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1235
|
+
if (auth.status !== "ok") {
|
|
1067
1236
|
logRequest("POST", "auth_fail");
|
|
1068
|
-
recordAuthFailure(reqIp);
|
|
1237
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1069
1238
|
send401(req, res);
|
|
1070
1239
|
return;
|
|
1071
1240
|
}
|
|
1241
|
+
const apiKey = auth.apiKey;
|
|
1072
1242
|
const sessionId = req.headers["mcp-session-id"];
|
|
1073
1243
|
const reqStart = Date.now();
|
|
1074
1244
|
try {
|
|
@@ -1083,41 +1253,91 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1083
1253
|
});
|
|
1084
1254
|
return;
|
|
1085
1255
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
+
}
|
|
1089
1265
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1090
1266
|
const keyH = hashKey(apiKey);
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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(() => {
|
|
1100
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
|
+
}));
|
|
1101
1289
|
return;
|
|
1102
1290
|
}
|
|
1291
|
+
const sid = randomUUID2();
|
|
1292
|
+
let initialized = false;
|
|
1103
1293
|
const transport = new StreamableHTTPServerTransport({
|
|
1104
|
-
sessionIdGenerator: () =>
|
|
1105
|
-
onsessioninitialized: (
|
|
1106
|
-
|
|
1107
|
-
logSessionLifecycle("session_created",
|
|
1294
|
+
sessionIdGenerator: () => sid,
|
|
1295
|
+
onsessioninitialized: (s) => {
|
|
1296
|
+
initialized = true;
|
|
1297
|
+
logSessionLifecycle("session_created", s);
|
|
1108
1298
|
}
|
|
1299
|
+
// log only — no map write
|
|
1109
1300
|
});
|
|
1301
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1302
|
+
sessions.set(sid, reserved);
|
|
1110
1303
|
transport.onclose = () => {
|
|
1111
|
-
const
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
1114
|
-
|
|
1304
|
+
const s = transport.sessionId ?? sid;
|
|
1305
|
+
if (sessions.has(s)) {
|
|
1306
|
+
sessions.delete(s);
|
|
1307
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1115
1308
|
}
|
|
1116
1309
|
};
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
+
});
|
|
1121
1341
|
} else {
|
|
1122
1342
|
process.stderr.write(
|
|
1123
1343
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
@@ -1143,20 +1363,32 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1143
1363
|
});
|
|
1144
1364
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1145
1365
|
const reqIp = req.ip ?? "unknown";
|
|
1146
|
-
|
|
1147
|
-
|
|
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 }));
|
|
1148
1371
|
return;
|
|
1149
1372
|
}
|
|
1150
|
-
const
|
|
1151
|
-
if (
|
|
1373
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1374
|
+
if (auth.status !== "ok") {
|
|
1152
1375
|
logRequest("GET", "auth_fail");
|
|
1153
|
-
recordAuthFailure(reqIp);
|
|
1376
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1154
1377
|
send401(req, res);
|
|
1155
1378
|
return;
|
|
1156
1379
|
}
|
|
1380
|
+
const apiKey = auth.apiKey;
|
|
1157
1381
|
const sessionId = req.headers["mcp-session-id"];
|
|
1158
|
-
if (!sessionId
|
|
1159
|
-
res.status(400).send("
|
|
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");
|
|
1160
1392
|
return;
|
|
1161
1393
|
}
|
|
1162
1394
|
try {
|
|
@@ -1180,20 +1412,32 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1180
1412
|
});
|
|
1181
1413
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1182
1414
|
const reqIp = req.ip ?? "unknown";
|
|
1183
|
-
|
|
1184
|
-
|
|
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 }));
|
|
1185
1420
|
return;
|
|
1186
1421
|
}
|
|
1187
|
-
const
|
|
1188
|
-
if (
|
|
1422
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1423
|
+
if (auth.status !== "ok") {
|
|
1189
1424
|
logRequest("DELETE", "auth_fail");
|
|
1190
|
-
recordAuthFailure(reqIp);
|
|
1425
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1191
1426
|
send401(req, res);
|
|
1192
1427
|
return;
|
|
1193
1428
|
}
|
|
1429
|
+
const apiKey = auth.apiKey;
|
|
1194
1430
|
const sessionId = req.headers["mcp-session-id"];
|
|
1195
|
-
if (!sessionId
|
|
1196
|
-
res.status(400).send("
|
|
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");
|
|
1197
1441
|
return;
|
|
1198
1442
|
}
|
|
1199
1443
|
try {
|