@productbrain/mcp 0.0.1-beta.951 → 0.0.1-beta.9511
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-GXPFXLOF.js +1694 -0
- package/dist/chunk-GXPFXLOF.js.map +1 -0
- package/dist/chunk-HHH3YC56.js +13506 -0
- package/dist/chunk-HHH3YC56.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +460 -249
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-AMF7M6X3.js} +35 -26
- package/dist/setup-AMF7M6X3.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-YMF3IQ5E.js +0 -465
- package/dist/chunk-YMF3IQ5E.js.map +0 -1
- package/dist/chunk-ZPK5CSCQ.js +0 -15207
- package/dist/chunk-ZPK5CSCQ.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-ZPK5CSCQ.js";
|
|
4
|
+
initFeatureFlags
|
|
5
|
+
} from "./chunk-HHH3YC56.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-GXPFXLOF.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,260 @@ 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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 };
|
|
96
186
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
187
|
+
return { status: "rejected" };
|
|
188
|
+
}
|
|
189
|
+
function checkAuthBlock(failures, ip, now) {
|
|
190
|
+
const rec = failures.get(ip);
|
|
191
|
+
if (!rec) return false;
|
|
192
|
+
return rec.blockedUntil > now;
|
|
193
|
+
}
|
|
194
|
+
function recordAuthFailure(failures, ip, now) {
|
|
195
|
+
const rec = failures.get(ip);
|
|
196
|
+
if (!rec) {
|
|
197
|
+
failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
201
|
+
rec.count = 1;
|
|
202
|
+
rec.firstFailure = now;
|
|
203
|
+
rec.blockedUntil = 0;
|
|
204
|
+
} else {
|
|
205
|
+
rec.count++;
|
|
206
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
207
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function initializeKeyGate(verdict, failures, ip, now) {
|
|
212
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
213
|
+
if (verdict === "rejected") {
|
|
214
|
+
recordAuthFailure(failures, ip, now);
|
|
215
|
+
return "unauthorized";
|
|
216
|
+
}
|
|
217
|
+
return "unavailable";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
221
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
222
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
223
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
224
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
225
|
+
];
|
|
226
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
227
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
228
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
229
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
230
|
+
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)";
|
|
231
|
+
function exactForm(url) {
|
|
232
|
+
return `${url.origin}${url.pathname}`;
|
|
233
|
+
}
|
|
234
|
+
function parseRedirectUriAllowlist(raw) {
|
|
235
|
+
if (!raw) return { uris: [], configError: null };
|
|
236
|
+
const uris = [];
|
|
237
|
+
for (const part of raw.split(",")) {
|
|
238
|
+
const trimmed = part.trim();
|
|
239
|
+
if (!trimmed) continue;
|
|
240
|
+
let url = null;
|
|
241
|
+
try {
|
|
242
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
243
|
+
} catch {
|
|
244
|
+
url = null;
|
|
245
|
+
}
|
|
246
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
247
|
+
if (!valid || url === null) {
|
|
248
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
249
|
+
}
|
|
250
|
+
uris.push(exactForm(url));
|
|
251
|
+
}
|
|
252
|
+
return { uris, configError: null };
|
|
253
|
+
}
|
|
254
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
255
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
256
|
+
return {
|
|
257
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
258
|
+
configError: parsed.configError
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
262
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
263
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
264
|
+
return "redirect_uri is required";
|
|
265
|
+
}
|
|
266
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
267
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
268
|
+
}
|
|
269
|
+
let url;
|
|
101
270
|
try {
|
|
102
|
-
|
|
103
|
-
payload = JSON.parse(json);
|
|
271
|
+
url = new URL(redirectUri);
|
|
104
272
|
} catch {
|
|
105
|
-
return
|
|
273
|
+
return "redirect_uri is not a valid absolute URL";
|
|
274
|
+
}
|
|
275
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
276
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
277
|
+
const host = url.hostname.toLowerCase();
|
|
278
|
+
if (url.protocol === "http:") {
|
|
279
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
280
|
+
}
|
|
281
|
+
if (url.protocol === "https:") {
|
|
282
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
283
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
284
|
+
const exact = exactForm(url);
|
|
285
|
+
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
286
|
}
|
|
107
|
-
|
|
108
|
-
|
|
287
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
288
|
+
}
|
|
289
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
290
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
291
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
292
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
293
|
+
}
|
|
294
|
+
for (const uri of redirectUris) {
|
|
295
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
296
|
+
if (reason) return reason;
|
|
109
297
|
}
|
|
110
|
-
|
|
111
|
-
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/lib/oauthAuthCode.ts
|
|
302
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
303
|
+
if (!pending) return "unknown_code";
|
|
304
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
305
|
+
if (now > pending.expiresAt) return "expired";
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
function tokenResponse(apiKey) {
|
|
309
|
+
return {
|
|
310
|
+
access_token: apiKey,
|
|
311
|
+
token_type: "Bearer",
|
|
312
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
313
|
+
expires_in: 365 * 24 * 3600
|
|
314
|
+
};
|
|
112
315
|
}
|
|
113
316
|
|
|
114
317
|
// src/http.ts
|
|
@@ -116,6 +319,10 @@ bootstrapHttp();
|
|
|
116
319
|
initAnalytics();
|
|
117
320
|
initFeatureFlags(getPostHogClient());
|
|
118
321
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
322
|
+
function keyCheckCandidates() {
|
|
323
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
324
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
325
|
+
}
|
|
119
326
|
function baseUrl(req) {
|
|
120
327
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
121
328
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -125,6 +332,7 @@ var app = express();
|
|
|
125
332
|
app.set("trust proxy", 1);
|
|
126
333
|
app.use(express.json());
|
|
127
334
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
335
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
128
336
|
app.use((_req, res, next) => {
|
|
129
337
|
const origin = _req.headers.origin;
|
|
130
338
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -135,7 +343,7 @@ app.use((_req, res, next) => {
|
|
|
135
343
|
"Access-Control-Allow-Headers",
|
|
136
344
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
137
345
|
);
|
|
138
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
346
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
139
347
|
if (_req.method === "OPTIONS") {
|
|
140
348
|
res.status(204).end();
|
|
141
349
|
return;
|
|
@@ -159,7 +367,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
159
367
|
token_endpoint: `${base}/oauth/token`,
|
|
160
368
|
registration_endpoint: `${base}/register`,
|
|
161
369
|
response_types_supported: ["code"],
|
|
162
|
-
grant_types_supported: ["authorization_code"
|
|
370
|
+
grant_types_supported: ["authorization_code"],
|
|
371
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
163
372
|
code_challenge_methods_supported: ["S256"],
|
|
164
373
|
token_endpoint_auth_methods_supported: ["none"],
|
|
165
374
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
@@ -187,14 +396,12 @@ app.post(
|
|
|
187
396
|
return;
|
|
188
397
|
}
|
|
189
398
|
const { redirect_uris, client_name } = req.body;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
error_description: "redirect_uris is required"
|
|
194
|
-
});
|
|
399
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
400
|
+
if (refusal) {
|
|
401
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
195
402
|
return;
|
|
196
403
|
}
|
|
197
|
-
const clientId = `pb_client_${
|
|
404
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
198
405
|
const client = {
|
|
199
406
|
client_id: clientId,
|
|
200
407
|
redirect_uris,
|
|
@@ -213,9 +420,6 @@ app.post(
|
|
|
213
420
|
}
|
|
214
421
|
);
|
|
215
422
|
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
423
|
setInterval(() => {
|
|
220
424
|
const now = Date.now();
|
|
221
425
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -224,9 +428,6 @@ setInterval(() => {
|
|
|
224
428
|
for (const [id, client] of registeredClients) {
|
|
225
429
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
226
430
|
}
|
|
227
|
-
for (const [token, entry] of accessTokens) {
|
|
228
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
229
|
-
}
|
|
230
431
|
for (const [ip, rec] of authFailures) {
|
|
231
432
|
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
232
433
|
authFailures.delete(ip);
|
|
@@ -609,7 +810,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
609
810
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
610
811
|
<div class="err-actions">
|
|
611
812
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
612
|
-
<a href="
|
|
813
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
613
814
|
</div>`;
|
|
614
815
|
}
|
|
615
816
|
var cmdScript = `
|
|
@@ -657,7 +858,7 @@ function authorizeFormPage(params) {
|
|
|
657
858
|
<div class="hint" id="hint" hidden></div>
|
|
658
859
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
659
860
|
</form>
|
|
660
|
-
<div class="small-link"><a href="
|
|
861
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
661
862
|
</div>
|
|
662
863
|
|
|
663
864
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -796,6 +997,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
796
997
|
}
|
|
797
998
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
798
999
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1000
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1001
|
+
if (refusal) {
|
|
1002
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
799
1005
|
const cid = String(client_id ?? "");
|
|
800
1006
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
801
1007
|
res.type("html").send(authorizeFormPage({
|
|
@@ -837,28 +1043,22 @@ app.post(
|
|
|
837
1043
|
return;
|
|
838
1044
|
}
|
|
839
1045
|
if (!client_id) {
|
|
840
|
-
res.status(400).json({
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1046
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1050
|
+
if (redirectRefusal) {
|
|
1051
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
844
1052
|
return;
|
|
845
1053
|
}
|
|
846
1054
|
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
|
-
});
|
|
1055
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1056
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
860
1057
|
return;
|
|
861
1058
|
}
|
|
1059
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1060
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1061
|
+
`);
|
|
862
1062
|
}
|
|
863
1063
|
const client = registeredClients.get(client_id);
|
|
864
1064
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -869,48 +1069,28 @@ app.post(
|
|
|
869
1069
|
return;
|
|
870
1070
|
}
|
|
871
1071
|
let workspaceName = "Your Workspace";
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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 {
|
|
1072
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1073
|
+
if (check.verdict === "ok") {
|
|
1074
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1075
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1076
|
+
} else if (check.verdict === "rate_limited") {
|
|
1077
|
+
sendError(
|
|
1078
|
+
"Rate limited",
|
|
1079
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1080
|
+
429
|
|
1081
|
+
);
|
|
1082
|
+
return;
|
|
1083
|
+
} else if (check.verdict === "rejected") {
|
|
1084
|
+
sendError(
|
|
1085
|
+
"Key not recognized",
|
|
1086
|
+
"This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
|
|
1087
|
+
401
|
|
1088
|
+
);
|
|
1089
|
+
return;
|
|
1090
|
+
} else {
|
|
911
1091
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
912
1092
|
}
|
|
913
|
-
const code =
|
|
1093
|
+
const code = randomUUID();
|
|
914
1094
|
pendingCodes.set(code, {
|
|
915
1095
|
apiKey: api_key,
|
|
916
1096
|
codeChallenge: code_challenge,
|
|
@@ -929,41 +1109,25 @@ app.post(
|
|
|
929
1109
|
}
|
|
930
1110
|
}
|
|
931
1111
|
);
|
|
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
1112
|
app.post(
|
|
943
1113
|
"/oauth/token",
|
|
944
1114
|
authLimiter,
|
|
945
1115
|
express.urlencoded({ extended: false }),
|
|
946
1116
|
express.json(),
|
|
947
1117
|
(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
|
-
}
|
|
1118
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
961
1119
|
if (grant_type !== "authorization_code") {
|
|
962
1120
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
963
1121
|
return;
|
|
964
1122
|
}
|
|
965
1123
|
const pending = pendingCodes.get(code);
|
|
966
|
-
|
|
1124
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1125
|
+
if (codeRefusal === "expired") {
|
|
1126
|
+
pendingCodes.delete(code);
|
|
1127
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (codeRefusal || !pending) {
|
|
967
1131
|
res.status(400).json({ error: "invalid_grant" });
|
|
968
1132
|
return;
|
|
969
1133
|
}
|
|
@@ -977,7 +1141,7 @@ app.post(
|
|
|
977
1141
|
return;
|
|
978
1142
|
}
|
|
979
1143
|
pendingCodes.delete(code);
|
|
980
|
-
res.json(
|
|
1144
|
+
res.json(tokenResponse(pending.apiKey));
|
|
981
1145
|
}
|
|
982
1146
|
);
|
|
983
1147
|
var mcpLimiter = rateLimit({
|
|
@@ -985,85 +1149,63 @@ var mcpLimiter = rateLimit({
|
|
|
985
1149
|
max: 120,
|
|
986
1150
|
standardHeaders: true,
|
|
987
1151
|
legacyHeaders: false,
|
|
988
|
-
|
|
1152
|
+
handler: (req, res) => {
|
|
1153
|
+
const r = req.rateLimit;
|
|
1154
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
|
|
1155
|
+
send429(res, buildRateLimitError({
|
|
1156
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
1157
|
+
limit: r?.limit ?? 120,
|
|
1158
|
+
current: r?.used ?? 0,
|
|
1159
|
+
id: req.body?.id ?? null
|
|
1160
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
1161
|
+
}));
|
|
1162
|
+
}
|
|
989
1163
|
});
|
|
990
1164
|
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
1165
|
app.get("/health", (_req, res) => {
|
|
1019
1166
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1020
1167
|
});
|
|
1021
1168
|
var sessions = /* @__PURE__ */ new Map();
|
|
1022
1169
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1023
1170
|
var MAX_SESSIONS = 200;
|
|
1024
|
-
var
|
|
1171
|
+
var EVICT_GRACE_MS = 6e4;
|
|
1172
|
+
var MAX_SESSIONS_PER_KEY = Math.max(
|
|
1173
|
+
1,
|
|
1174
|
+
Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
|
|
1175
|
+
);
|
|
1025
1176
|
function evictStaleSessions() {
|
|
1026
1177
|
const now = Date.now();
|
|
1027
1178
|
for (const [id, entry] of sessions) {
|
|
1028
1179
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1029
|
-
logSessionLifecycle(
|
|
1180
|
+
logSessionLifecycle(
|
|
1181
|
+
"session_deleted",
|
|
1182
|
+
id,
|
|
1183
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1184
|
+
);
|
|
1030
1185
|
entry.transport.close().catch(() => {
|
|
1031
1186
|
});
|
|
1032
1187
|
sessions.delete(id);
|
|
1033
1188
|
}
|
|
1034
1189
|
}
|
|
1035
1190
|
if (sessions.size > MAX_SESSIONS) {
|
|
1036
|
-
const sorted = [...sessions.entries()].sort(
|
|
1037
|
-
|
|
1038
|
-
);
|
|
1039
|
-
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
1191
|
+
const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
1192
|
+
const overflow = sessions.size - MAX_SESSIONS;
|
|
1193
|
+
for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
|
|
1040
1194
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1041
1195
|
sorted[i][1].transport.close().catch(() => {
|
|
1042
1196
|
});
|
|
1043
1197
|
sessions.delete(sorted[i][0]);
|
|
1044
1198
|
}
|
|
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;
|
|
1199
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
1200
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1201
|
+
process.stderr.write(
|
|
1202
|
+
`[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
|
|
1203
|
+
`
|
|
1204
|
+
);
|
|
1062
1205
|
}
|
|
1063
|
-
return entry.apiKey;
|
|
1064
1206
|
}
|
|
1065
|
-
return null;
|
|
1066
1207
|
}
|
|
1208
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1067
1209
|
function send401(req, res) {
|
|
1068
1210
|
const base = baseUrl(req);
|
|
1069
1211
|
res.status(401).set(
|
|
@@ -1086,21 +1228,25 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1086
1228
|
}
|
|
1087
1229
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1088
1230
|
const reqIp = req.ip ?? "unknown";
|
|
1089
|
-
|
|
1090
|
-
|
|
1231
|
+
const nowTs = Date.now();
|
|
1232
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1233
|
+
const rec = authFailures.get(reqIp);
|
|
1234
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1235
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1091
1236
|
return;
|
|
1092
1237
|
}
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1238
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1239
|
+
if (auth.status !== "ok") {
|
|
1095
1240
|
logRequest("POST", "auth_fail");
|
|
1096
|
-
recordAuthFailure(reqIp);
|
|
1241
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1097
1242
|
send401(req, res);
|
|
1098
1243
|
return;
|
|
1099
1244
|
}
|
|
1245
|
+
const apiKey = auth.apiKey;
|
|
1100
1246
|
const sessionId = req.headers["mcp-session-id"];
|
|
1101
1247
|
const reqStart = Date.now();
|
|
1102
1248
|
try {
|
|
1103
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1249
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1104
1250
|
if (sessionId && sessions.has(sessionId)) {
|
|
1105
1251
|
const entry = sessions.get(sessionId);
|
|
1106
1252
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1111,41 +1257,98 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1111
1257
|
});
|
|
1112
1258
|
return;
|
|
1113
1259
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1260
|
+
try {
|
|
1261
|
+
entry.inFlight++;
|
|
1262
|
+
entry.lastAccess = Date.now();
|
|
1263
|
+
await entry.transport.handleRequest(req, res, req.body);
|
|
1264
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
1265
|
+
} finally {
|
|
1266
|
+
entry.inFlight--;
|
|
1267
|
+
entry.lastAccess = Date.now();
|
|
1268
|
+
}
|
|
1117
1269
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1270
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1271
|
+
const gate = initializeKeyGate(check.verdict, authFailures, reqIp, Date.now());
|
|
1272
|
+
if (gate === "unauthorized") {
|
|
1273
|
+
logRequest("POST", "auth_fail");
|
|
1274
|
+
send401(req, res);
|
|
1275
|
+
return;
|
|
1122
1276
|
}
|
|
1123
|
-
if (
|
|
1124
|
-
res.status(
|
|
1277
|
+
if (gate === "unavailable") {
|
|
1278
|
+
res.status(503).json({
|
|
1125
1279
|
jsonrpc: "2.0",
|
|
1126
|
-
error: { code: -32e3, message: "
|
|
1127
|
-
id: null
|
|
1280
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1281
|
+
id: req.body?.id ?? null
|
|
1128
1282
|
});
|
|
1129
1283
|
return;
|
|
1130
1284
|
}
|
|
1285
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1286
|
+
const keyH = hashKey(apiKey);
|
|
1287
|
+
const plan = planKeyAdmission(
|
|
1288
|
+
sessions,
|
|
1289
|
+
keyH,
|
|
1290
|
+
MAX_SESSIONS_PER_KEY,
|
|
1291
|
+
Date.now(),
|
|
1292
|
+
{ ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
|
|
1293
|
+
);
|
|
1294
|
+
for (const sid2 of plan.evict) {
|
|
1295
|
+
const victim = sessions.get(sid2);
|
|
1296
|
+
sessions.delete(sid2);
|
|
1297
|
+
victim?.transport.close().catch(() => {
|
|
1298
|
+
});
|
|
1299
|
+
logSessionLifecycle("session_deleted", sid2, "lru_evict");
|
|
1300
|
+
}
|
|
1301
|
+
if (!plan.admit) {
|
|
1302
|
+
send429(res, buildSessionCapError({
|
|
1303
|
+
retryAfterSeconds: plan.retryAfterSeconds,
|
|
1304
|
+
limit: MAX_SESSIONS_PER_KEY,
|
|
1305
|
+
current: countKeySessions(sessions, keyH),
|
|
1306
|
+
poll: plan.retryIsPoll,
|
|
1307
|
+
id: req.body?.id ?? null
|
|
1308
|
+
}));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const sid = randomUUID();
|
|
1312
|
+
let initialized = false;
|
|
1131
1313
|
const transport = new StreamableHTTPServerTransport({
|
|
1132
|
-
sessionIdGenerator: () =>
|
|
1133
|
-
onsessioninitialized: (
|
|
1134
|
-
|
|
1135
|
-
logSessionLifecycle("session_created",
|
|
1314
|
+
sessionIdGenerator: () => sid,
|
|
1315
|
+
onsessioninitialized: (s) => {
|
|
1316
|
+
initialized = true;
|
|
1317
|
+
logSessionLifecycle("session_created", s);
|
|
1136
1318
|
}
|
|
1319
|
+
// log only — no map write
|
|
1137
1320
|
});
|
|
1321
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1322
|
+
sessions.set(sid, reserved);
|
|
1138
1323
|
transport.onclose = () => {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
sessions.delete(
|
|
1324
|
+
const s = transport.sessionId ?? sid;
|
|
1325
|
+
forgetStreamLifecycle(keyH, s);
|
|
1326
|
+
if (sessions.has(s)) {
|
|
1327
|
+
sessions.delete(s);
|
|
1328
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1143
1329
|
}
|
|
1144
1330
|
};
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1331
|
+
try {
|
|
1332
|
+
const server = createProductBrainServer();
|
|
1333
|
+
await server.connect(transport);
|
|
1334
|
+
await transport.handleRequest(req, res, req.body);
|
|
1335
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
transport.onclose = void 0;
|
|
1338
|
+
sessions.delete(sid);
|
|
1339
|
+
throw e;
|
|
1340
|
+
} finally {
|
|
1341
|
+
if (!initialized) {
|
|
1342
|
+
transport.onclose = void 0;
|
|
1343
|
+
if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
|
|
1344
|
+
} else {
|
|
1345
|
+
const e = sessions.get(sid);
|
|
1346
|
+
if (e) {
|
|
1347
|
+
e.inFlight--;
|
|
1348
|
+
e.lastAccess = Date.now();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1149
1352
|
} else if (sessionId) {
|
|
1150
1353
|
process.stderr.write(
|
|
1151
1354
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
|
|
@@ -1181,17 +1384,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1181
1384
|
});
|
|
1182
1385
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1183
1386
|
const reqIp = req.ip ?? "unknown";
|
|
1184
|
-
|
|
1185
|
-
|
|
1387
|
+
const nowTs = Date.now();
|
|
1388
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1389
|
+
const rec = authFailures.get(reqIp);
|
|
1390
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1391
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1186
1392
|
return;
|
|
1187
1393
|
}
|
|
1188
|
-
const
|
|
1189
|
-
if (
|
|
1394
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1395
|
+
if (auth.status !== "ok") {
|
|
1190
1396
|
logRequest("GET", "auth_fail");
|
|
1191
|
-
recordAuthFailure(reqIp);
|
|
1397
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1192
1398
|
send401(req, res);
|
|
1193
1399
|
return;
|
|
1194
1400
|
}
|
|
1401
|
+
const apiKey = auth.apiKey;
|
|
1195
1402
|
const sessionId = req.headers["mcp-session-id"];
|
|
1196
1403
|
if (!sessionId) {
|
|
1197
1404
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1206,7 +1413,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1206
1413
|
return;
|
|
1207
1414
|
}
|
|
1208
1415
|
try {
|
|
1209
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1416
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1210
1417
|
const entry = sessions.get(sessionId);
|
|
1211
1418
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1212
1419
|
res.status(403).json({
|
|
@@ -1226,17 +1433,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1226
1433
|
});
|
|
1227
1434
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1228
1435
|
const reqIp = req.ip ?? "unknown";
|
|
1229
|
-
|
|
1230
|
-
|
|
1436
|
+
const nowTs = Date.now();
|
|
1437
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1438
|
+
const rec = authFailures.get(reqIp);
|
|
1439
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1440
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1231
1441
|
return;
|
|
1232
1442
|
}
|
|
1233
|
-
const
|
|
1234
|
-
if (
|
|
1443
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1444
|
+
if (auth.status !== "ok") {
|
|
1235
1445
|
logRequest("DELETE", "auth_fail");
|
|
1236
|
-
recordAuthFailure(reqIp);
|
|
1446
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1237
1447
|
send401(req, res);
|
|
1238
1448
|
return;
|
|
1239
1449
|
}
|
|
1450
|
+
const apiKey = auth.apiKey;
|
|
1240
1451
|
const sessionId = req.headers["mcp-session-id"];
|
|
1241
1452
|
if (!sessionId) {
|
|
1242
1453
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1251,7 +1462,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1251
1462
|
return;
|
|
1252
1463
|
}
|
|
1253
1464
|
try {
|
|
1254
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1465
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1255
1466
|
const entry = sessions.get(sessionId);
|
|
1256
1467
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1257
1468
|
res.status(403).json({
|