@productbrain/mcp 0.0.1-beta.1016 → 0.0.1-beta.10165
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-BS4ZQRGV.js +1688 -0
- package/dist/chunk-BS4ZQRGV.js.map +1 -0
- package/dist/chunk-UM2G5FII.js +12932 -0
- package/dist/chunk-UM2G5FII.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +506 -266
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-6CXCTQKR.js} +20 -104
- package/dist/setup-6CXCTQKR.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +85 -60
- package/dist/views/src/graph-constellation/index.html +72 -47
- package/package.json +4 -3
- package/dist/chunk-CIHITLKC.js +0 -15293
- package/dist/chunk-CIHITLKC.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,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
PRODUCT_NAME,
|
|
3
3
|
SERVER_VERSION,
|
|
4
|
-
bootstrapHttp,
|
|
5
4
|
createProductBrainServer,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
initFeatureFlags,
|
|
9
|
-
runWithAuth
|
|
10
|
-
} from "./chunk-CIHITLKC.js";
|
|
5
|
+
initFeatureFlags
|
|
6
|
+
} from "./chunk-UM2G5FII.js";
|
|
11
7
|
import {
|
|
8
|
+
DEFAULT_CLOUD_URL,
|
|
9
|
+
apiKeysUrl,
|
|
10
|
+
bootstrapHttp,
|
|
11
|
+
checkKeyAgainstCandidates,
|
|
12
|
+
forgetStreamLifecycle,
|
|
13
|
+
getKeyState,
|
|
12
14
|
getPostHogClient,
|
|
15
|
+
hashKey,
|
|
13
16
|
initAnalytics,
|
|
17
|
+
parseFallbackUrls,
|
|
18
|
+
runWithAuth,
|
|
14
19
|
shutdownAnalytics
|
|
15
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-BS4ZQRGV.js";
|
|
16
21
|
|
|
17
22
|
// src/http.ts
|
|
18
|
-
import { createHash, randomUUID
|
|
23
|
+
import { createHash, randomUUID } from "crypto";
|
|
19
24
|
import express from "express";
|
|
20
25
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
26
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -50,65 +55,296 @@ function appLogoMarkup(opts = {}) {
|
|
|
50
55
|
const size = opts.size ?? "sm";
|
|
51
56
|
const showWordmark = opts.showWordmark ?? true;
|
|
52
57
|
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
53
|
-
const wordmark = showWordmark ? `<span class="pb-logo__name"
|
|
58
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
|
|
54
59
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
// src/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
);
|
|
62
|
+
// src/sessionCap.ts
|
|
63
|
+
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
64
|
+
if (cap < 1) {
|
|
65
|
+
throw new RangeError(`cap must be >= 1, got ${cap}`);
|
|
68
66
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
const { ttlMs, graceMs } = opts;
|
|
68
|
+
const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
|
|
69
|
+
const keyEntries = [];
|
|
70
|
+
for (const [id, session] of sessions2) {
|
|
71
|
+
if (session.keyHash === keyHash) {
|
|
72
|
+
keyEntries.push([id, session]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const evictSet = /* @__PURE__ */ new Set();
|
|
76
|
+
for (const [id, session] of keyEntries) {
|
|
77
|
+
if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
|
|
78
|
+
evictSet.add(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
|
|
82
|
+
if (survivors.length >= cap) {
|
|
83
|
+
const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
|
|
84
|
+
for (const [id] of idleSurvivors) {
|
|
85
|
+
if (survivors.length < cap) break;
|
|
86
|
+
evictSet.add(id);
|
|
87
|
+
survivors = survivors.filter(([sid]) => sid !== id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const evict = Array.from(evictSet);
|
|
91
|
+
const admit = survivors.length < cap;
|
|
92
|
+
let retryAfterSeconds = 0;
|
|
93
|
+
let retryIsPoll = false;
|
|
94
|
+
if (!admit) {
|
|
95
|
+
const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
|
|
96
|
+
if (graceBlockers.length > 0) {
|
|
97
|
+
const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
|
|
98
|
+
const msRemaining = graceMs - (now - minLastAccess);
|
|
99
|
+
retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
|
|
100
|
+
} else {
|
|
101
|
+
retryAfterSeconds = 5;
|
|
102
|
+
retryIsPoll = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { evict, admit, retryAfterSeconds, retryIsPoll };
|
|
106
|
+
}
|
|
107
|
+
function countKeySessions(sessions2, keyHash) {
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const session of sessions2.values()) {
|
|
110
|
+
if (session.keyHash === keyHash) count++;
|
|
111
|
+
}
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/httpErrors.ts
|
|
116
|
+
function clampSeconds(s) {
|
|
117
|
+
return Math.max(0, Math.round(s));
|
|
118
|
+
}
|
|
119
|
+
function build(reason, message, p) {
|
|
120
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
121
|
+
return {
|
|
122
|
+
status: 429,
|
|
123
|
+
headers: { "Retry-After": String(seconds) },
|
|
124
|
+
body: {
|
|
125
|
+
jsonrpc: "2.0",
|
|
126
|
+
id: p.id ?? null,
|
|
127
|
+
error: {
|
|
128
|
+
code: -32e3,
|
|
129
|
+
message,
|
|
130
|
+
data: {
|
|
131
|
+
reason,
|
|
132
|
+
retryAfterSeconds: seconds,
|
|
133
|
+
limit: p.limit,
|
|
134
|
+
current: p.current
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
79
138
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
139
|
+
}
|
|
140
|
+
function buildRateLimitError(p) {
|
|
141
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
142
|
+
return build(
|
|
143
|
+
"rate_limited",
|
|
144
|
+
`Too many requests \u2014 retry after ${seconds} s.`,
|
|
145
|
+
p
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function buildAuthLockoutError(p) {
|
|
149
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
150
|
+
return build(
|
|
151
|
+
"auth_lockout",
|
|
152
|
+
`Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
|
|
153
|
+
p
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
function buildSessionCapError(p) {
|
|
157
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
158
|
+
const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
|
|
159
|
+
return build("session_cap", message, p);
|
|
160
|
+
}
|
|
161
|
+
function send429(res, built) {
|
|
162
|
+
if (res.headersSent) return;
|
|
163
|
+
for (const [k, v] of Object.entries(built.headers)) {
|
|
164
|
+
res.setHeader(k, v);
|
|
165
|
+
}
|
|
166
|
+
res.status(built.status).json(built.body);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/authLockout.ts
|
|
170
|
+
var AUTH_FAILURE_MAX = 10;
|
|
171
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
172
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
173
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
174
|
+
function rejected(presented) {
|
|
175
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
176
|
+
}
|
|
177
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
178
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
179
|
+
return { status: "absent" };
|
|
180
|
+
}
|
|
181
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
182
|
+
return rejected(authorizationHeader.trim());
|
|
183
|
+
}
|
|
184
|
+
const token = authorizationHeader.slice(7).trim();
|
|
185
|
+
if (!token.startsWith("pb_sk_")) return rejected(token);
|
|
186
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
187
|
+
}
|
|
188
|
+
function resolvePastedKey(apiKey) {
|
|
189
|
+
if (apiKey === "") return { status: "absent" };
|
|
190
|
+
if (!apiKey.startsWith("pb_sk_")) return rejected(apiKey);
|
|
191
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
192
|
+
}
|
|
193
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
194
|
+
if (secFetchSite === "cross-site") return true;
|
|
195
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
92
196
|
try {
|
|
93
|
-
|
|
197
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
94
198
|
} catch {
|
|
95
|
-
return
|
|
199
|
+
return true;
|
|
96
200
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
201
|
+
}
|
|
202
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
203
|
+
const rec = failures.get(credentialId);
|
|
204
|
+
if (!rec) return false;
|
|
205
|
+
return rec.blockedUntil > now;
|
|
206
|
+
}
|
|
207
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
208
|
+
if (auth.status === "absent") return null;
|
|
209
|
+
const rec = failures.get(auth.credentialId);
|
|
210
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
211
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
212
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
213
|
+
}
|
|
214
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
215
|
+
const rec = failures.get(credentialId);
|
|
216
|
+
if (!rec) {
|
|
217
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
218
|
+
const oldest = failures.keys().next().value;
|
|
219
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
220
|
+
}
|
|
221
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
225
|
+
rec.count = 1;
|
|
226
|
+
rec.firstFailure = now;
|
|
227
|
+
rec.blockedUntil = 0;
|
|
228
|
+
} else {
|
|
229
|
+
rec.count++;
|
|
230
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
231
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
236
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
237
|
+
if (verdict === "rejected") {
|
|
238
|
+
recordAuthFailure(failures, credentialId, now);
|
|
239
|
+
return "unauthorized";
|
|
240
|
+
}
|
|
241
|
+
return "unavailable";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
245
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
246
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
247
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
248
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
249
|
+
];
|
|
250
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
251
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
252
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
253
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
254
|
+
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)";
|
|
255
|
+
function exactForm(url) {
|
|
256
|
+
return `${url.origin}${url.pathname}`;
|
|
257
|
+
}
|
|
258
|
+
function parseRedirectUriAllowlist(raw) {
|
|
259
|
+
if (!raw) return { uris: [], configError: null };
|
|
260
|
+
const uris = [];
|
|
261
|
+
for (const part of raw.split(",")) {
|
|
262
|
+
const trimmed = part.trim();
|
|
263
|
+
if (!trimmed) continue;
|
|
264
|
+
let url = null;
|
|
265
|
+
try {
|
|
266
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
267
|
+
} catch {
|
|
268
|
+
url = null;
|
|
269
|
+
}
|
|
270
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
271
|
+
if (!valid || url === null) {
|
|
272
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
273
|
+
}
|
|
274
|
+
uris.push(exactForm(url));
|
|
275
|
+
}
|
|
276
|
+
return { uris, configError: null };
|
|
277
|
+
}
|
|
278
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
279
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
280
|
+
return {
|
|
281
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
282
|
+
configError: parsed.configError
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
286
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
287
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
288
|
+
return "redirect_uri is required";
|
|
289
|
+
}
|
|
290
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
291
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
292
|
+
}
|
|
293
|
+
let url;
|
|
101
294
|
try {
|
|
102
|
-
|
|
103
|
-
payload = JSON.parse(json);
|
|
295
|
+
url = new URL(redirectUri);
|
|
104
296
|
} catch {
|
|
105
|
-
return
|
|
297
|
+
return "redirect_uri is not a valid absolute URL";
|
|
298
|
+
}
|
|
299
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
300
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
301
|
+
const host = url.hostname.toLowerCase();
|
|
302
|
+
if (url.protocol === "http:") {
|
|
303
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
304
|
+
}
|
|
305
|
+
if (url.protocol === "https:") {
|
|
306
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
307
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
308
|
+
const exact = exactForm(url);
|
|
309
|
+
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)";
|
|
106
310
|
}
|
|
107
|
-
|
|
108
|
-
|
|
311
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
312
|
+
}
|
|
313
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
314
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
315
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
316
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
317
|
+
}
|
|
318
|
+
for (const uri of redirectUris) {
|
|
319
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
320
|
+
if (reason) return reason;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/lib/oauthAuthCode.ts
|
|
326
|
+
function normalisePastedKey(raw) {
|
|
327
|
+
if (typeof raw !== "string") return "";
|
|
328
|
+
const trimmed = raw.trim();
|
|
329
|
+
const first = trimmed[0];
|
|
330
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
331
|
+
return trimmed.slice(1, -1).trim();
|
|
109
332
|
}
|
|
110
|
-
|
|
111
|
-
|
|
333
|
+
return trimmed;
|
|
334
|
+
}
|
|
335
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
336
|
+
if (!pending) return "unknown_code";
|
|
337
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
338
|
+
if (now > pending.expiresAt) return "expired";
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
function tokenResponse(apiKey) {
|
|
342
|
+
return {
|
|
343
|
+
access_token: apiKey,
|
|
344
|
+
token_type: "Bearer",
|
|
345
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
346
|
+
expires_in: 365 * 24 * 3600
|
|
347
|
+
};
|
|
112
348
|
}
|
|
113
349
|
|
|
114
350
|
// src/http.ts
|
|
@@ -116,6 +352,10 @@ bootstrapHttp();
|
|
|
116
352
|
initAnalytics();
|
|
117
353
|
initFeatureFlags(getPostHogClient());
|
|
118
354
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
355
|
+
function keyCheckCandidates() {
|
|
356
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
357
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
358
|
+
}
|
|
119
359
|
function baseUrl(req) {
|
|
120
360
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
121
361
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -125,6 +365,7 @@ var app = express();
|
|
|
125
365
|
app.set("trust proxy", 1);
|
|
126
366
|
app.use(express.json());
|
|
127
367
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
368
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
128
369
|
app.use((_req, res, next) => {
|
|
129
370
|
const origin = _req.headers.origin;
|
|
130
371
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -135,7 +376,7 @@ app.use((_req, res, next) => {
|
|
|
135
376
|
"Access-Control-Allow-Headers",
|
|
136
377
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
137
378
|
);
|
|
138
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
379
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
139
380
|
if (_req.method === "OPTIONS") {
|
|
140
381
|
res.status(204).end();
|
|
141
382
|
return;
|
|
@@ -159,7 +400,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
159
400
|
token_endpoint: `${base}/oauth/token`,
|
|
160
401
|
registration_endpoint: `${base}/register`,
|
|
161
402
|
response_types_supported: ["code"],
|
|
162
|
-
grant_types_supported: ["authorization_code"
|
|
403
|
+
grant_types_supported: ["authorization_code"],
|
|
404
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
163
405
|
code_challenge_methods_supported: ["S256"],
|
|
164
406
|
token_endpoint_auth_methods_supported: ["none"],
|
|
165
407
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
@@ -187,14 +429,12 @@ app.post(
|
|
|
187
429
|
return;
|
|
188
430
|
}
|
|
189
431
|
const { redirect_uris, client_name } = req.body;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
error_description: "redirect_uris is required"
|
|
194
|
-
});
|
|
432
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
433
|
+
if (refusal) {
|
|
434
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
195
435
|
return;
|
|
196
436
|
}
|
|
197
|
-
const clientId = `pb_client_${
|
|
437
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
198
438
|
const client = {
|
|
199
439
|
client_id: clientId,
|
|
200
440
|
redirect_uris,
|
|
@@ -213,9 +453,6 @@ app.post(
|
|
|
213
453
|
}
|
|
214
454
|
);
|
|
215
455
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
216
|
-
var ACCESS_TOKEN_TTL = 3600;
|
|
217
|
-
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
218
|
-
var accessTokens = /* @__PURE__ */ new Map();
|
|
219
456
|
setInterval(() => {
|
|
220
457
|
const now = Date.now();
|
|
221
458
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -224,18 +461,9 @@ setInterval(() => {
|
|
|
224
461
|
for (const [id, client] of registeredClients) {
|
|
225
462
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
226
463
|
}
|
|
227
|
-
for (const [
|
|
228
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
229
|
-
}
|
|
230
|
-
for (const [ip, rec] of authFailures) {
|
|
464
|
+
for (const [credentialId, rec] of authFailures) {
|
|
231
465
|
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
232
|
-
authFailures.delete(
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
|
|
236
|
-
const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
|
|
237
|
-
for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
|
|
238
|
-
authFailures.delete(sorted[i][0]);
|
|
466
|
+
authFailures.delete(credentialId);
|
|
239
467
|
}
|
|
240
468
|
}
|
|
241
469
|
}, 6e4);
|
|
@@ -609,7 +837,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
609
837
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
610
838
|
<div class="err-actions">
|
|
611
839
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
612
|
-
<a href="
|
|
840
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
613
841
|
</div>`;
|
|
614
842
|
}
|
|
615
843
|
var cmdScript = `
|
|
@@ -657,7 +885,7 @@ function authorizeFormPage(params) {
|
|
|
657
885
|
<div class="hint" id="hint" hidden></div>
|
|
658
886
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
659
887
|
</form>
|
|
660
|
-
<div class="small-link"><a href="
|
|
888
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
661
889
|
</div>
|
|
662
890
|
|
|
663
891
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -738,7 +966,6 @@ ${cmdScript}
|
|
|
738
966
|
e.preventDefault();
|
|
739
967
|
var v=k.value.trim();
|
|
740
968
|
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
|
|
741
|
-
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}
|
|
742
969
|
sb.disabled=true;bt.textContent='Verifying';
|
|
743
970
|
show(pVerify);
|
|
744
971
|
|
|
@@ -796,6 +1023,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
796
1023
|
}
|
|
797
1024
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
798
1025
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1026
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1027
|
+
if (refusal) {
|
|
1028
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
799
1031
|
const cid = String(client_id ?? "");
|
|
800
1032
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
801
1033
|
res.type("html").send(authorizeFormPage({
|
|
@@ -812,7 +1044,8 @@ app.post(
|
|
|
812
1044
|
authLimiter,
|
|
813
1045
|
express.urlencoded({ extended: false }),
|
|
814
1046
|
async (req, res) => {
|
|
815
|
-
const {
|
|
1047
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1048
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
816
1049
|
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
817
1050
|
const retryParams = new URLSearchParams({
|
|
818
1051
|
redirect_uri: redirect_uri ?? "",
|
|
@@ -829,36 +1062,41 @@ app.post(
|
|
|
829
1062
|
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
830
1063
|
}
|
|
831
1064
|
}
|
|
832
|
-
if (
|
|
833
|
-
sendError(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1065
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1066
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const nowTs = Date.now();
|
|
1070
|
+
const pasted = resolvePastedKey(api_key);
|
|
1071
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1072
|
+
if (lockout) {
|
|
1073
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1074
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1075
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (pasted.status !== "ok") {
|
|
1079
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1080
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
837
1081
|
return;
|
|
838
1082
|
}
|
|
839
1083
|
if (!client_id) {
|
|
840
|
-
res.status(400).json({
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1084
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1088
|
+
if (redirectRefusal) {
|
|
1089
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
844
1090
|
return;
|
|
845
1091
|
}
|
|
846
1092
|
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
|
-
});
|
|
1093
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1094
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
860
1095
|
return;
|
|
861
1096
|
}
|
|
1097
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1098
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1099
|
+
`);
|
|
862
1100
|
}
|
|
863
1101
|
const client = registeredClients.get(client_id);
|
|
864
1102
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -869,48 +1107,25 @@ app.post(
|
|
|
869
1107
|
return;
|
|
870
1108
|
}
|
|
871
1109
|
let workspaceName = "Your Workspace";
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
if (checkData.ok) {
|
|
891
|
-
if (checkData.workspaceName) workspaceName = checkData.workspaceName;
|
|
892
|
-
foundUrl = checkData.deploymentUrl ?? url2;
|
|
893
|
-
break;
|
|
894
|
-
}
|
|
895
|
-
anyDefinitiveReject = true;
|
|
896
|
-
}
|
|
897
|
-
if (!foundUrl) {
|
|
898
|
-
if (anyDefinitiveReject) {
|
|
899
|
-
sendError(
|
|
900
|
-
"Key not recognized",
|
|
901
|
-
"This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
|
|
902
|
-
401
|
|
903
|
-
);
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
907
|
-
} else {
|
|
908
|
-
getKeyState(api_key).deploymentUrl = foundUrl;
|
|
909
|
-
}
|
|
910
|
-
} catch {
|
|
1110
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1111
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1112
|
+
if (check.verdict === "ok") {
|
|
1113
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1114
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1115
|
+
} else if (check.verdict === "rate_limited") {
|
|
1116
|
+
sendError(
|
|
1117
|
+
"Rate limited",
|
|
1118
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1119
|
+
429
|
|
1120
|
+
);
|
|
1121
|
+
return;
|
|
1122
|
+
} else if (gate === "unauthorized") {
|
|
1123
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1124
|
+
return;
|
|
1125
|
+
} else {
|
|
911
1126
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
912
1127
|
}
|
|
913
|
-
const code =
|
|
1128
|
+
const code = randomUUID();
|
|
914
1129
|
pendingCodes.set(code, {
|
|
915
1130
|
apiKey: api_key,
|
|
916
1131
|
codeChallenge: code_challenge,
|
|
@@ -929,41 +1144,25 @@ app.post(
|
|
|
929
1144
|
}
|
|
930
1145
|
}
|
|
931
1146
|
);
|
|
932
|
-
function issueTokens(apiKey) {
|
|
933
|
-
return {
|
|
934
|
-
access_token: apiKey,
|
|
935
|
-
token_type: "Bearer",
|
|
936
|
-
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
937
|
-
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
938
|
-
expires_in: 365 * 24 * 3600,
|
|
939
|
-
refresh_token: signRefreshToken(apiKey)
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
1147
|
app.post(
|
|
943
1148
|
"/oauth/token",
|
|
944
1149
|
authLimiter,
|
|
945
1150
|
express.urlencoded({ extended: false }),
|
|
946
1151
|
express.json(),
|
|
947
1152
|
(req, res) => {
|
|
948
|
-
const { grant_type, code, code_verifier, redirect_uri
|
|
949
|
-
if (grant_type === "refresh_token") {
|
|
950
|
-
const verified = verifyRefreshToken(refresh_token);
|
|
951
|
-
if (!verified) {
|
|
952
|
-
res.status(400).json({
|
|
953
|
-
error: "invalid_grant",
|
|
954
|
-
error_description: "Invalid or expired refresh token"
|
|
955
|
-
});
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
res.json(issueTokens(verified.apiKey));
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
1153
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
961
1154
|
if (grant_type !== "authorization_code") {
|
|
962
1155
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
963
1156
|
return;
|
|
964
1157
|
}
|
|
965
1158
|
const pending = pendingCodes.get(code);
|
|
966
|
-
|
|
1159
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1160
|
+
if (codeRefusal === "expired") {
|
|
1161
|
+
pendingCodes.delete(code);
|
|
1162
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (codeRefusal || !pending) {
|
|
967
1166
|
res.status(400).json({ error: "invalid_grant" });
|
|
968
1167
|
return;
|
|
969
1168
|
}
|
|
@@ -977,7 +1176,7 @@ app.post(
|
|
|
977
1176
|
return;
|
|
978
1177
|
}
|
|
979
1178
|
pendingCodes.delete(code);
|
|
980
|
-
res.json(
|
|
1179
|
+
res.json(tokenResponse(pending.apiKey));
|
|
981
1180
|
}
|
|
982
1181
|
);
|
|
983
1182
|
var mcpLimiter = rateLimit({
|
|
@@ -985,85 +1184,63 @@ var mcpLimiter = rateLimit({
|
|
|
985
1184
|
max: 120,
|
|
986
1185
|
standardHeaders: true,
|
|
987
1186
|
legacyHeaders: false,
|
|
988
|
-
|
|
1187
|
+
handler: (req, res) => {
|
|
1188
|
+
const r = req.rateLimit;
|
|
1189
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
|
|
1190
|
+
send429(res, buildRateLimitError({
|
|
1191
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
1192
|
+
limit: r?.limit ?? 120,
|
|
1193
|
+
current: r?.used ?? 0,
|
|
1194
|
+
id: req.body?.id ?? null
|
|
1195
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
1196
|
+
}));
|
|
1197
|
+
}
|
|
989
1198
|
});
|
|
990
1199
|
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
1200
|
app.get("/health", (_req, res) => {
|
|
1019
1201
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1020
1202
|
});
|
|
1021
1203
|
var sessions = /* @__PURE__ */ new Map();
|
|
1022
1204
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1023
1205
|
var MAX_SESSIONS = 200;
|
|
1024
|
-
var
|
|
1206
|
+
var EVICT_GRACE_MS = 6e4;
|
|
1207
|
+
var MAX_SESSIONS_PER_KEY = Math.max(
|
|
1208
|
+
1,
|
|
1209
|
+
Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
|
|
1210
|
+
);
|
|
1025
1211
|
function evictStaleSessions() {
|
|
1026
1212
|
const now = Date.now();
|
|
1027
1213
|
for (const [id, entry] of sessions) {
|
|
1028
1214
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1029
|
-
logSessionLifecycle(
|
|
1215
|
+
logSessionLifecycle(
|
|
1216
|
+
"session_deleted",
|
|
1217
|
+
id,
|
|
1218
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1219
|
+
);
|
|
1030
1220
|
entry.transport.close().catch(() => {
|
|
1031
1221
|
});
|
|
1032
1222
|
sessions.delete(id);
|
|
1033
1223
|
}
|
|
1034
1224
|
}
|
|
1035
1225
|
if (sessions.size > MAX_SESSIONS) {
|
|
1036
|
-
const sorted = [...sessions.entries()].sort(
|
|
1037
|
-
|
|
1038
|
-
);
|
|
1039
|
-
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
1226
|
+
const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
1227
|
+
const overflow = sessions.size - MAX_SESSIONS;
|
|
1228
|
+
for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
|
|
1040
1229
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1041
1230
|
sorted[i][1].transport.close().catch(() => {
|
|
1042
1231
|
});
|
|
1043
1232
|
sessions.delete(sorted[i][0]);
|
|
1044
1233
|
}
|
|
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;
|
|
1234
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
1235
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1236
|
+
process.stderr.write(
|
|
1237
|
+
`[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
|
|
1238
|
+
`
|
|
1239
|
+
);
|
|
1062
1240
|
}
|
|
1063
|
-
return entry.apiKey;
|
|
1064
1241
|
}
|
|
1065
|
-
return null;
|
|
1066
1242
|
}
|
|
1243
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1067
1244
|
function send401(req, res) {
|
|
1068
1245
|
const base = baseUrl(req);
|
|
1069
1246
|
res.status(401).set(
|
|
@@ -1085,22 +1262,24 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1085
1262
|
`);
|
|
1086
1263
|
}
|
|
1087
1264
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1265
|
+
const nowTs = Date.now();
|
|
1266
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1267
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1268
|
+
if (lockout) {
|
|
1269
|
+
send429(res, lockout);
|
|
1091
1270
|
return;
|
|
1092
1271
|
}
|
|
1093
|
-
|
|
1094
|
-
if (!apiKey) {
|
|
1272
|
+
if (auth.status !== "ok") {
|
|
1095
1273
|
logRequest("POST", "auth_fail");
|
|
1096
|
-
recordAuthFailure(
|
|
1274
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1097
1275
|
send401(req, res);
|
|
1098
1276
|
return;
|
|
1099
1277
|
}
|
|
1278
|
+
const apiKey = auth.apiKey;
|
|
1100
1279
|
const sessionId = req.headers["mcp-session-id"];
|
|
1101
1280
|
const reqStart = Date.now();
|
|
1102
1281
|
try {
|
|
1103
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1282
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1104
1283
|
if (sessionId && sessions.has(sessionId)) {
|
|
1105
1284
|
const entry = sessions.get(sessionId);
|
|
1106
1285
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1111,41 +1290,98 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1111
1290
|
});
|
|
1112
1291
|
return;
|
|
1113
1292
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
entry.inFlight++;
|
|
1295
|
+
entry.lastAccess = Date.now();
|
|
1296
|
+
await entry.transport.handleRequest(req, res, req.body);
|
|
1297
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
1298
|
+
} finally {
|
|
1299
|
+
entry.inFlight--;
|
|
1300
|
+
entry.lastAccess = Date.now();
|
|
1301
|
+
}
|
|
1117
1302
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1303
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1304
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1305
|
+
if (gate === "unauthorized") {
|
|
1306
|
+
logRequest("POST", "auth_fail");
|
|
1307
|
+
send401(req, res);
|
|
1308
|
+
return;
|
|
1122
1309
|
}
|
|
1123
|
-
if (
|
|
1124
|
-
res.status(
|
|
1310
|
+
if (gate === "unavailable") {
|
|
1311
|
+
res.status(503).json({
|
|
1125
1312
|
jsonrpc: "2.0",
|
|
1126
|
-
error: { code: -32e3, message: "
|
|
1127
|
-
id: null
|
|
1313
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1314
|
+
id: req.body?.id ?? null
|
|
1315
|
+
});
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1319
|
+
const keyH = hashKey(apiKey);
|
|
1320
|
+
const plan = planKeyAdmission(
|
|
1321
|
+
sessions,
|
|
1322
|
+
keyH,
|
|
1323
|
+
MAX_SESSIONS_PER_KEY,
|
|
1324
|
+
Date.now(),
|
|
1325
|
+
{ ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
|
|
1326
|
+
);
|
|
1327
|
+
for (const sid2 of plan.evict) {
|
|
1328
|
+
const victim = sessions.get(sid2);
|
|
1329
|
+
sessions.delete(sid2);
|
|
1330
|
+
victim?.transport.close().catch(() => {
|
|
1128
1331
|
});
|
|
1332
|
+
logSessionLifecycle("session_deleted", sid2, "lru_evict");
|
|
1333
|
+
}
|
|
1334
|
+
if (!plan.admit) {
|
|
1335
|
+
send429(res, buildSessionCapError({
|
|
1336
|
+
retryAfterSeconds: plan.retryAfterSeconds,
|
|
1337
|
+
limit: MAX_SESSIONS_PER_KEY,
|
|
1338
|
+
current: countKeySessions(sessions, keyH),
|
|
1339
|
+
poll: plan.retryIsPoll,
|
|
1340
|
+
id: req.body?.id ?? null
|
|
1341
|
+
}));
|
|
1129
1342
|
return;
|
|
1130
1343
|
}
|
|
1344
|
+
const sid = randomUUID();
|
|
1345
|
+
let initialized = false;
|
|
1131
1346
|
const transport = new StreamableHTTPServerTransport({
|
|
1132
|
-
sessionIdGenerator: () =>
|
|
1133
|
-
onsessioninitialized: (
|
|
1134
|
-
|
|
1135
|
-
logSessionLifecycle("session_created",
|
|
1347
|
+
sessionIdGenerator: () => sid,
|
|
1348
|
+
onsessioninitialized: (s) => {
|
|
1349
|
+
initialized = true;
|
|
1350
|
+
logSessionLifecycle("session_created", s);
|
|
1136
1351
|
}
|
|
1352
|
+
// log only — no map write
|
|
1137
1353
|
});
|
|
1354
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1355
|
+
sessions.set(sid, reserved);
|
|
1138
1356
|
transport.onclose = () => {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
sessions.delete(
|
|
1357
|
+
const s = transport.sessionId ?? sid;
|
|
1358
|
+
forgetStreamLifecycle(keyH, s);
|
|
1359
|
+
if (sessions.has(s)) {
|
|
1360
|
+
sessions.delete(s);
|
|
1361
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1143
1362
|
}
|
|
1144
1363
|
};
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1364
|
+
try {
|
|
1365
|
+
const server = createProductBrainServer();
|
|
1366
|
+
await server.connect(transport);
|
|
1367
|
+
await transport.handleRequest(req, res, req.body);
|
|
1368
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
1369
|
+
} catch (e) {
|
|
1370
|
+
transport.onclose = void 0;
|
|
1371
|
+
sessions.delete(sid);
|
|
1372
|
+
throw e;
|
|
1373
|
+
} finally {
|
|
1374
|
+
if (!initialized) {
|
|
1375
|
+
transport.onclose = void 0;
|
|
1376
|
+
if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
|
|
1377
|
+
} else {
|
|
1378
|
+
const e = sessions.get(sid);
|
|
1379
|
+
if (e) {
|
|
1380
|
+
e.inFlight--;
|
|
1381
|
+
e.lastAccess = Date.now();
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1149
1385
|
} else if (sessionId) {
|
|
1150
1386
|
process.stderr.write(
|
|
1151
1387
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
|
|
@@ -1180,18 +1416,20 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1180
1416
|
}
|
|
1181
1417
|
});
|
|
1182
1418
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1419
|
+
const nowTs = Date.now();
|
|
1420
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1421
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1422
|
+
if (lockout) {
|
|
1423
|
+
send429(res, lockout);
|
|
1186
1424
|
return;
|
|
1187
1425
|
}
|
|
1188
|
-
|
|
1189
|
-
if (!apiKey) {
|
|
1426
|
+
if (auth.status !== "ok") {
|
|
1190
1427
|
logRequest("GET", "auth_fail");
|
|
1191
|
-
recordAuthFailure(
|
|
1428
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1192
1429
|
send401(req, res);
|
|
1193
1430
|
return;
|
|
1194
1431
|
}
|
|
1432
|
+
const apiKey = auth.apiKey;
|
|
1195
1433
|
const sessionId = req.headers["mcp-session-id"];
|
|
1196
1434
|
if (!sessionId) {
|
|
1197
1435
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1206,7 +1444,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1206
1444
|
return;
|
|
1207
1445
|
}
|
|
1208
1446
|
try {
|
|
1209
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1447
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1210
1448
|
const entry = sessions.get(sessionId);
|
|
1211
1449
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1212
1450
|
res.status(403).json({
|
|
@@ -1225,18 +1463,20 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1225
1463
|
}
|
|
1226
1464
|
});
|
|
1227
1465
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1466
|
+
const nowTs = Date.now();
|
|
1467
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1468
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1469
|
+
if (lockout) {
|
|
1470
|
+
send429(res, lockout);
|
|
1231
1471
|
return;
|
|
1232
1472
|
}
|
|
1233
|
-
|
|
1234
|
-
if (!apiKey) {
|
|
1473
|
+
if (auth.status !== "ok") {
|
|
1235
1474
|
logRequest("DELETE", "auth_fail");
|
|
1236
|
-
recordAuthFailure(
|
|
1475
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1237
1476
|
send401(req, res);
|
|
1238
1477
|
return;
|
|
1239
1478
|
}
|
|
1479
|
+
const apiKey = auth.apiKey;
|
|
1240
1480
|
const sessionId = req.headers["mcp-session-id"];
|
|
1241
1481
|
if (!sessionId) {
|
|
1242
1482
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1251,7 +1491,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1251
1491
|
return;
|
|
1252
1492
|
}
|
|
1253
1493
|
try {
|
|
1254
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1494
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1255
1495
|
const entry = sessions.get(sessionId);
|
|
1256
1496
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1257
1497
|
res.status(403).json({
|