@productbrain/mcp 0.0.1-beta.1131 → 0.0.1-beta.11313
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-OHJGWECT.js +12064 -0
- package/dist/chunk-OHJGWECT.js.map +1 -0
- package/dist/chunk-XAFCPBXE.js +1601 -0
- package/dist/chunk-XAFCPBXE.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +313 -260
- package/dist/http.js.map +1 -1
- package/dist/index.js +5 -19
- package/dist/index.js.map +1 -1
- package/dist/{setup-PC43F5AO.js → setup-2DDFZQLF.js} +21 -119
- package/dist/setup-2DDFZQLF.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +85 -60
- package/dist/views/src/graph-constellation/index.html +72 -47
- package/package.json +4 -4
- package/dist/chunk-WG7H45D3.js +0 -15293
- package/dist/chunk-WG7H45D3.js.map +0 -1
- package/dist/chunk-YMF3IQ5E.js +0 -465
- package/dist/chunk-YMF3IQ5E.js.map +0 -1
- package/dist/setup-PC43F5AO.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
PRODUCT_NAME,
|
|
3
3
|
SERVER_VERSION,
|
|
4
|
+
createProductBrainServer
|
|
5
|
+
} from "./chunk-OHJGWECT.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CLOUD_URL,
|
|
8
|
+
apiKeysUrl,
|
|
4
9
|
bootstrapHttp,
|
|
5
|
-
|
|
10
|
+
checkKeyAgainstCandidates,
|
|
11
|
+
forgetStreamLifecycle,
|
|
6
12
|
getKeyState,
|
|
7
13
|
hashKey,
|
|
8
|
-
|
|
14
|
+
parseFallbackUrls,
|
|
9
15
|
runWithAuth
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import {
|
|
12
|
-
getPostHogClient,
|
|
13
|
-
initAnalytics,
|
|
14
|
-
shutdownAnalytics
|
|
15
|
-
} from "./chunk-YMF3IQ5E.js";
|
|
16
|
+
} from "./chunk-XAFCPBXE.js";
|
|
16
17
|
|
|
17
18
|
// src/http.ts
|
|
18
|
-
import { createHash, randomUUID
|
|
19
|
+
import { createHash, randomUUID } from "crypto";
|
|
19
20
|
import express from "express";
|
|
20
21
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
22
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -50,67 +51,10 @@ function appLogoMarkup(opts = {}) {
|
|
|
50
51
|
const size = opts.size ?? "sm";
|
|
51
52
|
const showWordmark = opts.showWordmark ?? true;
|
|
52
53
|
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
53
|
-
const wordmark = showWordmark ? `<span class="pb-logo__name"
|
|
54
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
|
|
54
55
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
// src/lib/refresh-token.ts
|
|
58
|
-
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
59
|
-
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
60
|
-
var PREFIX = "pb_rt_";
|
|
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
|
-
);
|
|
68
|
-
}
|
|
69
|
-
return randomBytes(32);
|
|
70
|
-
})();
|
|
71
|
-
function sign(payloadB64) {
|
|
72
|
-
return createHmac("sha256", secret).update(payloadB64).digest();
|
|
73
|
-
}
|
|
74
|
-
function signRefreshToken(apiKey) {
|
|
75
|
-
const payload = {
|
|
76
|
-
k: apiKey,
|
|
77
|
-
i: Date.now(),
|
|
78
|
-
j: randomUUID()
|
|
79
|
-
};
|
|
80
|
-
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
81
|
-
const sigB64 = sign(payloadB64).toString("base64url");
|
|
82
|
-
return `${PREFIX}${payloadB64}.${sigB64}`;
|
|
83
|
-
}
|
|
84
|
-
function verifyRefreshToken(token) {
|
|
85
|
-
if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
|
|
86
|
-
const body = token.slice(PREFIX.length);
|
|
87
|
-
const dot = body.indexOf(".");
|
|
88
|
-
if (dot <= 0 || dot === body.length - 1) return null;
|
|
89
|
-
const payloadB64 = body.slice(0, dot);
|
|
90
|
-
const sigB64 = body.slice(dot + 1);
|
|
91
|
-
let providedSig;
|
|
92
|
-
try {
|
|
93
|
-
providedSig = Buffer.from(sigB64, "base64url");
|
|
94
|
-
} catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
const expectedSig = sign(payloadB64);
|
|
98
|
-
if (providedSig.length !== expectedSig.length) return null;
|
|
99
|
-
if (!timingSafeEqual(providedSig, expectedSig)) return null;
|
|
100
|
-
let payload;
|
|
101
|
-
try {
|
|
102
|
-
const json = Buffer.from(payloadB64, "base64url").toString("utf8");
|
|
103
|
-
payload = JSON.parse(json);
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
|
|
111
|
-
return { apiKey: payload.k };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
58
|
// src/sessionCap.ts
|
|
115
59
|
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
116
60
|
if (cap < 1) {
|
|
@@ -218,11 +162,194 @@ function send429(res, built) {
|
|
|
218
162
|
res.status(built.status).json(built.body);
|
|
219
163
|
}
|
|
220
164
|
|
|
165
|
+
// src/authLockout.ts
|
|
166
|
+
var AUTH_FAILURE_MAX = 10;
|
|
167
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
168
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
169
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
170
|
+
function rejected(presented) {
|
|
171
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
172
|
+
}
|
|
173
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
174
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
175
|
+
return { status: "absent" };
|
|
176
|
+
}
|
|
177
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
178
|
+
return rejected(authorizationHeader.trim());
|
|
179
|
+
}
|
|
180
|
+
const token = authorizationHeader.slice(7).trim();
|
|
181
|
+
if (!token.startsWith("pb_sk_")) return rejected(token);
|
|
182
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
183
|
+
}
|
|
184
|
+
function resolvePastedKey(apiKey) {
|
|
185
|
+
if (apiKey === "") return { status: "absent" };
|
|
186
|
+
if (!apiKey.startsWith("pb_sk_")) return rejected(apiKey);
|
|
187
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
188
|
+
}
|
|
189
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
190
|
+
if (secFetchSite === "cross-site") return true;
|
|
191
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
192
|
+
try {
|
|
193
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
194
|
+
} catch {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
199
|
+
const rec = failures.get(credentialId);
|
|
200
|
+
if (!rec) return false;
|
|
201
|
+
return rec.blockedUntil > now;
|
|
202
|
+
}
|
|
203
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
204
|
+
if (auth.status === "absent") return null;
|
|
205
|
+
const rec = failures.get(auth.credentialId);
|
|
206
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
207
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
208
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
209
|
+
}
|
|
210
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
211
|
+
const rec = failures.get(credentialId);
|
|
212
|
+
if (!rec) {
|
|
213
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
214
|
+
const oldest = failures.keys().next().value;
|
|
215
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
216
|
+
}
|
|
217
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
221
|
+
rec.count = 1;
|
|
222
|
+
rec.firstFailure = now;
|
|
223
|
+
rec.blockedUntil = 0;
|
|
224
|
+
} else {
|
|
225
|
+
rec.count++;
|
|
226
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
227
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
232
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
233
|
+
if (verdict === "rejected") {
|
|
234
|
+
recordAuthFailure(failures, credentialId, now);
|
|
235
|
+
return "unauthorized";
|
|
236
|
+
}
|
|
237
|
+
return "unavailable";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
241
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
242
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
243
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
244
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
245
|
+
];
|
|
246
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
247
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
248
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
249
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
250
|
+
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)";
|
|
251
|
+
function exactForm(url) {
|
|
252
|
+
return `${url.origin}${url.pathname}`;
|
|
253
|
+
}
|
|
254
|
+
function parseRedirectUriAllowlist(raw) {
|
|
255
|
+
if (!raw) return { uris: [], configError: null };
|
|
256
|
+
const uris = [];
|
|
257
|
+
for (const part of raw.split(",")) {
|
|
258
|
+
const trimmed = part.trim();
|
|
259
|
+
if (!trimmed) continue;
|
|
260
|
+
let url = null;
|
|
261
|
+
try {
|
|
262
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
263
|
+
} catch {
|
|
264
|
+
url = null;
|
|
265
|
+
}
|
|
266
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
267
|
+
if (!valid || url === null) {
|
|
268
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
269
|
+
}
|
|
270
|
+
uris.push(exactForm(url));
|
|
271
|
+
}
|
|
272
|
+
return { uris, configError: null };
|
|
273
|
+
}
|
|
274
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
275
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
276
|
+
return {
|
|
277
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
278
|
+
configError: parsed.configError
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
282
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
283
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
284
|
+
return "redirect_uri is required";
|
|
285
|
+
}
|
|
286
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
287
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
288
|
+
}
|
|
289
|
+
let url;
|
|
290
|
+
try {
|
|
291
|
+
url = new URL(redirectUri);
|
|
292
|
+
} catch {
|
|
293
|
+
return "redirect_uri is not a valid absolute URL";
|
|
294
|
+
}
|
|
295
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
296
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
297
|
+
const host = url.hostname.toLowerCase();
|
|
298
|
+
if (url.protocol === "http:") {
|
|
299
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
300
|
+
}
|
|
301
|
+
if (url.protocol === "https:") {
|
|
302
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
303
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
304
|
+
const exact = exactForm(url);
|
|
305
|
+
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)";
|
|
306
|
+
}
|
|
307
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
308
|
+
}
|
|
309
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
310
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
311
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
312
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
313
|
+
}
|
|
314
|
+
for (const uri of redirectUris) {
|
|
315
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
316
|
+
if (reason) return reason;
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/lib/oauthAuthCode.ts
|
|
322
|
+
function normalisePastedKey(raw) {
|
|
323
|
+
if (typeof raw !== "string") return "";
|
|
324
|
+
const trimmed = raw.trim();
|
|
325
|
+
const first = trimmed[0];
|
|
326
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
327
|
+
return trimmed.slice(1, -1).trim();
|
|
328
|
+
}
|
|
329
|
+
return trimmed;
|
|
330
|
+
}
|
|
331
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
332
|
+
if (!pending) return "unknown_code";
|
|
333
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
334
|
+
if (now > pending.expiresAt) return "expired";
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
function tokenResponse(apiKey) {
|
|
338
|
+
return {
|
|
339
|
+
access_token: apiKey,
|
|
340
|
+
token_type: "Bearer",
|
|
341
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
342
|
+
expires_in: 365 * 24 * 3600
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
221
346
|
// src/http.ts
|
|
222
347
|
bootstrapHttp();
|
|
223
|
-
initAnalytics();
|
|
224
|
-
initFeatureFlags(getPostHogClient());
|
|
225
348
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
349
|
+
function keyCheckCandidates() {
|
|
350
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
351
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
352
|
+
}
|
|
226
353
|
function baseUrl(req) {
|
|
227
354
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
228
355
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -232,6 +359,7 @@ var app = express();
|
|
|
232
359
|
app.set("trust proxy", 1);
|
|
233
360
|
app.use(express.json());
|
|
234
361
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
362
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
235
363
|
app.use((_req, res, next) => {
|
|
236
364
|
const origin = _req.headers.origin;
|
|
237
365
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -266,7 +394,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
266
394
|
token_endpoint: `${base}/oauth/token`,
|
|
267
395
|
registration_endpoint: `${base}/register`,
|
|
268
396
|
response_types_supported: ["code"],
|
|
269
|
-
grant_types_supported: ["authorization_code"
|
|
397
|
+
grant_types_supported: ["authorization_code"],
|
|
398
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
270
399
|
code_challenge_methods_supported: ["S256"],
|
|
271
400
|
token_endpoint_auth_methods_supported: ["none"],
|
|
272
401
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
@@ -294,14 +423,12 @@ app.post(
|
|
|
294
423
|
return;
|
|
295
424
|
}
|
|
296
425
|
const { redirect_uris, client_name } = req.body;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
error_description: "redirect_uris is required"
|
|
301
|
-
});
|
|
426
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
427
|
+
if (refusal) {
|
|
428
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
302
429
|
return;
|
|
303
430
|
}
|
|
304
|
-
const clientId = `pb_client_${
|
|
431
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
305
432
|
const client = {
|
|
306
433
|
client_id: clientId,
|
|
307
434
|
redirect_uris,
|
|
@@ -320,9 +447,6 @@ app.post(
|
|
|
320
447
|
}
|
|
321
448
|
);
|
|
322
449
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
323
|
-
var ACCESS_TOKEN_TTL = 3600;
|
|
324
|
-
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
325
|
-
var accessTokens = /* @__PURE__ */ new Map();
|
|
326
450
|
setInterval(() => {
|
|
327
451
|
const now = Date.now();
|
|
328
452
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -331,18 +455,9 @@ setInterval(() => {
|
|
|
331
455
|
for (const [id, client] of registeredClients) {
|
|
332
456
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
333
457
|
}
|
|
334
|
-
for (const [
|
|
335
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
336
|
-
}
|
|
337
|
-
for (const [ip, rec] of authFailures) {
|
|
458
|
+
for (const [credentialId, rec] of authFailures) {
|
|
338
459
|
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
339
|
-
authFailures.delete(
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
|
|
343
|
-
const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
|
|
344
|
-
for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
|
|
345
|
-
authFailures.delete(sorted[i][0]);
|
|
460
|
+
authFailures.delete(credentialId);
|
|
346
461
|
}
|
|
347
462
|
}
|
|
348
463
|
}, 6e4);
|
|
@@ -716,7 +831,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
716
831
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
717
832
|
<div class="err-actions">
|
|
718
833
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
719
|
-
<a href="
|
|
834
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
720
835
|
</div>`;
|
|
721
836
|
}
|
|
722
837
|
var cmdScript = `
|
|
@@ -764,7 +879,7 @@ function authorizeFormPage(params) {
|
|
|
764
879
|
<div class="hint" id="hint" hidden></div>
|
|
765
880
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
766
881
|
</form>
|
|
767
|
-
<div class="small-link"><a href="
|
|
882
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
768
883
|
</div>
|
|
769
884
|
|
|
770
885
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -845,7 +960,6 @@ ${cmdScript}
|
|
|
845
960
|
e.preventDefault();
|
|
846
961
|
var v=k.value.trim();
|
|
847
962
|
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
|
|
848
|
-
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}
|
|
849
963
|
sb.disabled=true;bt.textContent='Verifying';
|
|
850
964
|
show(pVerify);
|
|
851
965
|
|
|
@@ -903,6 +1017,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
903
1017
|
}
|
|
904
1018
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
905
1019
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1020
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1021
|
+
if (refusal) {
|
|
1022
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
906
1025
|
const cid = String(client_id ?? "");
|
|
907
1026
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
908
1027
|
res.type("html").send(authorizeFormPage({
|
|
@@ -919,7 +1038,8 @@ app.post(
|
|
|
919
1038
|
authLimiter,
|
|
920
1039
|
express.urlencoded({ extended: false }),
|
|
921
1040
|
async (req, res) => {
|
|
922
|
-
const {
|
|
1041
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1042
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
923
1043
|
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
924
1044
|
const retryParams = new URLSearchParams({
|
|
925
1045
|
redirect_uri: redirect_uri ?? "",
|
|
@@ -936,36 +1056,41 @@ app.post(
|
|
|
936
1056
|
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
937
1057
|
}
|
|
938
1058
|
}
|
|
939
|
-
if (
|
|
940
|
-
sendError(
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1059
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1060
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const nowTs = Date.now();
|
|
1064
|
+
const pasted = resolvePastedKey(api_key);
|
|
1065
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1066
|
+
if (lockout) {
|
|
1067
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1068
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1069
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (pasted.status !== "ok") {
|
|
1073
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1074
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
944
1075
|
return;
|
|
945
1076
|
}
|
|
946
1077
|
if (!client_id) {
|
|
947
|
-
res.status(400).json({
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1078
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1082
|
+
if (redirectRefusal) {
|
|
1083
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
951
1084
|
return;
|
|
952
1085
|
}
|
|
953
1086
|
if (!registeredClients.has(client_id)) {
|
|
954
|
-
if (
|
|
955
|
-
|
|
956
|
-
client_id,
|
|
957
|
-
redirect_uris: [redirect_uri],
|
|
958
|
-
registeredAt: Date.now()
|
|
959
|
-
});
|
|
960
|
-
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
961
|
-
`);
|
|
962
|
-
} else {
|
|
963
|
-
res.status(400).json({
|
|
964
|
-
error: "invalid_request",
|
|
965
|
-
error_description: "Unknown client_id and redirect_uri is not a valid https URL"
|
|
966
|
-
});
|
|
1087
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1088
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
967
1089
|
return;
|
|
968
1090
|
}
|
|
1091
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1092
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1093
|
+
`);
|
|
969
1094
|
}
|
|
970
1095
|
const client = registeredClients.get(client_id);
|
|
971
1096
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -976,48 +1101,25 @@ app.post(
|
|
|
976
1101
|
return;
|
|
977
1102
|
}
|
|
978
1103
|
let workspaceName = "Your Workspace";
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
if (checkData.ok) {
|
|
998
|
-
if (checkData.workspaceName) workspaceName = checkData.workspaceName;
|
|
999
|
-
foundUrl = checkData.deploymentUrl ?? url2;
|
|
1000
|
-
break;
|
|
1001
|
-
}
|
|
1002
|
-
anyDefinitiveReject = true;
|
|
1003
|
-
}
|
|
1004
|
-
if (!foundUrl) {
|
|
1005
|
-
if (anyDefinitiveReject) {
|
|
1006
|
-
sendError(
|
|
1007
|
-
"Key not recognized",
|
|
1008
|
-
"This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
|
|
1009
|
-
401
|
|
1010
|
-
);
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1014
|
-
} else {
|
|
1015
|
-
getKeyState(api_key).deploymentUrl = foundUrl;
|
|
1016
|
-
}
|
|
1017
|
-
} catch {
|
|
1104
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1105
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1106
|
+
if (check.verdict === "ok") {
|
|
1107
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1108
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1109
|
+
} else if (check.verdict === "rate_limited") {
|
|
1110
|
+
sendError(
|
|
1111
|
+
"Rate limited",
|
|
1112
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1113
|
+
429
|
|
1114
|
+
);
|
|
1115
|
+
return;
|
|
1116
|
+
} else if (gate === "unauthorized") {
|
|
1117
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1118
|
+
return;
|
|
1119
|
+
} else {
|
|
1018
1120
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1019
1121
|
}
|
|
1020
|
-
const code =
|
|
1122
|
+
const code = randomUUID();
|
|
1021
1123
|
pendingCodes.set(code, {
|
|
1022
1124
|
apiKey: api_key,
|
|
1023
1125
|
codeChallenge: code_challenge,
|
|
@@ -1036,41 +1138,25 @@ app.post(
|
|
|
1036
1138
|
}
|
|
1037
1139
|
}
|
|
1038
1140
|
);
|
|
1039
|
-
function issueTokens(apiKey) {
|
|
1040
|
-
return {
|
|
1041
|
-
access_token: apiKey,
|
|
1042
|
-
token_type: "Bearer",
|
|
1043
|
-
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
1044
|
-
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
1045
|
-
expires_in: 365 * 24 * 3600,
|
|
1046
|
-
refresh_token: signRefreshToken(apiKey)
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
1141
|
app.post(
|
|
1050
1142
|
"/oauth/token",
|
|
1051
1143
|
authLimiter,
|
|
1052
1144
|
express.urlencoded({ extended: false }),
|
|
1053
1145
|
express.json(),
|
|
1054
1146
|
(req, res) => {
|
|
1055
|
-
const { grant_type, code, code_verifier, redirect_uri
|
|
1056
|
-
if (grant_type === "refresh_token") {
|
|
1057
|
-
const verified = verifyRefreshToken(refresh_token);
|
|
1058
|
-
if (!verified) {
|
|
1059
|
-
res.status(400).json({
|
|
1060
|
-
error: "invalid_grant",
|
|
1061
|
-
error_description: "Invalid or expired refresh token"
|
|
1062
|
-
});
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
res.json(issueTokens(verified.apiKey));
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1147
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
1068
1148
|
if (grant_type !== "authorization_code") {
|
|
1069
1149
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
1070
1150
|
return;
|
|
1071
1151
|
}
|
|
1072
1152
|
const pending = pendingCodes.get(code);
|
|
1073
|
-
|
|
1153
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1154
|
+
if (codeRefusal === "expired") {
|
|
1155
|
+
pendingCodes.delete(code);
|
|
1156
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (codeRefusal || !pending) {
|
|
1074
1160
|
res.status(400).json({ error: "invalid_grant" });
|
|
1075
1161
|
return;
|
|
1076
1162
|
}
|
|
@@ -1084,7 +1170,7 @@ app.post(
|
|
|
1084
1170
|
return;
|
|
1085
1171
|
}
|
|
1086
1172
|
pendingCodes.delete(code);
|
|
1087
|
-
res.json(
|
|
1173
|
+
res.json(tokenResponse(pending.apiKey));
|
|
1088
1174
|
}
|
|
1089
1175
|
);
|
|
1090
1176
|
var mcpLimiter = rateLimit({
|
|
@@ -1105,33 +1191,6 @@ var mcpLimiter = rateLimit({
|
|
|
1105
1191
|
}
|
|
1106
1192
|
});
|
|
1107
1193
|
var authFailures = /* @__PURE__ */ new Map();
|
|
1108
|
-
var AUTH_FAILURE_MAX = 10;
|
|
1109
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
1110
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
1111
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
1112
|
-
function checkAuthBlock(ip) {
|
|
1113
|
-
const rec = authFailures.get(ip);
|
|
1114
|
-
if (!rec) return false;
|
|
1115
|
-
return rec.blockedUntil > Date.now();
|
|
1116
|
-
}
|
|
1117
|
-
function recordAuthFailure(ip) {
|
|
1118
|
-
const now = Date.now();
|
|
1119
|
-
const rec = authFailures.get(ip);
|
|
1120
|
-
if (!rec) {
|
|
1121
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
1125
|
-
rec.count = 1;
|
|
1126
|
-
rec.firstFailure = now;
|
|
1127
|
-
rec.blockedUntil = 0;
|
|
1128
|
-
} else {
|
|
1129
|
-
rec.count++;
|
|
1130
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
1131
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
1194
|
app.get("/health", (_req, res) => {
|
|
1136
1195
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1137
1196
|
});
|
|
@@ -1176,25 +1235,6 @@ function evictStaleSessions() {
|
|
|
1176
1235
|
}
|
|
1177
1236
|
}
|
|
1178
1237
|
setInterval(evictStaleSessions, 6e4);
|
|
1179
|
-
function extractBearerKey(req) {
|
|
1180
|
-
const header = req.headers?.authorization;
|
|
1181
|
-
if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
|
|
1182
|
-
const token = header.slice(7).trim();
|
|
1183
|
-
if (token.startsWith("pb_sk_")) {
|
|
1184
|
-
return token;
|
|
1185
|
-
}
|
|
1186
|
-
if (token.startsWith("pb_at_")) {
|
|
1187
|
-
const entry = accessTokens.get(token);
|
|
1188
|
-
if (!entry) return null;
|
|
1189
|
-
const now = Date.now();
|
|
1190
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1191
|
-
accessTokens.delete(token);
|
|
1192
|
-
return null;
|
|
1193
|
-
}
|
|
1194
|
-
return entry.apiKey;
|
|
1195
|
-
}
|
|
1196
|
-
return null;
|
|
1197
|
-
}
|
|
1198
1238
|
function send401(req, res) {
|
|
1199
1239
|
const base = baseUrl(req);
|
|
1200
1240
|
res.status(401).set(
|
|
@@ -1216,24 +1256,24 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1216
1256
|
`);
|
|
1217
1257
|
}
|
|
1218
1258
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
send429(res,
|
|
1259
|
+
const nowTs = Date.now();
|
|
1260
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1261
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1262
|
+
if (lockout) {
|
|
1263
|
+
send429(res, lockout);
|
|
1224
1264
|
return;
|
|
1225
1265
|
}
|
|
1226
|
-
|
|
1227
|
-
if (!apiKey) {
|
|
1266
|
+
if (auth.status !== "ok") {
|
|
1228
1267
|
logRequest("POST", "auth_fail");
|
|
1229
|
-
recordAuthFailure(
|
|
1268
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1230
1269
|
send401(req, res);
|
|
1231
1270
|
return;
|
|
1232
1271
|
}
|
|
1272
|
+
const apiKey = auth.apiKey;
|
|
1233
1273
|
const sessionId = req.headers["mcp-session-id"];
|
|
1234
1274
|
const reqStart = Date.now();
|
|
1235
1275
|
try {
|
|
1236
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1276
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1237
1277
|
if (sessionId && sessions.has(sessionId)) {
|
|
1238
1278
|
const entry = sessions.get(sessionId);
|
|
1239
1279
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1254,6 +1294,22 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1254
1294
|
entry.lastAccess = Date.now();
|
|
1255
1295
|
}
|
|
1256
1296
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1297
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1298
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1299
|
+
if (gate === "unauthorized") {
|
|
1300
|
+
logRequest("POST", "auth_fail");
|
|
1301
|
+
send401(req, res);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (gate === "unavailable") {
|
|
1305
|
+
res.status(503).json({
|
|
1306
|
+
jsonrpc: "2.0",
|
|
1307
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1308
|
+
id: req.body?.id ?? null
|
|
1309
|
+
});
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1257
1313
|
const keyH = hashKey(apiKey);
|
|
1258
1314
|
const plan = planKeyAdmission(
|
|
1259
1315
|
sessions,
|
|
@@ -1279,7 +1335,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1279
1335
|
}));
|
|
1280
1336
|
return;
|
|
1281
1337
|
}
|
|
1282
|
-
const sid =
|
|
1338
|
+
const sid = randomUUID();
|
|
1283
1339
|
let initialized = false;
|
|
1284
1340
|
const transport = new StreamableHTTPServerTransport({
|
|
1285
1341
|
sessionIdGenerator: () => sid,
|
|
@@ -1293,6 +1349,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1293
1349
|
sessions.set(sid, reserved);
|
|
1294
1350
|
transport.onclose = () => {
|
|
1295
1351
|
const s = transport.sessionId ?? sid;
|
|
1352
|
+
forgetStreamLifecycle(keyH, s);
|
|
1296
1353
|
if (sessions.has(s)) {
|
|
1297
1354
|
sessions.delete(s);
|
|
1298
1355
|
logSessionLifecycle("session_deleted", s, "onclose");
|
|
@@ -1353,20 +1410,20 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1353
1410
|
}
|
|
1354
1411
|
});
|
|
1355
1412
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
send429(res,
|
|
1413
|
+
const nowTs = Date.now();
|
|
1414
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1415
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1416
|
+
if (lockout) {
|
|
1417
|
+
send429(res, lockout);
|
|
1361
1418
|
return;
|
|
1362
1419
|
}
|
|
1363
|
-
|
|
1364
|
-
if (!apiKey) {
|
|
1420
|
+
if (auth.status !== "ok") {
|
|
1365
1421
|
logRequest("GET", "auth_fail");
|
|
1366
|
-
recordAuthFailure(
|
|
1422
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1367
1423
|
send401(req, res);
|
|
1368
1424
|
return;
|
|
1369
1425
|
}
|
|
1426
|
+
const apiKey = auth.apiKey;
|
|
1370
1427
|
const sessionId = req.headers["mcp-session-id"];
|
|
1371
1428
|
if (!sessionId) {
|
|
1372
1429
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1381,7 +1438,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1381
1438
|
return;
|
|
1382
1439
|
}
|
|
1383
1440
|
try {
|
|
1384
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1441
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1385
1442
|
const entry = sessions.get(sessionId);
|
|
1386
1443
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1387
1444
|
res.status(403).json({
|
|
@@ -1400,20 +1457,20 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1400
1457
|
}
|
|
1401
1458
|
});
|
|
1402
1459
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
send429(res,
|
|
1460
|
+
const nowTs = Date.now();
|
|
1461
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1462
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1463
|
+
if (lockout) {
|
|
1464
|
+
send429(res, lockout);
|
|
1408
1465
|
return;
|
|
1409
1466
|
}
|
|
1410
|
-
|
|
1411
|
-
if (!apiKey) {
|
|
1467
|
+
if (auth.status !== "ok") {
|
|
1412
1468
|
logRequest("DELETE", "auth_fail");
|
|
1413
|
-
recordAuthFailure(
|
|
1469
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1414
1470
|
send401(req, res);
|
|
1415
1471
|
return;
|
|
1416
1472
|
}
|
|
1473
|
+
const apiKey = auth.apiKey;
|
|
1417
1474
|
const sessionId = req.headers["mcp-session-id"];
|
|
1418
1475
|
if (!sessionId) {
|
|
1419
1476
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1428,7 +1485,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1428
1485
|
return;
|
|
1429
1486
|
}
|
|
1430
1487
|
try {
|
|
1431
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1488
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1432
1489
|
const entry = sessions.get(sessionId);
|
|
1433
1490
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1434
1491
|
res.status(403).json({
|
|
@@ -1463,10 +1520,6 @@ async function gracefulShutdown() {
|
|
|
1463
1520
|
await entry.transport.close().catch(() => {
|
|
1464
1521
|
});
|
|
1465
1522
|
}
|
|
1466
|
-
try {
|
|
1467
|
-
await shutdownAnalytics();
|
|
1468
|
-
} catch {
|
|
1469
|
-
}
|
|
1470
1523
|
process.exit(0);
|
|
1471
1524
|
}
|
|
1472
1525
|
var LISTEN_HOST = "0.0.0.0";
|