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