@productbrain/mcp 0.0.1-beta.1049 → 0.0.1-beta.10490
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-FTKQF7PG.js +12781 -0
- package/dist/chunk-FTKQF7PG.js.map +1 -0
- package/dist/chunk-L3QEFWAV.js +1896 -0
- package/dist/chunk-L3QEFWAV.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +316 -253
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-ZKXZRNV2.js} +20 -104
- package/dist/setup-ZKXZRNV2.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +85 -60
- package/dist/views/src/graph-constellation/index.html +72 -47
- package/package.json +4 -3
- package/dist/chunk-CIHITLKC.js +0 -15293
- package/dist/chunk-CIHITLKC.js.map +0 -1
- package/dist/chunk-YMF3IQ5E.js +0 -465
- package/dist/chunk-YMF3IQ5E.js.map +0 -1
- package/dist/setup-RYYXRDPB.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
PRODUCT_NAME,
|
|
3
3
|
SERVER_VERSION,
|
|
4
|
-
bootstrapHttp,
|
|
5
4
|
createProductBrainServer,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
initFeatureFlags,
|
|
9
|
-
runWithAuth
|
|
10
|
-
} from "./chunk-CIHITLKC.js";
|
|
5
|
+
initFeatureFlags
|
|
6
|
+
} from "./chunk-FTKQF7PG.js";
|
|
11
7
|
import {
|
|
8
|
+
DEFAULT_CLOUD_URL,
|
|
9
|
+
apiKeysUrl,
|
|
10
|
+
bootstrapHttp,
|
|
11
|
+
checkKeyAgainstCandidates,
|
|
12
|
+
forgetStreamLifecycle,
|
|
13
|
+
getKeyState,
|
|
12
14
|
getPostHogClient,
|
|
15
|
+
hashKey,
|
|
13
16
|
initAnalytics,
|
|
17
|
+
parseFallbackUrls,
|
|
18
|
+
runWithAuth,
|
|
14
19
|
shutdownAnalytics
|
|
15
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-L3QEFWAV.js";
|
|
16
21
|
|
|
17
22
|
// src/http.ts
|
|
18
|
-
import { createHash, randomUUID
|
|
23
|
+
import { createHash, randomUUID } from "crypto";
|
|
19
24
|
import express from "express";
|
|
20
25
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
26
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -50,67 +55,10 @@ function appLogoMarkup(opts = {}) {
|
|
|
50
55
|
const size = opts.size ?? "sm";
|
|
51
56
|
const showWordmark = opts.showWordmark ?? true;
|
|
52
57
|
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
53
|
-
const wordmark = showWordmark ? `<span class="pb-logo__name"
|
|
58
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
|
|
54
59
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
// src/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
62
|
// src/sessionCap.ts
|
|
115
63
|
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
116
64
|
if (cap < 1) {
|
|
@@ -218,11 +166,196 @@ function send429(res, built) {
|
|
|
218
166
|
res.status(built.status).json(built.body);
|
|
219
167
|
}
|
|
220
168
|
|
|
169
|
+
// src/authLockout.ts
|
|
170
|
+
var AUTH_FAILURE_MAX = 10;
|
|
171
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
172
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
173
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
174
|
+
function rejected(presented) {
|
|
175
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
176
|
+
}
|
|
177
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
178
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
179
|
+
return { status: "absent" };
|
|
180
|
+
}
|
|
181
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
182
|
+
return rejected(authorizationHeader.trim());
|
|
183
|
+
}
|
|
184
|
+
const token = authorizationHeader.slice(7).trim();
|
|
185
|
+
if (!token.startsWith("pb_sk_")) return rejected(token);
|
|
186
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
187
|
+
}
|
|
188
|
+
function resolvePastedKey(apiKey) {
|
|
189
|
+
if (apiKey === "") return { status: "absent" };
|
|
190
|
+
if (!apiKey.startsWith("pb_sk_")) return rejected(apiKey);
|
|
191
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
192
|
+
}
|
|
193
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
194
|
+
if (secFetchSite === "cross-site") return true;
|
|
195
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
196
|
+
try {
|
|
197
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
198
|
+
} catch {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
203
|
+
const rec = failures.get(credentialId);
|
|
204
|
+
if (!rec) return false;
|
|
205
|
+
return rec.blockedUntil > now;
|
|
206
|
+
}
|
|
207
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
208
|
+
if (auth.status === "absent") return null;
|
|
209
|
+
const rec = failures.get(auth.credentialId);
|
|
210
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
211
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
212
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
213
|
+
}
|
|
214
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
215
|
+
const rec = failures.get(credentialId);
|
|
216
|
+
if (!rec) {
|
|
217
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
218
|
+
const oldest = failures.keys().next().value;
|
|
219
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
220
|
+
}
|
|
221
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
225
|
+
rec.count = 1;
|
|
226
|
+
rec.firstFailure = now;
|
|
227
|
+
rec.blockedUntil = 0;
|
|
228
|
+
} else {
|
|
229
|
+
rec.count++;
|
|
230
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
231
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
236
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
237
|
+
if (verdict === "rejected") {
|
|
238
|
+
recordAuthFailure(failures, credentialId, now);
|
|
239
|
+
return "unauthorized";
|
|
240
|
+
}
|
|
241
|
+
return "unavailable";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
245
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
246
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
247
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
248
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
249
|
+
];
|
|
250
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
251
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
252
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
253
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
254
|
+
var OPERATOR_ENTRY_FORM = "OAUTH_REDIRECT_HOST_ALLOWLIST entries must be full https redirect URIs such as https://partner.example/oauth/callback (no bare host, no query, no fragment)";
|
|
255
|
+
function exactForm(url) {
|
|
256
|
+
return `${url.origin}${url.pathname}`;
|
|
257
|
+
}
|
|
258
|
+
function parseRedirectUriAllowlist(raw) {
|
|
259
|
+
if (!raw) return { uris: [], configError: null };
|
|
260
|
+
const uris = [];
|
|
261
|
+
for (const part of raw.split(",")) {
|
|
262
|
+
const trimmed = part.trim();
|
|
263
|
+
if (!trimmed) continue;
|
|
264
|
+
let url = null;
|
|
265
|
+
try {
|
|
266
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
267
|
+
} catch {
|
|
268
|
+
url = null;
|
|
269
|
+
}
|
|
270
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
271
|
+
if (!valid || url === null) {
|
|
272
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
273
|
+
}
|
|
274
|
+
uris.push(exactForm(url));
|
|
275
|
+
}
|
|
276
|
+
return { uris, configError: null };
|
|
277
|
+
}
|
|
278
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
279
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
280
|
+
return {
|
|
281
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
282
|
+
configError: parsed.configError
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
286
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
287
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
288
|
+
return "redirect_uri is required";
|
|
289
|
+
}
|
|
290
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
291
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
292
|
+
}
|
|
293
|
+
let url;
|
|
294
|
+
try {
|
|
295
|
+
url = new URL(redirectUri);
|
|
296
|
+
} catch {
|
|
297
|
+
return "redirect_uri is not a valid absolute URL";
|
|
298
|
+
}
|
|
299
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
300
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
301
|
+
const host = url.hostname.toLowerCase();
|
|
302
|
+
if (url.protocol === "http:") {
|
|
303
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
304
|
+
}
|
|
305
|
+
if (url.protocol === "https:") {
|
|
306
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
307
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
308
|
+
const exact = exactForm(url);
|
|
309
|
+
return allowlist.uris.includes(exact) || CHATGPT_CONNECTOR_CALLBACK.test(exact) ? null : "redirect_uri is not an allowed callback (an operator can add the full URI with OAUTH_REDIRECT_HOST_ALLOWLIST)";
|
|
310
|
+
}
|
|
311
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
312
|
+
}
|
|
313
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
314
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
315
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
316
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
317
|
+
}
|
|
318
|
+
for (const uri of redirectUris) {
|
|
319
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
320
|
+
if (reason) return reason;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/lib/oauthAuthCode.ts
|
|
326
|
+
function normalisePastedKey(raw) {
|
|
327
|
+
if (typeof raw !== "string") return "";
|
|
328
|
+
const trimmed = raw.trim();
|
|
329
|
+
const first = trimmed[0];
|
|
330
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
331
|
+
return trimmed.slice(1, -1).trim();
|
|
332
|
+
}
|
|
333
|
+
return trimmed;
|
|
334
|
+
}
|
|
335
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
336
|
+
if (!pending) return "unknown_code";
|
|
337
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
338
|
+
if (now > pending.expiresAt) return "expired";
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
function tokenResponse(apiKey) {
|
|
342
|
+
return {
|
|
343
|
+
access_token: apiKey,
|
|
344
|
+
token_type: "Bearer",
|
|
345
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
346
|
+
expires_in: 365 * 24 * 3600
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
221
350
|
// src/http.ts
|
|
222
351
|
bootstrapHttp();
|
|
223
352
|
initAnalytics();
|
|
224
353
|
initFeatureFlags(getPostHogClient());
|
|
225
354
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
355
|
+
function keyCheckCandidates() {
|
|
356
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
357
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
358
|
+
}
|
|
226
359
|
function baseUrl(req) {
|
|
227
360
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
228
361
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -232,6 +365,7 @@ var app = express();
|
|
|
232
365
|
app.set("trust proxy", 1);
|
|
233
366
|
app.use(express.json());
|
|
234
367
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
368
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
235
369
|
app.use((_req, res, next) => {
|
|
236
370
|
const origin = _req.headers.origin;
|
|
237
371
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -266,7 +400,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
266
400
|
token_endpoint: `${base}/oauth/token`,
|
|
267
401
|
registration_endpoint: `${base}/register`,
|
|
268
402
|
response_types_supported: ["code"],
|
|
269
|
-
grant_types_supported: ["authorization_code"
|
|
403
|
+
grant_types_supported: ["authorization_code"],
|
|
404
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
270
405
|
code_challenge_methods_supported: ["S256"],
|
|
271
406
|
token_endpoint_auth_methods_supported: ["none"],
|
|
272
407
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
@@ -294,14 +429,12 @@ app.post(
|
|
|
294
429
|
return;
|
|
295
430
|
}
|
|
296
431
|
const { redirect_uris, client_name } = req.body;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
error_description: "redirect_uris is required"
|
|
301
|
-
});
|
|
432
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
433
|
+
if (refusal) {
|
|
434
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
302
435
|
return;
|
|
303
436
|
}
|
|
304
|
-
const clientId = `pb_client_${
|
|
437
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
305
438
|
const client = {
|
|
306
439
|
client_id: clientId,
|
|
307
440
|
redirect_uris,
|
|
@@ -320,9 +453,6 @@ app.post(
|
|
|
320
453
|
}
|
|
321
454
|
);
|
|
322
455
|
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
456
|
setInterval(() => {
|
|
327
457
|
const now = Date.now();
|
|
328
458
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -331,18 +461,9 @@ setInterval(() => {
|
|
|
331
461
|
for (const [id, client] of registeredClients) {
|
|
332
462
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
333
463
|
}
|
|
334
|
-
for (const [
|
|
335
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
336
|
-
}
|
|
337
|
-
for (const [ip, rec] of authFailures) {
|
|
464
|
+
for (const [credentialId, rec] of authFailures) {
|
|
338
465
|
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]);
|
|
466
|
+
authFailures.delete(credentialId);
|
|
346
467
|
}
|
|
347
468
|
}
|
|
348
469
|
}, 6e4);
|
|
@@ -716,7 +837,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
716
837
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
717
838
|
<div class="err-actions">
|
|
718
839
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
719
|
-
<a href="
|
|
840
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
720
841
|
</div>`;
|
|
721
842
|
}
|
|
722
843
|
var cmdScript = `
|
|
@@ -764,7 +885,7 @@ function authorizeFormPage(params) {
|
|
|
764
885
|
<div class="hint" id="hint" hidden></div>
|
|
765
886
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
766
887
|
</form>
|
|
767
|
-
<div class="small-link"><a href="
|
|
888
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
768
889
|
</div>
|
|
769
890
|
|
|
770
891
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -845,7 +966,6 @@ ${cmdScript}
|
|
|
845
966
|
e.preventDefault();
|
|
846
967
|
var v=k.value.trim();
|
|
847
968
|
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
969
|
sb.disabled=true;bt.textContent='Verifying';
|
|
850
970
|
show(pVerify);
|
|
851
971
|
|
|
@@ -903,6 +1023,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
903
1023
|
}
|
|
904
1024
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
905
1025
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1026
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1027
|
+
if (refusal) {
|
|
1028
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
906
1031
|
const cid = String(client_id ?? "");
|
|
907
1032
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
908
1033
|
res.type("html").send(authorizeFormPage({
|
|
@@ -919,7 +1044,8 @@ app.post(
|
|
|
919
1044
|
authLimiter,
|
|
920
1045
|
express.urlencoded({ extended: false }),
|
|
921
1046
|
async (req, res) => {
|
|
922
|
-
const {
|
|
1047
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1048
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
923
1049
|
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
924
1050
|
const retryParams = new URLSearchParams({
|
|
925
1051
|
redirect_uri: redirect_uri ?? "",
|
|
@@ -936,36 +1062,41 @@ app.post(
|
|
|
936
1062
|
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
937
1063
|
}
|
|
938
1064
|
}
|
|
939
|
-
if (
|
|
940
|
-
sendError(
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1065
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1066
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const nowTs = Date.now();
|
|
1070
|
+
const pasted = resolvePastedKey(api_key);
|
|
1071
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1072
|
+
if (lockout) {
|
|
1073
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1074
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1075
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (pasted.status !== "ok") {
|
|
1079
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1080
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
944
1081
|
return;
|
|
945
1082
|
}
|
|
946
1083
|
if (!client_id) {
|
|
947
|
-
res.status(400).json({
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1084
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1088
|
+
if (redirectRefusal) {
|
|
1089
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
951
1090
|
return;
|
|
952
1091
|
}
|
|
953
1092
|
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
|
-
});
|
|
1093
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1094
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
967
1095
|
return;
|
|
968
1096
|
}
|
|
1097
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1098
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1099
|
+
`);
|
|
969
1100
|
}
|
|
970
1101
|
const client = registeredClients.get(client_id);
|
|
971
1102
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -976,48 +1107,25 @@ app.post(
|
|
|
976
1107
|
return;
|
|
977
1108
|
}
|
|
978
1109
|
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 {
|
|
1110
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1111
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1112
|
+
if (check.verdict === "ok") {
|
|
1113
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1114
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1115
|
+
} else if (check.verdict === "rate_limited") {
|
|
1116
|
+
sendError(
|
|
1117
|
+
"Rate limited",
|
|
1118
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1119
|
+
429
|
|
1120
|
+
);
|
|
1121
|
+
return;
|
|
1122
|
+
} else if (gate === "unauthorized") {
|
|
1123
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1124
|
+
return;
|
|
1125
|
+
} else {
|
|
1018
1126
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1019
1127
|
}
|
|
1020
|
-
const code =
|
|
1128
|
+
const code = randomUUID();
|
|
1021
1129
|
pendingCodes.set(code, {
|
|
1022
1130
|
apiKey: api_key,
|
|
1023
1131
|
codeChallenge: code_challenge,
|
|
@@ -1036,41 +1144,25 @@ app.post(
|
|
|
1036
1144
|
}
|
|
1037
1145
|
}
|
|
1038
1146
|
);
|
|
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
1147
|
app.post(
|
|
1050
1148
|
"/oauth/token",
|
|
1051
1149
|
authLimiter,
|
|
1052
1150
|
express.urlencoded({ extended: false }),
|
|
1053
1151
|
express.json(),
|
|
1054
1152
|
(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
|
-
}
|
|
1153
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
1068
1154
|
if (grant_type !== "authorization_code") {
|
|
1069
1155
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
1070
1156
|
return;
|
|
1071
1157
|
}
|
|
1072
1158
|
const pending = pendingCodes.get(code);
|
|
1073
|
-
|
|
1159
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1160
|
+
if (codeRefusal === "expired") {
|
|
1161
|
+
pendingCodes.delete(code);
|
|
1162
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (codeRefusal || !pending) {
|
|
1074
1166
|
res.status(400).json({ error: "invalid_grant" });
|
|
1075
1167
|
return;
|
|
1076
1168
|
}
|
|
@@ -1084,7 +1176,7 @@ app.post(
|
|
|
1084
1176
|
return;
|
|
1085
1177
|
}
|
|
1086
1178
|
pendingCodes.delete(code);
|
|
1087
|
-
res.json(
|
|
1179
|
+
res.json(tokenResponse(pending.apiKey));
|
|
1088
1180
|
}
|
|
1089
1181
|
);
|
|
1090
1182
|
var mcpLimiter = rateLimit({
|
|
@@ -1105,33 +1197,6 @@ var mcpLimiter = rateLimit({
|
|
|
1105
1197
|
}
|
|
1106
1198
|
});
|
|
1107
1199
|
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
1200
|
app.get("/health", (_req, res) => {
|
|
1136
1201
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1137
1202
|
});
|
|
@@ -1176,25 +1241,6 @@ function evictStaleSessions() {
|
|
|
1176
1241
|
}
|
|
1177
1242
|
}
|
|
1178
1243
|
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
1244
|
function send401(req, res) {
|
|
1199
1245
|
const base = baseUrl(req);
|
|
1200
1246
|
res.status(401).set(
|
|
@@ -1216,24 +1262,24 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1216
1262
|
`);
|
|
1217
1263
|
}
|
|
1218
1264
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
send429(res,
|
|
1265
|
+
const nowTs = Date.now();
|
|
1266
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1267
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1268
|
+
if (lockout) {
|
|
1269
|
+
send429(res, lockout);
|
|
1224
1270
|
return;
|
|
1225
1271
|
}
|
|
1226
|
-
|
|
1227
|
-
if (!apiKey) {
|
|
1272
|
+
if (auth.status !== "ok") {
|
|
1228
1273
|
logRequest("POST", "auth_fail");
|
|
1229
|
-
recordAuthFailure(
|
|
1274
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1230
1275
|
send401(req, res);
|
|
1231
1276
|
return;
|
|
1232
1277
|
}
|
|
1278
|
+
const apiKey = auth.apiKey;
|
|
1233
1279
|
const sessionId = req.headers["mcp-session-id"];
|
|
1234
1280
|
const reqStart = Date.now();
|
|
1235
1281
|
try {
|
|
1236
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1282
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1237
1283
|
if (sessionId && sessions.has(sessionId)) {
|
|
1238
1284
|
const entry = sessions.get(sessionId);
|
|
1239
1285
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1254,6 +1300,22 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1254
1300
|
entry.lastAccess = Date.now();
|
|
1255
1301
|
}
|
|
1256
1302
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1303
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1304
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1305
|
+
if (gate === "unauthorized") {
|
|
1306
|
+
logRequest("POST", "auth_fail");
|
|
1307
|
+
send401(req, res);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (gate === "unavailable") {
|
|
1311
|
+
res.status(503).json({
|
|
1312
|
+
jsonrpc: "2.0",
|
|
1313
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1314
|
+
id: req.body?.id ?? null
|
|
1315
|
+
});
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1257
1319
|
const keyH = hashKey(apiKey);
|
|
1258
1320
|
const plan = planKeyAdmission(
|
|
1259
1321
|
sessions,
|
|
@@ -1279,7 +1341,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1279
1341
|
}));
|
|
1280
1342
|
return;
|
|
1281
1343
|
}
|
|
1282
|
-
const sid =
|
|
1344
|
+
const sid = randomUUID();
|
|
1283
1345
|
let initialized = false;
|
|
1284
1346
|
const transport = new StreamableHTTPServerTransport({
|
|
1285
1347
|
sessionIdGenerator: () => sid,
|
|
@@ -1293,6 +1355,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1293
1355
|
sessions.set(sid, reserved);
|
|
1294
1356
|
transport.onclose = () => {
|
|
1295
1357
|
const s = transport.sessionId ?? sid;
|
|
1358
|
+
forgetStreamLifecycle(keyH, s);
|
|
1296
1359
|
if (sessions.has(s)) {
|
|
1297
1360
|
sessions.delete(s);
|
|
1298
1361
|
logSessionLifecycle("session_deleted", s, "onclose");
|
|
@@ -1353,20 +1416,20 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1353
1416
|
}
|
|
1354
1417
|
});
|
|
1355
1418
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
send429(res,
|
|
1419
|
+
const nowTs = Date.now();
|
|
1420
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1421
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1422
|
+
if (lockout) {
|
|
1423
|
+
send429(res, lockout);
|
|
1361
1424
|
return;
|
|
1362
1425
|
}
|
|
1363
|
-
|
|
1364
|
-
if (!apiKey) {
|
|
1426
|
+
if (auth.status !== "ok") {
|
|
1365
1427
|
logRequest("GET", "auth_fail");
|
|
1366
|
-
recordAuthFailure(
|
|
1428
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1367
1429
|
send401(req, res);
|
|
1368
1430
|
return;
|
|
1369
1431
|
}
|
|
1432
|
+
const apiKey = auth.apiKey;
|
|
1370
1433
|
const sessionId = req.headers["mcp-session-id"];
|
|
1371
1434
|
if (!sessionId) {
|
|
1372
1435
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1381,7 +1444,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1381
1444
|
return;
|
|
1382
1445
|
}
|
|
1383
1446
|
try {
|
|
1384
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1447
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1385
1448
|
const entry = sessions.get(sessionId);
|
|
1386
1449
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1387
1450
|
res.status(403).json({
|
|
@@ -1400,20 +1463,20 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1400
1463
|
}
|
|
1401
1464
|
});
|
|
1402
1465
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
send429(res,
|
|
1466
|
+
const nowTs = Date.now();
|
|
1467
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1468
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1469
|
+
if (lockout) {
|
|
1470
|
+
send429(res, lockout);
|
|
1408
1471
|
return;
|
|
1409
1472
|
}
|
|
1410
|
-
|
|
1411
|
-
if (!apiKey) {
|
|
1473
|
+
if (auth.status !== "ok") {
|
|
1412
1474
|
logRequest("DELETE", "auth_fail");
|
|
1413
|
-
recordAuthFailure(
|
|
1475
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1414
1476
|
send401(req, res);
|
|
1415
1477
|
return;
|
|
1416
1478
|
}
|
|
1479
|
+
const apiKey = auth.apiKey;
|
|
1417
1480
|
const sessionId = req.headers["mcp-session-id"];
|
|
1418
1481
|
if (!sessionId) {
|
|
1419
1482
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1428,7 +1491,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1428
1491
|
return;
|
|
1429
1492
|
}
|
|
1430
1493
|
try {
|
|
1431
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1494
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1432
1495
|
const entry = sessions.get(sessionId);
|
|
1433
1496
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1434
1497
|
res.status(403).json({
|