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