@productbrain/mcp 0.0.1-beta.187 → 0.0.1-beta.1874
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-OOAGD3TL.js → chunk-ZKW7JZUF.js} +2453 -3413
- package/dist/chunk-ZKW7JZUF.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +422 -193
- 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 +1 -1
- package/dist/chunk-OOAGD3TL.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,21 +1,21 @@
|
|
|
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-OOAGD3TL.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";
|
|
@@ -54,6 +54,223 @@ function appLogoMarkup(opts = {}) {
|
|
|
54
54
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// src/lib/refresh-token.ts
|
|
58
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
59
|
+
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
60
|
+
var PREFIX = "pb_rt_";
|
|
61
|
+
var secret = (() => {
|
|
62
|
+
const fromEnv = process.env.MCP_REFRESH_SECRET;
|
|
63
|
+
if (fromEnv && fromEnv.length > 0) return Buffer.from(fromEnv, "utf8");
|
|
64
|
+
if (process.env.NODE_ENV === "production") {
|
|
65
|
+
console.warn(
|
|
66
|
+
"[HTTP] WARNING MCP_REFRESH_SECRET not set \u2014 refresh tokens will not survive restart"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return randomBytes(32);
|
|
70
|
+
})();
|
|
71
|
+
function sign(payloadB64) {
|
|
72
|
+
return createHmac("sha256", secret).update(payloadB64).digest();
|
|
73
|
+
}
|
|
74
|
+
function signRefreshToken(apiKey) {
|
|
75
|
+
const payload = {
|
|
76
|
+
k: apiKey,
|
|
77
|
+
i: Date.now(),
|
|
78
|
+
j: randomUUID()
|
|
79
|
+
};
|
|
80
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
81
|
+
const sigB64 = sign(payloadB64).toString("base64url");
|
|
82
|
+
return `${PREFIX}${payloadB64}.${sigB64}`;
|
|
83
|
+
}
|
|
84
|
+
function verifyRefreshToken(token) {
|
|
85
|
+
if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
|
|
86
|
+
const body = token.slice(PREFIX.length);
|
|
87
|
+
const dot = body.indexOf(".");
|
|
88
|
+
if (dot <= 0 || dot === body.length - 1) return null;
|
|
89
|
+
const payloadB64 = body.slice(0, dot);
|
|
90
|
+
const sigB64 = body.slice(dot + 1);
|
|
91
|
+
let providedSig;
|
|
92
|
+
try {
|
|
93
|
+
providedSig = Buffer.from(sigB64, "base64url");
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const expectedSig = sign(payloadB64);
|
|
98
|
+
if (providedSig.length !== expectedSig.length) return null;
|
|
99
|
+
if (!timingSafeEqual(providedSig, expectedSig)) return null;
|
|
100
|
+
let payload;
|
|
101
|
+
try {
|
|
102
|
+
const json = Buffer.from(payloadB64, "base64url").toString("utf8");
|
|
103
|
+
payload = JSON.parse(json);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
|
|
111
|
+
return { apiKey: payload.k };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/sessionCap.ts
|
|
115
|
+
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
116
|
+
if (cap < 1) {
|
|
117
|
+
throw new RangeError(`cap must be >= 1, got ${cap}`);
|
|
118
|
+
}
|
|
119
|
+
const { ttlMs, graceMs } = opts;
|
|
120
|
+
const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
|
|
121
|
+
const keyEntries = [];
|
|
122
|
+
for (const [id, session] of sessions2) {
|
|
123
|
+
if (session.keyHash === keyHash) {
|
|
124
|
+
keyEntries.push([id, session]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const evictSet = /* @__PURE__ */ new Set();
|
|
128
|
+
for (const [id, session] of keyEntries) {
|
|
129
|
+
if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
|
|
130
|
+
evictSet.add(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
|
|
134
|
+
if (survivors.length >= cap) {
|
|
135
|
+
const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
|
|
136
|
+
for (const [id] of idleSurvivors) {
|
|
137
|
+
if (survivors.length < cap) break;
|
|
138
|
+
evictSet.add(id);
|
|
139
|
+
survivors = survivors.filter(([sid]) => sid !== id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const evict = Array.from(evictSet);
|
|
143
|
+
const admit = survivors.length < cap;
|
|
144
|
+
let retryAfterSeconds = 0;
|
|
145
|
+
let retryIsPoll = false;
|
|
146
|
+
if (!admit) {
|
|
147
|
+
const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
|
|
148
|
+
if (graceBlockers.length > 0) {
|
|
149
|
+
const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
|
|
150
|
+
const msRemaining = graceMs - (now - minLastAccess);
|
|
151
|
+
retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
|
|
152
|
+
} else {
|
|
153
|
+
retryAfterSeconds = 5;
|
|
154
|
+
retryIsPoll = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { evict, admit, retryAfterSeconds, retryIsPoll };
|
|
158
|
+
}
|
|
159
|
+
function countKeySessions(sessions2, keyHash) {
|
|
160
|
+
let count = 0;
|
|
161
|
+
for (const session of sessions2.values()) {
|
|
162
|
+
if (session.keyHash === keyHash) count++;
|
|
163
|
+
}
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/httpErrors.ts
|
|
168
|
+
function clampSeconds(s) {
|
|
169
|
+
return Math.max(0, Math.round(s));
|
|
170
|
+
}
|
|
171
|
+
function build(reason, message, p) {
|
|
172
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
173
|
+
return {
|
|
174
|
+
status: 429,
|
|
175
|
+
headers: { "Retry-After": String(seconds) },
|
|
176
|
+
body: {
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
id: p.id ?? null,
|
|
179
|
+
error: {
|
|
180
|
+
code: -32e3,
|
|
181
|
+
message,
|
|
182
|
+
data: {
|
|
183
|
+
reason,
|
|
184
|
+
retryAfterSeconds: seconds,
|
|
185
|
+
limit: p.limit,
|
|
186
|
+
current: p.current
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function buildRateLimitError(p) {
|
|
193
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
194
|
+
return build(
|
|
195
|
+
"rate_limited",
|
|
196
|
+
`Too many requests \u2014 retry after ${seconds} s.`,
|
|
197
|
+
p
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
function buildAuthLockoutError(p) {
|
|
201
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
202
|
+
return build(
|
|
203
|
+
"auth_lockout",
|
|
204
|
+
`Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
|
|
205
|
+
p
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
function buildSessionCapError(p) {
|
|
209
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
210
|
+
const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
|
|
211
|
+
return build("session_cap", message, p);
|
|
212
|
+
}
|
|
213
|
+
function send429(res, built) {
|
|
214
|
+
if (res.headersSent) return;
|
|
215
|
+
for (const [k, v] of Object.entries(built.headers)) {
|
|
216
|
+
res.setHeader(k, v);
|
|
217
|
+
}
|
|
218
|
+
res.status(built.status).json(built.body);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/authLockout.ts
|
|
222
|
+
var AUTH_FAILURE_MAX = 10;
|
|
223
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
224
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
225
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
226
|
+
function resolveBearerAuth(authorizationHeader, accessTokens2, now, accessTokenTtlMs) {
|
|
227
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
228
|
+
return { status: "absent" };
|
|
229
|
+
}
|
|
230
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
231
|
+
return { status: "rejected" };
|
|
232
|
+
}
|
|
233
|
+
const token = authorizationHeader.slice(7).trim();
|
|
234
|
+
if (token === "") {
|
|
235
|
+
return { status: "rejected" };
|
|
236
|
+
}
|
|
237
|
+
if (token.startsWith("pb_sk_")) {
|
|
238
|
+
return { status: "ok", apiKey: token };
|
|
239
|
+
}
|
|
240
|
+
if (token.startsWith("pb_at_")) {
|
|
241
|
+
const entry = accessTokens2.get(token);
|
|
242
|
+
if (!entry) return { status: "rejected" };
|
|
243
|
+
if (now - entry.createdAt > accessTokenTtlMs) {
|
|
244
|
+
accessTokens2.delete(token);
|
|
245
|
+
return { status: "rejected" };
|
|
246
|
+
}
|
|
247
|
+
return { status: "ok", apiKey: entry.apiKey };
|
|
248
|
+
}
|
|
249
|
+
return { status: "rejected" };
|
|
250
|
+
}
|
|
251
|
+
function checkAuthBlock(failures, ip, now) {
|
|
252
|
+
const rec = failures.get(ip);
|
|
253
|
+
if (!rec) return false;
|
|
254
|
+
return rec.blockedUntil > now;
|
|
255
|
+
}
|
|
256
|
+
function recordAuthFailure(failures, ip, now) {
|
|
257
|
+
const rec = failures.get(ip);
|
|
258
|
+
if (!rec) {
|
|
259
|
+
failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
263
|
+
rec.count = 1;
|
|
264
|
+
rec.firstFailure = now;
|
|
265
|
+
rec.blockedUntil = 0;
|
|
266
|
+
} else {
|
|
267
|
+
rec.count++;
|
|
268
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
269
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
57
274
|
// src/http.ts
|
|
58
275
|
bootstrapHttp();
|
|
59
276
|
initAnalytics();
|
|
@@ -78,7 +295,7 @@ app.use((_req, res, next) => {
|
|
|
78
295
|
"Access-Control-Allow-Headers",
|
|
79
296
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
80
297
|
);
|
|
81
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
298
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
82
299
|
if (_req.method === "OPTIONS") {
|
|
83
300
|
res.status(204).end();
|
|
84
301
|
return;
|
|
@@ -137,7 +354,7 @@ app.post(
|
|
|
137
354
|
});
|
|
138
355
|
return;
|
|
139
356
|
}
|
|
140
|
-
const clientId = `pb_client_${
|
|
357
|
+
const clientId = `pb_client_${randomUUID2()}`;
|
|
141
358
|
const client = {
|
|
142
359
|
client_id: clientId,
|
|
143
360
|
redirect_uris,
|
|
@@ -158,10 +375,6 @@ app.post(
|
|
|
158
375
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
159
376
|
var ACCESS_TOKEN_TTL = 3600;
|
|
160
377
|
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
161
|
-
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
162
|
-
var refreshTokens = /* @__PURE__ */ new Map();
|
|
163
|
-
var MAX_REFRESH_TOKENS = 2e3;
|
|
164
|
-
var MAX_REFRESH_TOKENS_PER_KEY = 20;
|
|
165
378
|
var accessTokens = /* @__PURE__ */ new Map();
|
|
166
379
|
setInterval(() => {
|
|
167
380
|
const now = Date.now();
|
|
@@ -171,15 +384,6 @@ setInterval(() => {
|
|
|
171
384
|
for (const [id, client] of registeredClients) {
|
|
172
385
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
173
386
|
}
|
|
174
|
-
for (const [token, entry] of refreshTokens) {
|
|
175
|
-
if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
|
|
176
|
-
}
|
|
177
|
-
if (refreshTokens.size > MAX_REFRESH_TOKENS) {
|
|
178
|
-
const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
179
|
-
for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
|
|
180
|
-
refreshTokens.delete(sorted[i][0]);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
387
|
for (const [token, entry] of accessTokens) {
|
|
184
388
|
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
185
389
|
}
|
|
@@ -415,12 +619,22 @@ ${appLogoStyles}
|
|
|
415
619
|
color:var(--fg-bright);opacity:0;
|
|
416
620
|
}
|
|
417
621
|
.panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
|
|
418
|
-
.ok-
|
|
419
|
-
margin-
|
|
622
|
+
.ok-lead{
|
|
623
|
+
margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
|
|
420
624
|
opacity:0;
|
|
421
|
-
display:inline-flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;
|
|
422
625
|
}
|
|
423
|
-
.panel:not([hidden]) .ok-
|
|
626
|
+
.panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
|
|
627
|
+
.ok-phrase-row{
|
|
628
|
+
margin-top:14px;display:flex;align-items:center;justify-content:center;
|
|
629
|
+
opacity:0;
|
|
630
|
+
}
|
|
631
|
+
.panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
|
|
632
|
+
.cmd.is-copied .cmd-quote-part{display:none}
|
|
633
|
+
.success-cta-wrap{
|
|
634
|
+
margin-top:48px;width:100%;opacity:0;
|
|
635
|
+
}
|
|
636
|
+
.panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
|
|
637
|
+
a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
|
|
424
638
|
|
|
425
639
|
.cmd{
|
|
426
640
|
display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
|
|
@@ -441,16 +655,6 @@ ${appLogoStyles}
|
|
|
441
655
|
.cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
|
|
442
656
|
.cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
|
443
657
|
|
|
444
|
-
.return-link{
|
|
445
|
-
display:inline-block;margin-top:28px;
|
|
446
|
-
font-family:var(--font-mono);font-size:11px;letter-spacing:0.18em;
|
|
447
|
-
text-transform:uppercase;color:var(--fg4);text-decoration:none;
|
|
448
|
-
border-bottom:1px dotted currentColor;padding-bottom:1px;
|
|
449
|
-
opacity:0;transition:color 140ms;
|
|
450
|
-
}
|
|
451
|
-
.panel:not([hidden]) .return-link{animation:rise 600ms ease-out 720ms forwards}
|
|
452
|
-
.return-link:hover{color:var(--fg2)}
|
|
453
|
-
|
|
454
658
|
/* error specifics */
|
|
455
659
|
.err-title{
|
|
456
660
|
font-family:var(--font-display);font-weight:600;
|
|
@@ -514,7 +718,7 @@ ${appLogoStyles}
|
|
|
514
718
|
}
|
|
515
719
|
function providerDisplayName(clientName) {
|
|
516
720
|
const name = (clientName ?? "").trim();
|
|
517
|
-
if (!name) return "your
|
|
721
|
+
if (!name) return "your assistant";
|
|
518
722
|
return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
|
|
519
723
|
}
|
|
520
724
|
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
@@ -536,18 +740,19 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
|
536
740
|
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
537
741
|
</div>
|
|
538
742
|
<div class="eyebrow success"><span class="dot"></span>Connected</div>
|
|
539
|
-
<h1 class="ok-title">Product Brain is
|
|
540
|
-
<p class="ok-
|
|
541
|
-
|
|
542
|
-
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy
|
|
543
|
-
<span data-cmd-text>Start PB</span>
|
|
743
|
+
<h1 class="ok-title">Product Brain is Live</h1>
|
|
744
|
+
<p class="ok-lead">Return to your assistant, then say</p>
|
|
745
|
+
<div class="ok-phrase-row">
|
|
746
|
+
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy "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>
|
|
544
748
|
<span class="cmd-icon" aria-hidden="true">
|
|
545
749
|
<svg data-cmd-svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>
|
|
546
750
|
</span>
|
|
547
751
|
</button>
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
<a class="
|
|
752
|
+
</div>
|
|
753
|
+
<div class="success-cta-wrap">
|
|
754
|
+
<a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
|
|
755
|
+
</div>
|
|
551
756
|
<!-- workspace name retained as data hook for tests, hidden from view -->
|
|
552
757
|
<span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
|
|
553
758
|
}
|
|
@@ -579,7 +784,7 @@ var cmdScript = `
|
|
|
579
784
|
pill.classList.add('is-copied');
|
|
580
785
|
if(textEl)textEl.textContent='Copied';
|
|
581
786
|
if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
|
|
582
|
-
setTimeout(function(){if(redirectUrl)window.location.
|
|
787
|
+
setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
|
|
583
788
|
};
|
|
584
789
|
try{
|
|
585
790
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
@@ -609,7 +814,7 @@ function authorizeFormPage(params) {
|
|
|
609
814
|
<div class="input-wrap" id="iw">
|
|
610
815
|
<input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
|
|
611
816
|
</div>
|
|
612
|
-
<div class="hint" id="hint"
|
|
817
|
+
<div class="hint" id="hint" hidden></div>
|
|
613
818
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
614
819
|
</form>
|
|
615
820
|
<div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
@@ -654,7 +859,8 @@ ${cmdScript}
|
|
|
654
859
|
sb.disabled=!k.value.trim();
|
|
655
860
|
iw.classList.remove('has-error');
|
|
656
861
|
hint.classList.remove('is-error');
|
|
657
|
-
hint.textContent='
|
|
862
|
+
hint.textContent='';
|
|
863
|
+
hint.setAttribute('hidden','');
|
|
658
864
|
}
|
|
659
865
|
k.addEventListener('input',syncInput);
|
|
660
866
|
k.addEventListener('keydown',function(e){
|
|
@@ -676,7 +882,7 @@ ${cmdScript}
|
|
|
676
882
|
function showSuccess(workspaceName,redirectUrl,providerName){
|
|
677
883
|
var tpl=document.getElementById('tpl-connected');
|
|
678
884
|
var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
679
|
-
var safeProv=String(providerName||'your
|
|
885
|
+
var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
680
886
|
var safeUrl=String(redirectUrl||'').replace(/"/g,'"').replace(/[<>]/g,function(c){return{'<':'<','>':'>'}[c]});
|
|
681
887
|
var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
|
|
682
888
|
pOk.innerHTML=html;
|
|
@@ -691,16 +897,16 @@ ${cmdScript}
|
|
|
691
897
|
f.addEventListener('submit',function(e){
|
|
692
898
|
e.preventDefault();
|
|
693
899
|
var v=k.value.trim();
|
|
694
|
-
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';return}
|
|
695
|
-
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}
|
|
696
902
|
sb.disabled=true;bt.textContent='Verifying';
|
|
697
903
|
show(pVerify);
|
|
698
904
|
|
|
699
905
|
var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
|
|
700
906
|
var i=0;verifySub.textContent=steps[0];
|
|
701
|
-
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);
|
|
702
908
|
|
|
703
|
-
var minDelay=new Promise(function(r){setTimeout(r,
|
|
909
|
+
var minDelay=new Promise(function(r){setTimeout(r,2800)});
|
|
704
910
|
var fd=new FormData(f);
|
|
705
911
|
var body=new URLSearchParams();
|
|
706
912
|
fd.forEach(function(val,key){body.append(key,String(val))});
|
|
@@ -864,7 +1070,7 @@ app.post(
|
|
|
864
1070
|
} catch {
|
|
865
1071
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
866
1072
|
}
|
|
867
|
-
const code =
|
|
1073
|
+
const code = randomUUID2();
|
|
868
1074
|
pendingCodes.set(code, {
|
|
869
1075
|
apiKey: api_key,
|
|
870
1076
|
codeChallenge: code_challenge,
|
|
@@ -884,42 +1090,13 @@ app.post(
|
|
|
884
1090
|
}
|
|
885
1091
|
);
|
|
886
1092
|
function issueTokens(apiKey) {
|
|
887
|
-
const now = Date.now();
|
|
888
|
-
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
889
|
-
let perKeyCount = 0;
|
|
890
|
-
let oldestKeyForApiKey = null;
|
|
891
|
-
let oldestAtForApiKey = Infinity;
|
|
892
|
-
for (const [k, v] of refreshTokens) {
|
|
893
|
-
if (v.apiKey === apiKey) {
|
|
894
|
-
perKeyCount++;
|
|
895
|
-
if (v.createdAt < oldestAtForApiKey) {
|
|
896
|
-
oldestAtForApiKey = v.createdAt;
|
|
897
|
-
oldestKeyForApiKey = k;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
|
|
902
|
-
refreshTokens.delete(oldestKeyForApiKey);
|
|
903
|
-
}
|
|
904
|
-
if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
|
|
905
|
-
let oldestKey = null;
|
|
906
|
-
let oldestAt = Infinity;
|
|
907
|
-
for (const [k, v] of refreshTokens) {
|
|
908
|
-
if (v.createdAt < oldestAt) {
|
|
909
|
-
oldestAt = v.createdAt;
|
|
910
|
-
oldestKey = k;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
if (oldestKey) refreshTokens.delete(oldestKey);
|
|
914
|
-
}
|
|
915
|
-
refreshTokens.set(refreshToken, { apiKey, createdAt: now });
|
|
916
1093
|
return {
|
|
917
1094
|
access_token: apiKey,
|
|
918
1095
|
token_type: "Bearer",
|
|
919
1096
|
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
920
1097
|
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
921
1098
|
expires_in: 365 * 24 * 3600,
|
|
922
|
-
refresh_token:
|
|
1099
|
+
refresh_token: signRefreshToken(apiKey)
|
|
923
1100
|
};
|
|
924
1101
|
}
|
|
925
1102
|
app.post(
|
|
@@ -930,19 +1107,15 @@ app.post(
|
|
|
930
1107
|
(req, res) => {
|
|
931
1108
|
const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
|
|
932
1109
|
if (grant_type === "refresh_token") {
|
|
933
|
-
const
|
|
934
|
-
if (!
|
|
935
|
-
res.status(400).json({
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
refreshTokens.delete(refresh_token);
|
|
940
|
-
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
|
+
});
|
|
941
1116
|
return;
|
|
942
1117
|
}
|
|
943
|
-
|
|
944
|
-
refreshTokens.delete(refresh_token);
|
|
945
|
-
res.json(issueTokens(apiKey));
|
|
1118
|
+
res.json(issueTokens(verified.apiKey));
|
|
946
1119
|
return;
|
|
947
1120
|
}
|
|
948
1121
|
if (grant_type !== "authorization_code") {
|
|
@@ -972,85 +1145,63 @@ var mcpLimiter = rateLimit({
|
|
|
972
1145
|
max: 120,
|
|
973
1146
|
standardHeaders: true,
|
|
974
1147
|
legacyHeaders: false,
|
|
975
|
-
|
|
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
|
+
}
|
|
976
1159
|
});
|
|
977
1160
|
var authFailures = /* @__PURE__ */ new Map();
|
|
978
|
-
var AUTH_FAILURE_MAX = 10;
|
|
979
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
980
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
981
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
982
|
-
function checkAuthBlock(ip) {
|
|
983
|
-
const rec = authFailures.get(ip);
|
|
984
|
-
if (!rec) return false;
|
|
985
|
-
return rec.blockedUntil > Date.now();
|
|
986
|
-
}
|
|
987
|
-
function recordAuthFailure(ip) {
|
|
988
|
-
const now = Date.now();
|
|
989
|
-
const rec = authFailures.get(ip);
|
|
990
|
-
if (!rec) {
|
|
991
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
995
|
-
rec.count = 1;
|
|
996
|
-
rec.firstFailure = now;
|
|
997
|
-
rec.blockedUntil = 0;
|
|
998
|
-
} else {
|
|
999
|
-
rec.count++;
|
|
1000
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
1001
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
1161
|
app.get("/health", (_req, res) => {
|
|
1006
1162
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1007
1163
|
});
|
|
1008
1164
|
var sessions = /* @__PURE__ */ new Map();
|
|
1009
1165
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1010
1166
|
var MAX_SESSIONS = 200;
|
|
1011
|
-
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
|
+
);
|
|
1012
1172
|
function evictStaleSessions() {
|
|
1013
1173
|
const now = Date.now();
|
|
1014
1174
|
for (const [id, entry] of sessions) {
|
|
1015
1175
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1016
|
-
logSessionLifecycle(
|
|
1176
|
+
logSessionLifecycle(
|
|
1177
|
+
"session_deleted",
|
|
1178
|
+
id,
|
|
1179
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1180
|
+
);
|
|
1017
1181
|
entry.transport.close().catch(() => {
|
|
1018
1182
|
});
|
|
1019
1183
|
sessions.delete(id);
|
|
1020
1184
|
}
|
|
1021
1185
|
}
|
|
1022
1186
|
if (sessions.size > MAX_SESSIONS) {
|
|
1023
|
-
const sorted = [...sessions.entries()].sort(
|
|
1024
|
-
|
|
1025
|
-
);
|
|
1026
|
-
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++) {
|
|
1027
1190
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1028
1191
|
sorted[i][1].transport.close().catch(() => {
|
|
1029
1192
|
});
|
|
1030
1193
|
sessions.delete(sorted[i][0]);
|
|
1031
1194
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const token = header.slice(7).trim();
|
|
1039
|
-
if (token.startsWith("pb_sk_")) {
|
|
1040
|
-
return token;
|
|
1041
|
-
}
|
|
1042
|
-
if (token.startsWith("pb_at_")) {
|
|
1043
|
-
const entry = accessTokens.get(token);
|
|
1044
|
-
if (!entry) return null;
|
|
1045
|
-
const now = Date.now();
|
|
1046
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1047
|
-
accessTokens.delete(token);
|
|
1048
|
-
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
|
+
);
|
|
1049
1201
|
}
|
|
1050
|
-
return entry.apiKey;
|
|
1051
1202
|
}
|
|
1052
|
-
return null;
|
|
1053
1203
|
}
|
|
1204
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1054
1205
|
function send401(req, res) {
|
|
1055
1206
|
const base = baseUrl(req);
|
|
1056
1207
|
res.status(401).set(
|
|
@@ -1073,17 +1224,21 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1073
1224
|
}
|
|
1074
1225
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1075
1226
|
const reqIp = req.ip ?? "unknown";
|
|
1076
|
-
|
|
1077
|
-
|
|
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 }));
|
|
1078
1232
|
return;
|
|
1079
1233
|
}
|
|
1080
|
-
const
|
|
1081
|
-
if (
|
|
1234
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1235
|
+
if (auth.status !== "ok") {
|
|
1082
1236
|
logRequest("POST", "auth_fail");
|
|
1083
|
-
recordAuthFailure(reqIp);
|
|
1237
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1084
1238
|
send401(req, res);
|
|
1085
1239
|
return;
|
|
1086
1240
|
}
|
|
1241
|
+
const apiKey = auth.apiKey;
|
|
1087
1242
|
const sessionId = req.headers["mcp-session-id"];
|
|
1088
1243
|
const reqStart = Date.now();
|
|
1089
1244
|
try {
|
|
@@ -1098,41 +1253,91 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1098
1253
|
});
|
|
1099
1254
|
return;
|
|
1100
1255
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
+
}
|
|
1104
1265
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1105
1266
|
const keyH = hashKey(apiKey);
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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(() => {
|
|
1115
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
|
+
}));
|
|
1116
1289
|
return;
|
|
1117
1290
|
}
|
|
1291
|
+
const sid = randomUUID2();
|
|
1292
|
+
let initialized = false;
|
|
1118
1293
|
const transport = new StreamableHTTPServerTransport({
|
|
1119
|
-
sessionIdGenerator: () =>
|
|
1120
|
-
onsessioninitialized: (
|
|
1121
|
-
|
|
1122
|
-
logSessionLifecycle("session_created",
|
|
1294
|
+
sessionIdGenerator: () => sid,
|
|
1295
|
+
onsessioninitialized: (s) => {
|
|
1296
|
+
initialized = true;
|
|
1297
|
+
logSessionLifecycle("session_created", s);
|
|
1123
1298
|
}
|
|
1299
|
+
// log only — no map write
|
|
1124
1300
|
});
|
|
1301
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1302
|
+
sessions.set(sid, reserved);
|
|
1125
1303
|
transport.onclose = () => {
|
|
1126
|
-
const
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
1129
|
-
|
|
1304
|
+
const s = transport.sessionId ?? sid;
|
|
1305
|
+
if (sessions.has(s)) {
|
|
1306
|
+
sessions.delete(s);
|
|
1307
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1130
1308
|
}
|
|
1131
1309
|
};
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
+
});
|
|
1136
1341
|
} else {
|
|
1137
1342
|
process.stderr.write(
|
|
1138
1343
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
@@ -1158,20 +1363,32 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1158
1363
|
});
|
|
1159
1364
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1160
1365
|
const reqIp = req.ip ?? "unknown";
|
|
1161
|
-
|
|
1162
|
-
|
|
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 }));
|
|
1163
1371
|
return;
|
|
1164
1372
|
}
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1373
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1374
|
+
if (auth.status !== "ok") {
|
|
1167
1375
|
logRequest("GET", "auth_fail");
|
|
1168
|
-
recordAuthFailure(reqIp);
|
|
1376
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1169
1377
|
send401(req, res);
|
|
1170
1378
|
return;
|
|
1171
1379
|
}
|
|
1380
|
+
const apiKey = auth.apiKey;
|
|
1172
1381
|
const sessionId = req.headers["mcp-session-id"];
|
|
1173
|
-
if (!sessionId
|
|
1174
|
-
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");
|
|
1175
1392
|
return;
|
|
1176
1393
|
}
|
|
1177
1394
|
try {
|
|
@@ -1195,20 +1412,32 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1195
1412
|
});
|
|
1196
1413
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1197
1414
|
const reqIp = req.ip ?? "unknown";
|
|
1198
|
-
|
|
1199
|
-
|
|
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 }));
|
|
1200
1420
|
return;
|
|
1201
1421
|
}
|
|
1202
|
-
const
|
|
1203
|
-
if (
|
|
1422
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1423
|
+
if (auth.status !== "ok") {
|
|
1204
1424
|
logRequest("DELETE", "auth_fail");
|
|
1205
|
-
recordAuthFailure(reqIp);
|
|
1425
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1206
1426
|
send401(req, res);
|
|
1207
1427
|
return;
|
|
1208
1428
|
}
|
|
1429
|
+
const apiKey = auth.apiKey;
|
|
1209
1430
|
const sessionId = req.headers["mcp-session-id"];
|
|
1210
|
-
if (!sessionId
|
|
1211
|
-
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");
|
|
1212
1441
|
return;
|
|
1213
1442
|
}
|
|
1214
1443
|
try {
|