@productbrain/mcp 0.0.1-beta.945 → 0.0.1-beta.9450
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/README.md +67 -0
- package/dist/chunk-BCYOMOD2.js +13547 -0
- package/dist/chunk-BCYOMOD2.js.map +1 -0
- package/dist/chunk-DKJ4X2D7.js +1684 -0
- package/dist/chunk-DKJ4X2D7.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +428 -129
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-ZT5VSESE.js} +35 -26
- package/dist/setup-ZT5VSESE.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +97 -72
- package/dist/views/src/graph-constellation/index.html +90 -65
- package/package.json +4 -3
- package/dist/chunk-VHL3Q5K6.js +0 -15191
- package/dist/chunk-VHL3Q5K6.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,18 +1,20 @@
|
|
|
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-VHL3Q5K6.js";
|
|
4
|
+
initFeatureFlags
|
|
5
|
+
} from "./chunk-BCYOMOD2.js";
|
|
11
6
|
import {
|
|
7
|
+
DEFAULT_CLOUD_URL,
|
|
8
|
+
apiKeysUrl,
|
|
9
|
+
bootstrapHttp,
|
|
10
|
+
forgetStreamLifecycle,
|
|
11
|
+
getKeyState,
|
|
12
12
|
getPostHogClient,
|
|
13
|
+
hashKey,
|
|
13
14
|
initAnalytics,
|
|
15
|
+
runWithAuth,
|
|
14
16
|
shutdownAnalytics
|
|
15
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-DKJ4X2D7.js";
|
|
16
18
|
|
|
17
19
|
// src/http.ts
|
|
18
20
|
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
@@ -111,6 +113,255 @@ function verifyRefreshToken(token) {
|
|
|
111
113
|
return { apiKey: payload.k };
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
// src/sessionCap.ts
|
|
117
|
+
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
118
|
+
if (cap < 1) {
|
|
119
|
+
throw new RangeError(`cap must be >= 1, got ${cap}`);
|
|
120
|
+
}
|
|
121
|
+
const { ttlMs, graceMs } = opts;
|
|
122
|
+
const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
|
|
123
|
+
const keyEntries = [];
|
|
124
|
+
for (const [id, session] of sessions2) {
|
|
125
|
+
if (session.keyHash === keyHash) {
|
|
126
|
+
keyEntries.push([id, session]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const evictSet = /* @__PURE__ */ new Set();
|
|
130
|
+
for (const [id, session] of keyEntries) {
|
|
131
|
+
if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
|
|
132
|
+
evictSet.add(id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
|
|
136
|
+
if (survivors.length >= cap) {
|
|
137
|
+
const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
|
|
138
|
+
for (const [id] of idleSurvivors) {
|
|
139
|
+
if (survivors.length < cap) break;
|
|
140
|
+
evictSet.add(id);
|
|
141
|
+
survivors = survivors.filter(([sid]) => sid !== id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const evict = Array.from(evictSet);
|
|
145
|
+
const admit = survivors.length < cap;
|
|
146
|
+
let retryAfterSeconds = 0;
|
|
147
|
+
let retryIsPoll = false;
|
|
148
|
+
if (!admit) {
|
|
149
|
+
const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
|
|
150
|
+
if (graceBlockers.length > 0) {
|
|
151
|
+
const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
|
|
152
|
+
const msRemaining = graceMs - (now - minLastAccess);
|
|
153
|
+
retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
|
|
154
|
+
} else {
|
|
155
|
+
retryAfterSeconds = 5;
|
|
156
|
+
retryIsPoll = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { evict, admit, retryAfterSeconds, retryIsPoll };
|
|
160
|
+
}
|
|
161
|
+
function countKeySessions(sessions2, keyHash) {
|
|
162
|
+
let count = 0;
|
|
163
|
+
for (const session of sessions2.values()) {
|
|
164
|
+
if (session.keyHash === keyHash) count++;
|
|
165
|
+
}
|
|
166
|
+
return count;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/httpErrors.ts
|
|
170
|
+
function clampSeconds(s) {
|
|
171
|
+
return Math.max(0, Math.round(s));
|
|
172
|
+
}
|
|
173
|
+
function build(reason, message, p) {
|
|
174
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
175
|
+
return {
|
|
176
|
+
status: 429,
|
|
177
|
+
headers: { "Retry-After": String(seconds) },
|
|
178
|
+
body: {
|
|
179
|
+
jsonrpc: "2.0",
|
|
180
|
+
id: p.id ?? null,
|
|
181
|
+
error: {
|
|
182
|
+
code: -32e3,
|
|
183
|
+
message,
|
|
184
|
+
data: {
|
|
185
|
+
reason,
|
|
186
|
+
retryAfterSeconds: seconds,
|
|
187
|
+
limit: p.limit,
|
|
188
|
+
current: p.current
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function buildRateLimitError(p) {
|
|
195
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
196
|
+
return build(
|
|
197
|
+
"rate_limited",
|
|
198
|
+
`Too many requests \u2014 retry after ${seconds} s.`,
|
|
199
|
+
p
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
function buildAuthLockoutError(p) {
|
|
203
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
204
|
+
return build(
|
|
205
|
+
"auth_lockout",
|
|
206
|
+
`Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
|
|
207
|
+
p
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
function buildSessionCapError(p) {
|
|
211
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
212
|
+
const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
|
|
213
|
+
return build("session_cap", message, p);
|
|
214
|
+
}
|
|
215
|
+
function send429(res, built) {
|
|
216
|
+
if (res.headersSent) return;
|
|
217
|
+
for (const [k, v] of Object.entries(built.headers)) {
|
|
218
|
+
res.setHeader(k, v);
|
|
219
|
+
}
|
|
220
|
+
res.status(built.status).json(built.body);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/authLockout.ts
|
|
224
|
+
var AUTH_FAILURE_MAX = 10;
|
|
225
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
226
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
227
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
228
|
+
function resolveBearerAuth(authorizationHeader, accessTokens2, now, accessTokenTtlMs) {
|
|
229
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
230
|
+
return { status: "absent" };
|
|
231
|
+
}
|
|
232
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
233
|
+
return { status: "rejected" };
|
|
234
|
+
}
|
|
235
|
+
const token = authorizationHeader.slice(7).trim();
|
|
236
|
+
if (token === "") {
|
|
237
|
+
return { status: "rejected" };
|
|
238
|
+
}
|
|
239
|
+
if (token.startsWith("pb_sk_")) {
|
|
240
|
+
return { status: "ok", apiKey: token };
|
|
241
|
+
}
|
|
242
|
+
if (token.startsWith("pb_at_")) {
|
|
243
|
+
const entry = accessTokens2.get(token);
|
|
244
|
+
if (!entry) return { status: "rejected" };
|
|
245
|
+
if (now - entry.createdAt > accessTokenTtlMs) {
|
|
246
|
+
accessTokens2.delete(token);
|
|
247
|
+
return { status: "rejected" };
|
|
248
|
+
}
|
|
249
|
+
return { status: "ok", apiKey: entry.apiKey };
|
|
250
|
+
}
|
|
251
|
+
return { status: "rejected" };
|
|
252
|
+
}
|
|
253
|
+
function checkAuthBlock(failures, ip, now) {
|
|
254
|
+
const rec = failures.get(ip);
|
|
255
|
+
if (!rec) return false;
|
|
256
|
+
return rec.blockedUntil > now;
|
|
257
|
+
}
|
|
258
|
+
function recordAuthFailure(failures, ip, now) {
|
|
259
|
+
const rec = failures.get(ip);
|
|
260
|
+
if (!rec) {
|
|
261
|
+
failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
265
|
+
rec.count = 1;
|
|
266
|
+
rec.firstFailure = now;
|
|
267
|
+
rec.blockedUntil = 0;
|
|
268
|
+
} else {
|
|
269
|
+
rec.count++;
|
|
270
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
271
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
277
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
278
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
279
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
280
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
281
|
+
];
|
|
282
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
283
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
284
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
285
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
286
|
+
var OPERATOR_ENTRY_FORM = "OAUTH_REDIRECT_HOST_ALLOWLIST entries must be full https redirect URIs such as https://partner.example/oauth/callback (no bare host, no query, no fragment)";
|
|
287
|
+
function exactForm(url) {
|
|
288
|
+
return `${url.origin}${url.pathname}`;
|
|
289
|
+
}
|
|
290
|
+
function parseRedirectUriAllowlist(raw) {
|
|
291
|
+
if (!raw) return { uris: [], configError: null };
|
|
292
|
+
const uris = [];
|
|
293
|
+
for (const part of raw.split(",")) {
|
|
294
|
+
const trimmed = part.trim();
|
|
295
|
+
if (!trimmed) continue;
|
|
296
|
+
let url = null;
|
|
297
|
+
try {
|
|
298
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
299
|
+
} catch {
|
|
300
|
+
url = null;
|
|
301
|
+
}
|
|
302
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
303
|
+
if (!valid || url === null) {
|
|
304
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
305
|
+
}
|
|
306
|
+
uris.push(exactForm(url));
|
|
307
|
+
}
|
|
308
|
+
return { uris, configError: null };
|
|
309
|
+
}
|
|
310
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
311
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
312
|
+
return {
|
|
313
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
314
|
+
configError: parsed.configError
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
318
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
319
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
320
|
+
return "redirect_uri is required";
|
|
321
|
+
}
|
|
322
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
323
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
324
|
+
}
|
|
325
|
+
let url;
|
|
326
|
+
try {
|
|
327
|
+
url = new URL(redirectUri);
|
|
328
|
+
} catch {
|
|
329
|
+
return "redirect_uri is not a valid absolute URL";
|
|
330
|
+
}
|
|
331
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
332
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
333
|
+
const host = url.hostname.toLowerCase();
|
|
334
|
+
if (url.protocol === "http:") {
|
|
335
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
336
|
+
}
|
|
337
|
+
if (url.protocol === "https:") {
|
|
338
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
339
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
340
|
+
const exact = exactForm(url);
|
|
341
|
+
return allowlist.uris.includes(exact) || CHATGPT_CONNECTOR_CALLBACK.test(exact) ? null : "redirect_uri is not an allowed callback (an operator can add the full URI with OAUTH_REDIRECT_HOST_ALLOWLIST)";
|
|
342
|
+
}
|
|
343
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
344
|
+
}
|
|
345
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
346
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
347
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
348
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
349
|
+
}
|
|
350
|
+
for (const uri of redirectUris) {
|
|
351
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
352
|
+
if (reason) return reason;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/lib/oauthAuthCode.ts
|
|
358
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
359
|
+
if (!pending) return "unknown_code";
|
|
360
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
361
|
+
if (now > pending.expiresAt) return "expired";
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
114
365
|
// src/http.ts
|
|
115
366
|
bootstrapHttp();
|
|
116
367
|
initAnalytics();
|
|
@@ -125,6 +376,7 @@ var app = express();
|
|
|
125
376
|
app.set("trust proxy", 1);
|
|
126
377
|
app.use(express.json());
|
|
127
378
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
379
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
128
380
|
app.use((_req, res, next) => {
|
|
129
381
|
const origin = _req.headers.origin;
|
|
130
382
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -135,7 +387,7 @@ app.use((_req, res, next) => {
|
|
|
135
387
|
"Access-Control-Allow-Headers",
|
|
136
388
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
137
389
|
);
|
|
138
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
390
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
139
391
|
if (_req.method === "OPTIONS") {
|
|
140
392
|
res.status(204).end();
|
|
141
393
|
return;
|
|
@@ -187,11 +439,9 @@ app.post(
|
|
|
187
439
|
return;
|
|
188
440
|
}
|
|
189
441
|
const { redirect_uris, client_name } = req.body;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
error_description: "redirect_uris is required"
|
|
194
|
-
});
|
|
442
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
443
|
+
if (refusal) {
|
|
444
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
195
445
|
return;
|
|
196
446
|
}
|
|
197
447
|
const clientId = `pb_client_${randomUUID2()}`;
|
|
@@ -609,7 +859,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
609
859
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
610
860
|
<div class="err-actions">
|
|
611
861
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
612
|
-
<a href="
|
|
862
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
613
863
|
</div>`;
|
|
614
864
|
}
|
|
615
865
|
var cmdScript = `
|
|
@@ -657,7 +907,7 @@ function authorizeFormPage(params) {
|
|
|
657
907
|
<div class="hint" id="hint" hidden></div>
|
|
658
908
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
659
909
|
</form>
|
|
660
|
-
<div class="small-link"><a href="
|
|
910
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
661
911
|
</div>
|
|
662
912
|
|
|
663
913
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -796,6 +1046,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
796
1046
|
}
|
|
797
1047
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
798
1048
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1049
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1050
|
+
if (refusal) {
|
|
1051
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
799
1054
|
const cid = String(client_id ?? "");
|
|
800
1055
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
801
1056
|
res.type("html").send(authorizeFormPage({
|
|
@@ -837,28 +1092,22 @@ app.post(
|
|
|
837
1092
|
return;
|
|
838
1093
|
}
|
|
839
1094
|
if (!client_id) {
|
|
840
|
-
res.status(400).json({
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1095
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1099
|
+
if (redirectRefusal) {
|
|
1100
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
844
1101
|
return;
|
|
845
1102
|
}
|
|
846
1103
|
if (!registeredClients.has(client_id)) {
|
|
847
|
-
if (
|
|
848
|
-
|
|
849
|
-
client_id,
|
|
850
|
-
redirect_uris: [redirect_uri],
|
|
851
|
-
registeredAt: Date.now()
|
|
852
|
-
});
|
|
853
|
-
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
854
|
-
`);
|
|
855
|
-
} else {
|
|
856
|
-
res.status(400).json({
|
|
857
|
-
error: "invalid_request",
|
|
858
|
-
error_description: "Unknown client_id and redirect_uri is not a valid https URL"
|
|
859
|
-
});
|
|
1104
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1105
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
860
1106
|
return;
|
|
861
1107
|
}
|
|
1108
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1109
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1110
|
+
`);
|
|
862
1111
|
}
|
|
863
1112
|
const client = registeredClients.get(client_id);
|
|
864
1113
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -875,6 +1124,7 @@ app.post(
|
|
|
875
1124
|
const candidates = [primaryUrl, ...fallbackUrls];
|
|
876
1125
|
let foundUrl;
|
|
877
1126
|
let anyDefinitiveReject = false;
|
|
1127
|
+
let rateLimitedRetryAfter;
|
|
878
1128
|
for (const url2 of candidates) {
|
|
879
1129
|
let checkData = null;
|
|
880
1130
|
try {
|
|
@@ -892,9 +1142,21 @@ app.post(
|
|
|
892
1142
|
foundUrl = checkData.deploymentUrl ?? url2;
|
|
893
1143
|
break;
|
|
894
1144
|
}
|
|
1145
|
+
if (checkData.error === "rate_limited") {
|
|
1146
|
+
rateLimitedRetryAfter = checkData.retryAfterSeconds;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
895
1149
|
anyDefinitiveReject = true;
|
|
896
1150
|
}
|
|
897
1151
|
if (!foundUrl) {
|
|
1152
|
+
if (rateLimitedRetryAfter != null && !anyDefinitiveReject) {
|
|
1153
|
+
sendError(
|
|
1154
|
+
"Rate limited",
|
|
1155
|
+
`This API key is valid but temporarily rate limited. Try again in ${rateLimitedRetryAfter}s.`,
|
|
1156
|
+
429
|
|
1157
|
+
);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
898
1160
|
if (anyDefinitiveReject) {
|
|
899
1161
|
sendError(
|
|
900
1162
|
"Key not recognized",
|
|
@@ -963,7 +1225,13 @@ app.post(
|
|
|
963
1225
|
return;
|
|
964
1226
|
}
|
|
965
1227
|
const pending = pendingCodes.get(code);
|
|
966
|
-
|
|
1228
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1229
|
+
if (codeRefusal === "expired") {
|
|
1230
|
+
pendingCodes.delete(code);
|
|
1231
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (codeRefusal || !pending) {
|
|
967
1235
|
res.status(400).json({ error: "invalid_grant" });
|
|
968
1236
|
return;
|
|
969
1237
|
}
|
|
@@ -985,85 +1253,63 @@ var mcpLimiter = rateLimit({
|
|
|
985
1253
|
max: 120,
|
|
986
1254
|
standardHeaders: true,
|
|
987
1255
|
legacyHeaders: false,
|
|
988
|
-
|
|
1256
|
+
handler: (req, res) => {
|
|
1257
|
+
const r = req.rateLimit;
|
|
1258
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
|
|
1259
|
+
send429(res, buildRateLimitError({
|
|
1260
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
1261
|
+
limit: r?.limit ?? 120,
|
|
1262
|
+
current: r?.used ?? 0,
|
|
1263
|
+
id: req.body?.id ?? null
|
|
1264
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
1265
|
+
}));
|
|
1266
|
+
}
|
|
989
1267
|
});
|
|
990
1268
|
var authFailures = /* @__PURE__ */ new Map();
|
|
991
|
-
var AUTH_FAILURE_MAX = 10;
|
|
992
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
993
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
994
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
995
|
-
function checkAuthBlock(ip) {
|
|
996
|
-
const rec = authFailures.get(ip);
|
|
997
|
-
if (!rec) return false;
|
|
998
|
-
return rec.blockedUntil > Date.now();
|
|
999
|
-
}
|
|
1000
|
-
function recordAuthFailure(ip) {
|
|
1001
|
-
const now = Date.now();
|
|
1002
|
-
const rec = authFailures.get(ip);
|
|
1003
|
-
if (!rec) {
|
|
1004
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
1008
|
-
rec.count = 1;
|
|
1009
|
-
rec.firstFailure = now;
|
|
1010
|
-
rec.blockedUntil = 0;
|
|
1011
|
-
} else {
|
|
1012
|
-
rec.count++;
|
|
1013
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
1014
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
1269
|
app.get("/health", (_req, res) => {
|
|
1019
1270
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1020
1271
|
});
|
|
1021
1272
|
var sessions = /* @__PURE__ */ new Map();
|
|
1022
1273
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1023
1274
|
var MAX_SESSIONS = 200;
|
|
1024
|
-
var
|
|
1275
|
+
var EVICT_GRACE_MS = 6e4;
|
|
1276
|
+
var MAX_SESSIONS_PER_KEY = Math.max(
|
|
1277
|
+
1,
|
|
1278
|
+
Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
|
|
1279
|
+
);
|
|
1025
1280
|
function evictStaleSessions() {
|
|
1026
1281
|
const now = Date.now();
|
|
1027
1282
|
for (const [id, entry] of sessions) {
|
|
1028
1283
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1029
|
-
logSessionLifecycle(
|
|
1284
|
+
logSessionLifecycle(
|
|
1285
|
+
"session_deleted",
|
|
1286
|
+
id,
|
|
1287
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1288
|
+
);
|
|
1030
1289
|
entry.transport.close().catch(() => {
|
|
1031
1290
|
});
|
|
1032
1291
|
sessions.delete(id);
|
|
1033
1292
|
}
|
|
1034
1293
|
}
|
|
1035
1294
|
if (sessions.size > MAX_SESSIONS) {
|
|
1036
|
-
const sorted = [...sessions.entries()].sort(
|
|
1037
|
-
|
|
1038
|
-
);
|
|
1039
|
-
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
1295
|
+
const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
1296
|
+
const overflow = sessions.size - MAX_SESSIONS;
|
|
1297
|
+
for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
|
|
1040
1298
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1041
1299
|
sorted[i][1].transport.close().catch(() => {
|
|
1042
1300
|
});
|
|
1043
1301
|
sessions.delete(sorted[i][0]);
|
|
1044
1302
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
const token = header.slice(7).trim();
|
|
1052
|
-
if (token.startsWith("pb_sk_")) {
|
|
1053
|
-
return token;
|
|
1054
|
-
}
|
|
1055
|
-
if (token.startsWith("pb_at_")) {
|
|
1056
|
-
const entry = accessTokens.get(token);
|
|
1057
|
-
if (!entry) return null;
|
|
1058
|
-
const now = Date.now();
|
|
1059
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1060
|
-
accessTokens.delete(token);
|
|
1061
|
-
return null;
|
|
1303
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
1304
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1305
|
+
process.stderr.write(
|
|
1306
|
+
`[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
|
|
1307
|
+
`
|
|
1308
|
+
);
|
|
1062
1309
|
}
|
|
1063
|
-
return entry.apiKey;
|
|
1064
1310
|
}
|
|
1065
|
-
return null;
|
|
1066
1311
|
}
|
|
1312
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1067
1313
|
function send401(req, res) {
|
|
1068
1314
|
const base = baseUrl(req);
|
|
1069
1315
|
res.status(401).set(
|
|
@@ -1086,21 +1332,25 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1086
1332
|
}
|
|
1087
1333
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1088
1334
|
const reqIp = req.ip ?? "unknown";
|
|
1089
|
-
|
|
1090
|
-
|
|
1335
|
+
const nowTs = Date.now();
|
|
1336
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1337
|
+
const rec = authFailures.get(reqIp);
|
|
1338
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1339
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1091
1340
|
return;
|
|
1092
1341
|
}
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1342
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1343
|
+
if (auth.status !== "ok") {
|
|
1095
1344
|
logRequest("POST", "auth_fail");
|
|
1096
|
-
recordAuthFailure(reqIp);
|
|
1345
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1097
1346
|
send401(req, res);
|
|
1098
1347
|
return;
|
|
1099
1348
|
}
|
|
1349
|
+
const apiKey = auth.apiKey;
|
|
1100
1350
|
const sessionId = req.headers["mcp-session-id"];
|
|
1101
1351
|
const reqStart = Date.now();
|
|
1102
1352
|
try {
|
|
1103
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1353
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1104
1354
|
if (sessionId && sessions.has(sessionId)) {
|
|
1105
1355
|
const entry = sessions.get(sessionId);
|
|
1106
1356
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1111,41 +1361,82 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1111
1361
|
});
|
|
1112
1362
|
return;
|
|
1113
1363
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1364
|
+
try {
|
|
1365
|
+
entry.inFlight++;
|
|
1366
|
+
entry.lastAccess = Date.now();
|
|
1367
|
+
await entry.transport.handleRequest(req, res, req.body);
|
|
1368
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
1369
|
+
} finally {
|
|
1370
|
+
entry.inFlight--;
|
|
1371
|
+
entry.lastAccess = Date.now();
|
|
1372
|
+
}
|
|
1117
1373
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1118
1374
|
const keyH = hashKey(apiKey);
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1375
|
+
const plan = planKeyAdmission(
|
|
1376
|
+
sessions,
|
|
1377
|
+
keyH,
|
|
1378
|
+
MAX_SESSIONS_PER_KEY,
|
|
1379
|
+
Date.now(),
|
|
1380
|
+
{ ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
|
|
1381
|
+
);
|
|
1382
|
+
for (const sid2 of plan.evict) {
|
|
1383
|
+
const victim = sessions.get(sid2);
|
|
1384
|
+
sessions.delete(sid2);
|
|
1385
|
+
victim?.transport.close().catch(() => {
|
|
1128
1386
|
});
|
|
1387
|
+
logSessionLifecycle("session_deleted", sid2, "lru_evict");
|
|
1388
|
+
}
|
|
1389
|
+
if (!plan.admit) {
|
|
1390
|
+
send429(res, buildSessionCapError({
|
|
1391
|
+
retryAfterSeconds: plan.retryAfterSeconds,
|
|
1392
|
+
limit: MAX_SESSIONS_PER_KEY,
|
|
1393
|
+
current: countKeySessions(sessions, keyH),
|
|
1394
|
+
poll: plan.retryIsPoll,
|
|
1395
|
+
id: req.body?.id ?? null
|
|
1396
|
+
}));
|
|
1129
1397
|
return;
|
|
1130
1398
|
}
|
|
1399
|
+
const sid = randomUUID2();
|
|
1400
|
+
let initialized = false;
|
|
1131
1401
|
const transport = new StreamableHTTPServerTransport({
|
|
1132
|
-
sessionIdGenerator: () =>
|
|
1133
|
-
onsessioninitialized: (
|
|
1134
|
-
|
|
1135
|
-
logSessionLifecycle("session_created",
|
|
1402
|
+
sessionIdGenerator: () => sid,
|
|
1403
|
+
onsessioninitialized: (s) => {
|
|
1404
|
+
initialized = true;
|
|
1405
|
+
logSessionLifecycle("session_created", s);
|
|
1136
1406
|
}
|
|
1407
|
+
// log only — no map write
|
|
1137
1408
|
});
|
|
1409
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1410
|
+
sessions.set(sid, reserved);
|
|
1138
1411
|
transport.onclose = () => {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
sessions.delete(
|
|
1412
|
+
const s = transport.sessionId ?? sid;
|
|
1413
|
+
forgetStreamLifecycle(keyH, s);
|
|
1414
|
+
if (sessions.has(s)) {
|
|
1415
|
+
sessions.delete(s);
|
|
1416
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1143
1417
|
}
|
|
1144
1418
|
};
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1419
|
+
try {
|
|
1420
|
+
const server = createProductBrainServer();
|
|
1421
|
+
await server.connect(transport);
|
|
1422
|
+
await transport.handleRequest(req, res, req.body);
|
|
1423
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
1424
|
+
} catch (e) {
|
|
1425
|
+
transport.onclose = void 0;
|
|
1426
|
+
sessions.delete(sid);
|
|
1427
|
+
throw e;
|
|
1428
|
+
} finally {
|
|
1429
|
+
if (!initialized) {
|
|
1430
|
+
transport.onclose = void 0;
|
|
1431
|
+
if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
|
|
1432
|
+
} else {
|
|
1433
|
+
const e = sessions.get(sid);
|
|
1434
|
+
if (e) {
|
|
1435
|
+
e.inFlight--;
|
|
1436
|
+
e.lastAccess = Date.now();
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1149
1440
|
} else if (sessionId) {
|
|
1150
1441
|
process.stderr.write(
|
|
1151
1442
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
|
|
@@ -1181,17 +1472,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1181
1472
|
});
|
|
1182
1473
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1183
1474
|
const reqIp = req.ip ?? "unknown";
|
|
1184
|
-
|
|
1185
|
-
|
|
1475
|
+
const nowTs = Date.now();
|
|
1476
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1477
|
+
const rec = authFailures.get(reqIp);
|
|
1478
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1479
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1186
1480
|
return;
|
|
1187
1481
|
}
|
|
1188
|
-
const
|
|
1189
|
-
if (
|
|
1482
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1483
|
+
if (auth.status !== "ok") {
|
|
1190
1484
|
logRequest("GET", "auth_fail");
|
|
1191
|
-
recordAuthFailure(reqIp);
|
|
1485
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1192
1486
|
send401(req, res);
|
|
1193
1487
|
return;
|
|
1194
1488
|
}
|
|
1489
|
+
const apiKey = auth.apiKey;
|
|
1195
1490
|
const sessionId = req.headers["mcp-session-id"];
|
|
1196
1491
|
if (!sessionId) {
|
|
1197
1492
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1206,7 +1501,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1206
1501
|
return;
|
|
1207
1502
|
}
|
|
1208
1503
|
try {
|
|
1209
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1504
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1210
1505
|
const entry = sessions.get(sessionId);
|
|
1211
1506
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1212
1507
|
res.status(403).json({
|
|
@@ -1226,17 +1521,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1226
1521
|
});
|
|
1227
1522
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1228
1523
|
const reqIp = req.ip ?? "unknown";
|
|
1229
|
-
|
|
1230
|
-
|
|
1524
|
+
const nowTs = Date.now();
|
|
1525
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1526
|
+
const rec = authFailures.get(reqIp);
|
|
1527
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1528
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1231
1529
|
return;
|
|
1232
1530
|
}
|
|
1233
|
-
const
|
|
1234
|
-
if (
|
|
1531
|
+
const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
|
|
1532
|
+
if (auth.status !== "ok") {
|
|
1235
1533
|
logRequest("DELETE", "auth_fail");
|
|
1236
|
-
recordAuthFailure(reqIp);
|
|
1534
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1237
1535
|
send401(req, res);
|
|
1238
1536
|
return;
|
|
1239
1537
|
}
|
|
1538
|
+
const apiKey = auth.apiKey;
|
|
1240
1539
|
const sessionId = req.headers["mcp-session-id"];
|
|
1241
1540
|
if (!sessionId) {
|
|
1242
1541
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1251,7 +1550,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1251
1550
|
return;
|
|
1252
1551
|
}
|
|
1253
1552
|
try {
|
|
1254
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1553
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1255
1554
|
const entry = sessions.get(sessionId);
|
|
1256
1555
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1257
1556
|
res.status(403).json({
|