@productbrain/mcp 0.0.1-beta.185 → 0.0.1-beta.1851
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 +473 -236
- 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;
|
|
@@ -262,13 +490,9 @@ body::before{
|
|
|
262
490
|
10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}
|
|
263
491
|
30%,50%,70%{transform:translateX(-4px)}40%,60%{transform:translateX(4px)}
|
|
264
492
|
}
|
|
265
|
-
.input-prefix{
|
|
266
|
-
font-family:var(--font-mono);font-size:14px;color:var(--fg3);
|
|
267
|
-
padding-left:16px;user-select:none;
|
|
268
|
-
}
|
|
269
493
|
.input{
|
|
270
494
|
flex:1;min-width:0;background:transparent;border:0;outline:none;
|
|
271
|
-
padding:16px
|
|
495
|
+
padding:16px;
|
|
272
496
|
font-family:var(--font-mono);font-size:14px;color:var(--fg1);letter-spacing:0.02em;
|
|
273
497
|
}
|
|
274
498
|
.input::placeholder{color:var(--fg4)}
|
|
@@ -395,48 +619,42 @@ body::before{
|
|
|
395
619
|
color:var(--fg-bright);opacity:0;
|
|
396
620
|
}
|
|
397
621
|
.panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
|
|
398
|
-
.ok-
|
|
399
|
-
margin-
|
|
400
|
-
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;
|
|
401
625
|
}
|
|
402
|
-
.panel:not([hidden]) .ok-
|
|
403
|
-
.ok-
|
|
404
|
-
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;
|
|
405
629
|
opacity:0;
|
|
406
630
|
}
|
|
407
|
-
.panel:not([hidden]) .ok-
|
|
408
|
-
.
|
|
409
|
-
|
|
410
|
-
|
|
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;
|
|
411
635
|
}
|
|
412
|
-
.panel:not([hidden]) .
|
|
413
|
-
.
|
|
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}
|
|
414
638
|
|
|
415
639
|
.cmd{
|
|
416
|
-
display:inline-flex;align-items:center;gap:8px;
|
|
417
|
-
font-family:var(--font-mono);font-size:
|
|
418
|
-
background:rgba(
|
|
419
|
-
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;
|
|
420
644
|
cursor:pointer;user-select:none;
|
|
421
645
|
transition:background 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out;
|
|
422
646
|
}
|
|
423
|
-
.cmd:hover{background:rgba(
|
|
424
|
-
.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)}
|
|
425
649
|
.cmd .cmd-icon{
|
|
426
|
-
width:12px;height:12px;color:
|
|
427
|
-
|
|
650
|
+
width:12px;height:12px;color:currentColor;opacity:0.75;
|
|
651
|
+
display:inline-grid;place-items:center;
|
|
652
|
+
transition:opacity 140ms;
|
|
428
653
|
}
|
|
429
|
-
.cmd
|
|
654
|
+
.cmd:hover .cmd-icon{opacity:1}
|
|
655
|
+
.cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
|
|
430
656
|
.cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
|
431
657
|
|
|
432
|
-
.return-link{
|
|
433
|
-
font-family:var(--font-mono);font-size:11px;letter-spacing:0.18em;
|
|
434
|
-
text-transform:uppercase;color:var(--fg3);text-decoration:none;
|
|
435
|
-
border-bottom:1px dotted currentColor;padding-bottom:1px;
|
|
436
|
-
transition:color 140ms;
|
|
437
|
-
}
|
|
438
|
-
.return-link:hover{color:var(--fg1)}
|
|
439
|
-
|
|
440
658
|
/* error specifics */
|
|
441
659
|
.err-title{
|
|
442
660
|
font-family:var(--font-display);font-weight:600;
|
|
@@ -494,16 +712,13 @@ body::before{
|
|
|
494
712
|
}
|
|
495
713
|
</style>
|
|
496
714
|
</head><body>
|
|
497
|
-
<div class="top-mark">
|
|
498
|
-
<span class="m"><span class="core"></span></span>
|
|
499
|
-
<span class="name">Product Brain</span>
|
|
500
|
-
</div>
|
|
715
|
+
<div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
|
|
501
716
|
<div class="stage">${bodyContent}</div>
|
|
502
717
|
</body></html>`;
|
|
503
718
|
}
|
|
504
719
|
function providerDisplayName(clientName) {
|
|
505
720
|
const name = (clientName ?? "").trim();
|
|
506
|
-
if (!name) return "your
|
|
721
|
+
if (!name) return "your assistant";
|
|
507
722
|
return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
|
|
508
723
|
}
|
|
509
724
|
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
@@ -525,20 +740,21 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
|
525
740
|
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
526
741
|
</div>
|
|
527
742
|
<div class="eyebrow success"><span class="dot"></span>Connected</div>
|
|
528
|
-
<h1 class="ok-title">Product Brain is
|
|
529
|
-
<p class="ok-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
<
|
|
533
|
-
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy 'Start PB' and return to ${esc(providerName)}">
|
|
534
|
-
<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>
|
|
535
748
|
<span class="cmd-icon" aria-hidden="true">
|
|
536
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>
|
|
537
750
|
</span>
|
|
538
751
|
</button>
|
|
539
|
-
<a class="return-link" href="${esc(redirectUrl)}" data-return-link>Return to <span data-provider>${esc(providerName)}</span> →</a>
|
|
540
752
|
</div>
|
|
541
|
-
<
|
|
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>`;
|
|
542
758
|
}
|
|
543
759
|
function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
544
760
|
return `
|
|
@@ -568,7 +784,7 @@ var cmdScript = `
|
|
|
568
784
|
pill.classList.add('is-copied');
|
|
569
785
|
if(textEl)textEl.textContent='Copied';
|
|
570
786
|
if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
|
|
571
|
-
setTimeout(function(){if(redirectUrl)window.location.
|
|
787
|
+
setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
|
|
572
788
|
};
|
|
573
789
|
try{
|
|
574
790
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
@@ -596,10 +812,9 @@ function authorizeFormPage(params) {
|
|
|
596
812
|
<input type="hidden" name="state" value="${esc(state)}">
|
|
597
813
|
<input type="hidden" name="client_id" value="${esc(client_id)}">
|
|
598
814
|
<div class="input-wrap" id="iw">
|
|
599
|
-
<
|
|
600
|
-
<input type="password" id="k" name="api_key" class="input" placeholder="\u2026" required autofocus spellcheck="false">
|
|
815
|
+
<input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
|
|
601
816
|
</div>
|
|
602
|
-
<div class="hint" id="hint"
|
|
817
|
+
<div class="hint" id="hint" hidden></div>
|
|
603
818
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
604
819
|
</form>
|
|
605
820
|
<div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
@@ -641,15 +856,13 @@ ${cmdScript}
|
|
|
641
856
|
}
|
|
642
857
|
|
|
643
858
|
function syncInput(){
|
|
644
|
-
var v=k.value;
|
|
645
|
-
if(v.indexOf('pb_sk_')===0)k.value=v.slice(6);
|
|
646
859
|
sb.disabled=!k.value.trim();
|
|
647
860
|
iw.classList.remove('has-error');
|
|
648
861
|
hint.classList.remove('is-error');
|
|
649
|
-
hint.textContent='
|
|
862
|
+
hint.textContent='';
|
|
863
|
+
hint.setAttribute('hidden','');
|
|
650
864
|
}
|
|
651
865
|
k.addEventListener('input',syncInput);
|
|
652
|
-
k.addEventListener('paste',function(){setTimeout(syncInput,0)});
|
|
653
866
|
k.addEventListener('keydown',function(e){
|
|
654
867
|
if(e.key==='Escape'){k.value='';syncInput()}
|
|
655
868
|
});
|
|
@@ -669,7 +882,7 @@ ${cmdScript}
|
|
|
669
882
|
function showSuccess(workspaceName,redirectUrl,providerName){
|
|
670
883
|
var tpl=document.getElementById('tpl-connected');
|
|
671
884
|
var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
672
|
-
var safeProv=String(providerName||'your
|
|
885
|
+
var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
673
886
|
var safeUrl=String(redirectUrl||'').replace(/"/g,'"').replace(/[<>]/g,function(c){return{'<':'<','>':'>'}[c]});
|
|
674
887
|
var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
|
|
675
888
|
pOk.innerHTML=html;
|
|
@@ -684,15 +897,16 @@ ${cmdScript}
|
|
|
684
897
|
f.addEventListener('submit',function(e){
|
|
685
898
|
e.preventDefault();
|
|
686
899
|
var v=k.value.trim();
|
|
687
|
-
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';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}
|
|
688
902
|
sb.disabled=true;bt.textContent='Verifying';
|
|
689
903
|
show(pVerify);
|
|
690
904
|
|
|
691
905
|
var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
|
|
692
906
|
var i=0;verifySub.textContent=steps[0];
|
|
693
|
-
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);
|
|
694
908
|
|
|
695
|
-
var minDelay=new Promise(function(r){setTimeout(r,
|
|
909
|
+
var minDelay=new Promise(function(r){setTimeout(r,2800)});
|
|
696
910
|
var fd=new FormData(f);
|
|
697
911
|
var body=new URLSearchParams();
|
|
698
912
|
fd.forEach(function(val,key){body.append(key,String(val))});
|
|
@@ -844,7 +1058,7 @@ app.post(
|
|
|
844
1058
|
if (anyDefinitiveReject) {
|
|
845
1059
|
sendError(
|
|
846
1060
|
"Key not recognized",
|
|
847
|
-
"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.",
|
|
848
1062
|
401
|
|
849
1063
|
);
|
|
850
1064
|
return;
|
|
@@ -856,7 +1070,7 @@ app.post(
|
|
|
856
1070
|
} catch {
|
|
857
1071
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
858
1072
|
}
|
|
859
|
-
const code =
|
|
1073
|
+
const code = randomUUID2();
|
|
860
1074
|
pendingCodes.set(code, {
|
|
861
1075
|
apiKey: api_key,
|
|
862
1076
|
codeChallenge: code_challenge,
|
|
@@ -876,42 +1090,13 @@ app.post(
|
|
|
876
1090
|
}
|
|
877
1091
|
);
|
|
878
1092
|
function issueTokens(apiKey) {
|
|
879
|
-
const now = Date.now();
|
|
880
|
-
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
881
|
-
let perKeyCount = 0;
|
|
882
|
-
let oldestKeyForApiKey = null;
|
|
883
|
-
let oldestAtForApiKey = Infinity;
|
|
884
|
-
for (const [k, v] of refreshTokens) {
|
|
885
|
-
if (v.apiKey === apiKey) {
|
|
886
|
-
perKeyCount++;
|
|
887
|
-
if (v.createdAt < oldestAtForApiKey) {
|
|
888
|
-
oldestAtForApiKey = v.createdAt;
|
|
889
|
-
oldestKeyForApiKey = k;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
|
|
894
|
-
refreshTokens.delete(oldestKeyForApiKey);
|
|
895
|
-
}
|
|
896
|
-
if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
|
|
897
|
-
let oldestKey = null;
|
|
898
|
-
let oldestAt = Infinity;
|
|
899
|
-
for (const [k, v] of refreshTokens) {
|
|
900
|
-
if (v.createdAt < oldestAt) {
|
|
901
|
-
oldestAt = v.createdAt;
|
|
902
|
-
oldestKey = k;
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
if (oldestKey) refreshTokens.delete(oldestKey);
|
|
906
|
-
}
|
|
907
|
-
refreshTokens.set(refreshToken, { apiKey, createdAt: now });
|
|
908
1093
|
return {
|
|
909
1094
|
access_token: apiKey,
|
|
910
1095
|
token_type: "Bearer",
|
|
911
1096
|
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
912
1097
|
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
913
1098
|
expires_in: 365 * 24 * 3600,
|
|
914
|
-
refresh_token:
|
|
1099
|
+
refresh_token: signRefreshToken(apiKey)
|
|
915
1100
|
};
|
|
916
1101
|
}
|
|
917
1102
|
app.post(
|
|
@@ -922,19 +1107,15 @@ app.post(
|
|
|
922
1107
|
(req, res) => {
|
|
923
1108
|
const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
|
|
924
1109
|
if (grant_type === "refresh_token") {
|
|
925
|
-
const
|
|
926
|
-
if (!
|
|
927
|
-
res.status(400).json({
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
refreshTokens.delete(refresh_token);
|
|
932
|
-
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
|
+
});
|
|
933
1116
|
return;
|
|
934
1117
|
}
|
|
935
|
-
|
|
936
|
-
refreshTokens.delete(refresh_token);
|
|
937
|
-
res.json(issueTokens(apiKey));
|
|
1118
|
+
res.json(issueTokens(verified.apiKey));
|
|
938
1119
|
return;
|
|
939
1120
|
}
|
|
940
1121
|
if (grant_type !== "authorization_code") {
|
|
@@ -964,85 +1145,63 @@ var mcpLimiter = rateLimit({
|
|
|
964
1145
|
max: 120,
|
|
965
1146
|
standardHeaders: true,
|
|
966
1147
|
legacyHeaders: false,
|
|
967
|
-
|
|
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
|
+
}
|
|
968
1159
|
});
|
|
969
1160
|
var authFailures = /* @__PURE__ */ new Map();
|
|
970
|
-
var AUTH_FAILURE_MAX = 10;
|
|
971
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
972
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
973
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
974
|
-
function checkAuthBlock(ip) {
|
|
975
|
-
const rec = authFailures.get(ip);
|
|
976
|
-
if (!rec) return false;
|
|
977
|
-
return rec.blockedUntil > Date.now();
|
|
978
|
-
}
|
|
979
|
-
function recordAuthFailure(ip) {
|
|
980
|
-
const now = Date.now();
|
|
981
|
-
const rec = authFailures.get(ip);
|
|
982
|
-
if (!rec) {
|
|
983
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
987
|
-
rec.count = 1;
|
|
988
|
-
rec.firstFailure = now;
|
|
989
|
-
rec.blockedUntil = 0;
|
|
990
|
-
} else {
|
|
991
|
-
rec.count++;
|
|
992
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
993
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
1161
|
app.get("/health", (_req, res) => {
|
|
998
1162
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
999
1163
|
});
|
|
1000
1164
|
var sessions = /* @__PURE__ */ new Map();
|
|
1001
1165
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1002
1166
|
var MAX_SESSIONS = 200;
|
|
1003
|
-
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
|
+
);
|
|
1004
1172
|
function evictStaleSessions() {
|
|
1005
1173
|
const now = Date.now();
|
|
1006
1174
|
for (const [id, entry] of sessions) {
|
|
1007
1175
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1008
|
-
logSessionLifecycle(
|
|
1176
|
+
logSessionLifecycle(
|
|
1177
|
+
"session_deleted",
|
|
1178
|
+
id,
|
|
1179
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1180
|
+
);
|
|
1009
1181
|
entry.transport.close().catch(() => {
|
|
1010
1182
|
});
|
|
1011
1183
|
sessions.delete(id);
|
|
1012
1184
|
}
|
|
1013
1185
|
}
|
|
1014
1186
|
if (sessions.size > MAX_SESSIONS) {
|
|
1015
|
-
const sorted = [...sessions.entries()].sort(
|
|
1016
|
-
|
|
1017
|
-
);
|
|
1018
|
-
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++) {
|
|
1019
1190
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1020
1191
|
sorted[i][1].transport.close().catch(() => {
|
|
1021
1192
|
});
|
|
1022
1193
|
sessions.delete(sorted[i][0]);
|
|
1023
1194
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
const token = header.slice(7).trim();
|
|
1031
|
-
if (token.startsWith("pb_sk_")) {
|
|
1032
|
-
return token;
|
|
1033
|
-
}
|
|
1034
|
-
if (token.startsWith("pb_at_")) {
|
|
1035
|
-
const entry = accessTokens.get(token);
|
|
1036
|
-
if (!entry) return null;
|
|
1037
|
-
const now = Date.now();
|
|
1038
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1039
|
-
accessTokens.delete(token);
|
|
1040
|
-
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
|
+
);
|
|
1041
1201
|
}
|
|
1042
|
-
return entry.apiKey;
|
|
1043
1202
|
}
|
|
1044
|
-
return null;
|
|
1045
1203
|
}
|
|
1204
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1046
1205
|
function send401(req, res) {
|
|
1047
1206
|
const base = baseUrl(req);
|
|
1048
1207
|
res.status(401).set(
|
|
@@ -1065,17 +1224,21 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1065
1224
|
}
|
|
1066
1225
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1067
1226
|
const reqIp = req.ip ?? "unknown";
|
|
1068
|
-
|
|
1069
|
-
|
|
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 }));
|
|
1070
1232
|
return;
|
|
1071
1233
|
}
|
|
1072
|
-
const
|
|
1073
|
-
if (
|
|
1234
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1235
|
+
if (auth.status !== "ok") {
|
|
1074
1236
|
logRequest("POST", "auth_fail");
|
|
1075
|
-
recordAuthFailure(reqIp);
|
|
1237
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1076
1238
|
send401(req, res);
|
|
1077
1239
|
return;
|
|
1078
1240
|
}
|
|
1241
|
+
const apiKey = auth.apiKey;
|
|
1079
1242
|
const sessionId = req.headers["mcp-session-id"];
|
|
1080
1243
|
const reqStart = Date.now();
|
|
1081
1244
|
try {
|
|
@@ -1090,41 +1253,91 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1090
1253
|
});
|
|
1091
1254
|
return;
|
|
1092
1255
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
+
}
|
|
1096
1265
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1097
1266
|
const keyH = hashKey(apiKey);
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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(() => {
|
|
1107
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
|
+
}));
|
|
1108
1289
|
return;
|
|
1109
1290
|
}
|
|
1291
|
+
const sid = randomUUID2();
|
|
1292
|
+
let initialized = false;
|
|
1110
1293
|
const transport = new StreamableHTTPServerTransport({
|
|
1111
|
-
sessionIdGenerator: () =>
|
|
1112
|
-
onsessioninitialized: (
|
|
1113
|
-
|
|
1114
|
-
logSessionLifecycle("session_created",
|
|
1294
|
+
sessionIdGenerator: () => sid,
|
|
1295
|
+
onsessioninitialized: (s) => {
|
|
1296
|
+
initialized = true;
|
|
1297
|
+
logSessionLifecycle("session_created", s);
|
|
1115
1298
|
}
|
|
1299
|
+
// log only — no map write
|
|
1116
1300
|
});
|
|
1301
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1302
|
+
sessions.set(sid, reserved);
|
|
1117
1303
|
transport.onclose = () => {
|
|
1118
|
-
const
|
|
1119
|
-
if (
|
|
1120
|
-
|
|
1121
|
-
|
|
1304
|
+
const s = transport.sessionId ?? sid;
|
|
1305
|
+
if (sessions.has(s)) {
|
|
1306
|
+
sessions.delete(s);
|
|
1307
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1122
1308
|
}
|
|
1123
1309
|
};
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
+
});
|
|
1128
1341
|
} else {
|
|
1129
1342
|
process.stderr.write(
|
|
1130
1343
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
@@ -1150,20 +1363,32 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1150
1363
|
});
|
|
1151
1364
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1152
1365
|
const reqIp = req.ip ?? "unknown";
|
|
1153
|
-
|
|
1154
|
-
|
|
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 }));
|
|
1155
1371
|
return;
|
|
1156
1372
|
}
|
|
1157
|
-
const
|
|
1158
|
-
if (
|
|
1373
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1374
|
+
if (auth.status !== "ok") {
|
|
1159
1375
|
logRequest("GET", "auth_fail");
|
|
1160
|
-
recordAuthFailure(reqIp);
|
|
1376
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1161
1377
|
send401(req, res);
|
|
1162
1378
|
return;
|
|
1163
1379
|
}
|
|
1380
|
+
const apiKey = auth.apiKey;
|
|
1164
1381
|
const sessionId = req.headers["mcp-session-id"];
|
|
1165
|
-
if (!sessionId
|
|
1166
|
-
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");
|
|
1167
1392
|
return;
|
|
1168
1393
|
}
|
|
1169
1394
|
try {
|
|
@@ -1187,20 +1412,32 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1187
1412
|
});
|
|
1188
1413
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1189
1414
|
const reqIp = req.ip ?? "unknown";
|
|
1190
|
-
|
|
1191
|
-
|
|
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 }));
|
|
1192
1420
|
return;
|
|
1193
1421
|
}
|
|
1194
|
-
const
|
|
1195
|
-
if (
|
|
1422
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1423
|
+
if (auth.status !== "ok") {
|
|
1196
1424
|
logRequest("DELETE", "auth_fail");
|
|
1197
|
-
recordAuthFailure(reqIp);
|
|
1425
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1198
1426
|
send401(req, res);
|
|
1199
1427
|
return;
|
|
1200
1428
|
}
|
|
1429
|
+
const apiKey = auth.apiKey;
|
|
1201
1430
|
const sessionId = req.headers["mcp-session-id"];
|
|
1202
|
-
if (!sessionId
|
|
1203
|
-
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");
|
|
1204
1441
|
return;
|
|
1205
1442
|
}
|
|
1206
1443
|
try {
|