@productbrain/mcp 0.0.1-beta.945 → 0.0.1-beta.9452
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-NCTCZQHK.js +13547 -0
- package/dist/chunk-NCTCZQHK.js.map +1 -0
- package/dist/chunk-Q77SFDBZ.js +1684 -0
- package/dist/chunk-Q77SFDBZ.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +425 -211
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/{setup-RYYXRDPB.js → setup-6UYW6XKO.js} +35 -26
- package/dist/setup-6UYW6XKO.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-VHL3Q5K6.js +0 -15191
- package/dist/chunk-VHL3Q5K6.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,23 @@
|
|
|
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-VHL3Q5K6.js";
|
|
4
|
+
initFeatureFlags
|
|
5
|
+
} from "./chunk-NCTCZQHK.js";
|
|
11
6
|
import {
|
|
7
|
+
DEFAULT_CLOUD_URL,
|
|
8
|
+
apiKeysUrl,
|
|
9
|
+
bootstrapHttp,
|
|
10
|
+
forgetStreamLifecycle,
|
|
11
|
+
getKeyState,
|
|
12
12
|
getPostHogClient,
|
|
13
|
+
hashKey,
|
|
13
14
|
initAnalytics,
|
|
15
|
+
runWithAuth,
|
|
14
16
|
shutdownAnalytics
|
|
15
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-Q77SFDBZ.js";
|
|
16
18
|
|
|
17
19
|
// src/http.ts
|
|
18
|
-
import { createHash, randomUUID
|
|
20
|
+
import { createHash, randomUUID } from "crypto";
|
|
19
21
|
import express from "express";
|
|
20
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
23
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -54,61 +56,252 @@ function appLogoMarkup(opts = {}) {
|
|
|
54
56
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
57
|
}
|
|
56
58
|
|
|
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
|
-
);
|
|
59
|
+
// src/sessionCap.ts
|
|
60
|
+
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
61
|
+
if (cap < 1) {
|
|
62
|
+
throw new RangeError(`cap must be >= 1, got ${cap}`);
|
|
68
63
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
const { ttlMs, graceMs } = opts;
|
|
65
|
+
const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
|
|
66
|
+
const keyEntries = [];
|
|
67
|
+
for (const [id, session] of sessions2) {
|
|
68
|
+
if (session.keyHash === keyHash) {
|
|
69
|
+
keyEntries.push([id, session]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const evictSet = /* @__PURE__ */ new Set();
|
|
73
|
+
for (const [id, session] of keyEntries) {
|
|
74
|
+
if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
|
|
75
|
+
evictSet.add(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
|
|
79
|
+
if (survivors.length >= cap) {
|
|
80
|
+
const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
|
|
81
|
+
for (const [id] of idleSurvivors) {
|
|
82
|
+
if (survivors.length < cap) break;
|
|
83
|
+
evictSet.add(id);
|
|
84
|
+
survivors = survivors.filter(([sid]) => sid !== id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const evict = Array.from(evictSet);
|
|
88
|
+
const admit = survivors.length < cap;
|
|
89
|
+
let retryAfterSeconds = 0;
|
|
90
|
+
let retryIsPoll = false;
|
|
91
|
+
if (!admit) {
|
|
92
|
+
const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
|
|
93
|
+
if (graceBlockers.length > 0) {
|
|
94
|
+
const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
|
|
95
|
+
const msRemaining = graceMs - (now - minLastAccess);
|
|
96
|
+
retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
|
|
97
|
+
} else {
|
|
98
|
+
retryAfterSeconds = 5;
|
|
99
|
+
retryIsPoll = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { evict, admit, retryAfterSeconds, retryIsPoll };
|
|
103
|
+
}
|
|
104
|
+
function countKeySessions(sessions2, keyHash) {
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const session of sessions2.values()) {
|
|
107
|
+
if (session.keyHash === keyHash) count++;
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/httpErrors.ts
|
|
113
|
+
function clampSeconds(s) {
|
|
114
|
+
return Math.max(0, Math.round(s));
|
|
115
|
+
}
|
|
116
|
+
function build(reason, message, p) {
|
|
117
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
118
|
+
return {
|
|
119
|
+
status: 429,
|
|
120
|
+
headers: { "Retry-After": String(seconds) },
|
|
121
|
+
body: {
|
|
122
|
+
jsonrpc: "2.0",
|
|
123
|
+
id: p.id ?? null,
|
|
124
|
+
error: {
|
|
125
|
+
code: -32e3,
|
|
126
|
+
message,
|
|
127
|
+
data: {
|
|
128
|
+
reason,
|
|
129
|
+
retryAfterSeconds: seconds,
|
|
130
|
+
limit: p.limit,
|
|
131
|
+
current: p.current
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
79
135
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
}
|
|
137
|
+
function buildRateLimitError(p) {
|
|
138
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
139
|
+
return build(
|
|
140
|
+
"rate_limited",
|
|
141
|
+
`Too many requests \u2014 retry after ${seconds} s.`,
|
|
142
|
+
p
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function buildAuthLockoutError(p) {
|
|
146
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
147
|
+
return build(
|
|
148
|
+
"auth_lockout",
|
|
149
|
+
`Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
|
|
150
|
+
p
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
function buildSessionCapError(p) {
|
|
154
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
155
|
+
const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
|
|
156
|
+
return build("session_cap", message, p);
|
|
157
|
+
}
|
|
158
|
+
function send429(res, built) {
|
|
159
|
+
if (res.headersSent) return;
|
|
160
|
+
for (const [k, v] of Object.entries(built.headers)) {
|
|
161
|
+
res.setHeader(k, v);
|
|
162
|
+
}
|
|
163
|
+
res.status(built.status).json(built.body);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/authLockout.ts
|
|
167
|
+
var AUTH_FAILURE_MAX = 10;
|
|
168
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
169
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
170
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
171
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
172
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
173
|
+
return { status: "absent" };
|
|
96
174
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
175
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
176
|
+
return { status: "rejected" };
|
|
177
|
+
}
|
|
178
|
+
const token = authorizationHeader.slice(7).trim();
|
|
179
|
+
if (token === "") {
|
|
180
|
+
return { status: "rejected" };
|
|
181
|
+
}
|
|
182
|
+
if (token.startsWith("pb_sk_")) {
|
|
183
|
+
return { status: "ok", apiKey: token };
|
|
184
|
+
}
|
|
185
|
+
return { status: "rejected" };
|
|
186
|
+
}
|
|
187
|
+
function checkAuthBlock(failures, ip, now) {
|
|
188
|
+
const rec = failures.get(ip);
|
|
189
|
+
if (!rec) return false;
|
|
190
|
+
return rec.blockedUntil > now;
|
|
191
|
+
}
|
|
192
|
+
function recordAuthFailure(failures, ip, now) {
|
|
193
|
+
const rec = failures.get(ip);
|
|
194
|
+
if (!rec) {
|
|
195
|
+
failures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
199
|
+
rec.count = 1;
|
|
200
|
+
rec.firstFailure = now;
|
|
201
|
+
rec.blockedUntil = 0;
|
|
202
|
+
} else {
|
|
203
|
+
rec.count++;
|
|
204
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
205
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
211
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
212
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
213
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
214
|
+
"https://chatgpt.com/connector_platform_oauth_redirect"
|
|
215
|
+
];
|
|
216
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
217
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
218
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
219
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
220
|
+
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)";
|
|
221
|
+
function exactForm(url) {
|
|
222
|
+
return `${url.origin}${url.pathname}`;
|
|
223
|
+
}
|
|
224
|
+
function parseRedirectUriAllowlist(raw) {
|
|
225
|
+
if (!raw) return { uris: [], configError: null };
|
|
226
|
+
const uris = [];
|
|
227
|
+
for (const part of raw.split(",")) {
|
|
228
|
+
const trimmed = part.trim();
|
|
229
|
+
if (!trimmed) continue;
|
|
230
|
+
let url = null;
|
|
231
|
+
try {
|
|
232
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
233
|
+
} catch {
|
|
234
|
+
url = null;
|
|
235
|
+
}
|
|
236
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
237
|
+
if (!valid || url === null) {
|
|
238
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
239
|
+
}
|
|
240
|
+
uris.push(exactForm(url));
|
|
241
|
+
}
|
|
242
|
+
return { uris, configError: null };
|
|
243
|
+
}
|
|
244
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
245
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
246
|
+
return {
|
|
247
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
248
|
+
configError: parsed.configError
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
252
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
253
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
254
|
+
return "redirect_uri is required";
|
|
255
|
+
}
|
|
256
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
257
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
258
|
+
}
|
|
259
|
+
let url;
|
|
101
260
|
try {
|
|
102
|
-
|
|
103
|
-
payload = JSON.parse(json);
|
|
261
|
+
url = new URL(redirectUri);
|
|
104
262
|
} catch {
|
|
105
|
-
return
|
|
263
|
+
return "redirect_uri is not a valid absolute URL";
|
|
264
|
+
}
|
|
265
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
266
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
267
|
+
const host = url.hostname.toLowerCase();
|
|
268
|
+
if (url.protocol === "http:") {
|
|
269
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
270
|
+
}
|
|
271
|
+
if (url.protocol === "https:") {
|
|
272
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
273
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
274
|
+
const exact = exactForm(url);
|
|
275
|
+
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)";
|
|
276
|
+
}
|
|
277
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
278
|
+
}
|
|
279
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
280
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
|
|
281
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
282
|
+
return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
|
|
106
283
|
}
|
|
107
|
-
|
|
108
|
-
|
|
284
|
+
for (const uri of redirectUris) {
|
|
285
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
286
|
+
if (reason) return reason;
|
|
109
287
|
}
|
|
110
|
-
|
|
111
|
-
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/lib/oauthAuthCode.ts
|
|
292
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
293
|
+
if (!pending) return "unknown_code";
|
|
294
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
295
|
+
if (now > pending.expiresAt) return "expired";
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
function tokenResponse(apiKey) {
|
|
299
|
+
return {
|
|
300
|
+
access_token: apiKey,
|
|
301
|
+
token_type: "Bearer",
|
|
302
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
303
|
+
expires_in: 365 * 24 * 3600
|
|
304
|
+
};
|
|
112
305
|
}
|
|
113
306
|
|
|
114
307
|
// src/http.ts
|
|
@@ -125,6 +318,7 @@ var app = express();
|
|
|
125
318
|
app.set("trust proxy", 1);
|
|
126
319
|
app.use(express.json());
|
|
127
320
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
321
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
128
322
|
app.use((_req, res, next) => {
|
|
129
323
|
const origin = _req.headers.origin;
|
|
130
324
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -135,7 +329,7 @@ app.use((_req, res, next) => {
|
|
|
135
329
|
"Access-Control-Allow-Headers",
|
|
136
330
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
137
331
|
);
|
|
138
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
332
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
139
333
|
if (_req.method === "OPTIONS") {
|
|
140
334
|
res.status(204).end();
|
|
141
335
|
return;
|
|
@@ -159,7 +353,8 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
159
353
|
token_endpoint: `${base}/oauth/token`,
|
|
160
354
|
registration_endpoint: `${base}/register`,
|
|
161
355
|
response_types_supported: ["code"],
|
|
162
|
-
grant_types_supported: ["authorization_code"
|
|
356
|
+
grant_types_supported: ["authorization_code"],
|
|
357
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
163
358
|
code_challenge_methods_supported: ["S256"],
|
|
164
359
|
token_endpoint_auth_methods_supported: ["none"],
|
|
165
360
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
@@ -187,14 +382,12 @@ app.post(
|
|
|
187
382
|
return;
|
|
188
383
|
}
|
|
189
384
|
const { redirect_uris, client_name } = req.body;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
error_description: "redirect_uris is required"
|
|
194
|
-
});
|
|
385
|
+
const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
386
|
+
if (refusal) {
|
|
387
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
195
388
|
return;
|
|
196
389
|
}
|
|
197
|
-
const clientId = `pb_client_${
|
|
390
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
198
391
|
const client = {
|
|
199
392
|
client_id: clientId,
|
|
200
393
|
redirect_uris,
|
|
@@ -213,9 +406,6 @@ app.post(
|
|
|
213
406
|
}
|
|
214
407
|
);
|
|
215
408
|
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
409
|
setInterval(() => {
|
|
220
410
|
const now = Date.now();
|
|
221
411
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -224,9 +414,6 @@ setInterval(() => {
|
|
|
224
414
|
for (const [id, client] of registeredClients) {
|
|
225
415
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
226
416
|
}
|
|
227
|
-
for (const [token, entry] of accessTokens) {
|
|
228
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
229
|
-
}
|
|
230
417
|
for (const [ip, rec] of authFailures) {
|
|
231
418
|
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
232
419
|
authFailures.delete(ip);
|
|
@@ -609,7 +796,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
609
796
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
610
797
|
<div class="err-actions">
|
|
611
798
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
612
|
-
<a href="
|
|
799
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
613
800
|
</div>`;
|
|
614
801
|
}
|
|
615
802
|
var cmdScript = `
|
|
@@ -657,7 +844,7 @@ function authorizeFormPage(params) {
|
|
|
657
844
|
<div class="hint" id="hint" hidden></div>
|
|
658
845
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
659
846
|
</form>
|
|
660
|
-
<div class="small-link"><a href="
|
|
847
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
661
848
|
</div>
|
|
662
849
|
|
|
663
850
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -796,6 +983,11 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
796
983
|
}
|
|
797
984
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
798
985
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
986
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
987
|
+
if (refusal) {
|
|
988
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
799
991
|
const cid = String(client_id ?? "");
|
|
800
992
|
const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
|
|
801
993
|
res.type("html").send(authorizeFormPage({
|
|
@@ -837,28 +1029,22 @@ app.post(
|
|
|
837
1029
|
return;
|
|
838
1030
|
}
|
|
839
1031
|
if (!client_id) {
|
|
840
|
-
res.status(400).json({
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1032
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1036
|
+
if (redirectRefusal) {
|
|
1037
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
844
1038
|
return;
|
|
845
1039
|
}
|
|
846
1040
|
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
|
-
});
|
|
1041
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
1042
|
+
res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
|
|
860
1043
|
return;
|
|
861
1044
|
}
|
|
1045
|
+
registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
|
|
1046
|
+
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
1047
|
+
`);
|
|
862
1048
|
}
|
|
863
1049
|
const client = registeredClients.get(client_id);
|
|
864
1050
|
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
@@ -875,6 +1061,7 @@ app.post(
|
|
|
875
1061
|
const candidates = [primaryUrl, ...fallbackUrls];
|
|
876
1062
|
let foundUrl;
|
|
877
1063
|
let anyDefinitiveReject = false;
|
|
1064
|
+
let rateLimitedRetryAfter;
|
|
878
1065
|
for (const url2 of candidates) {
|
|
879
1066
|
let checkData = null;
|
|
880
1067
|
try {
|
|
@@ -892,9 +1079,21 @@ app.post(
|
|
|
892
1079
|
foundUrl = checkData.deploymentUrl ?? url2;
|
|
893
1080
|
break;
|
|
894
1081
|
}
|
|
1082
|
+
if (checkData.error === "rate_limited") {
|
|
1083
|
+
rateLimitedRetryAfter = checkData.retryAfterSeconds;
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
895
1086
|
anyDefinitiveReject = true;
|
|
896
1087
|
}
|
|
897
1088
|
if (!foundUrl) {
|
|
1089
|
+
if (rateLimitedRetryAfter != null && !anyDefinitiveReject) {
|
|
1090
|
+
sendError(
|
|
1091
|
+
"Rate limited",
|
|
1092
|
+
`This API key is valid but temporarily rate limited. Try again in ${rateLimitedRetryAfter}s.`,
|
|
1093
|
+
429
|
|
1094
|
+
);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
898
1097
|
if (anyDefinitiveReject) {
|
|
899
1098
|
sendError(
|
|
900
1099
|
"Key not recognized",
|
|
@@ -910,7 +1109,7 @@ app.post(
|
|
|
910
1109
|
} catch {
|
|
911
1110
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
912
1111
|
}
|
|
913
|
-
const code =
|
|
1112
|
+
const code = randomUUID();
|
|
914
1113
|
pendingCodes.set(code, {
|
|
915
1114
|
apiKey: api_key,
|
|
916
1115
|
codeChallenge: code_challenge,
|
|
@@ -929,41 +1128,25 @@ app.post(
|
|
|
929
1128
|
}
|
|
930
1129
|
}
|
|
931
1130
|
);
|
|
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
1131
|
app.post(
|
|
943
1132
|
"/oauth/token",
|
|
944
1133
|
authLimiter,
|
|
945
1134
|
express.urlencoded({ extended: false }),
|
|
946
1135
|
express.json(),
|
|
947
1136
|
(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
|
-
}
|
|
1137
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
961
1138
|
if (grant_type !== "authorization_code") {
|
|
962
1139
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
963
1140
|
return;
|
|
964
1141
|
}
|
|
965
1142
|
const pending = pendingCodes.get(code);
|
|
966
|
-
|
|
1143
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1144
|
+
if (codeRefusal === "expired") {
|
|
1145
|
+
pendingCodes.delete(code);
|
|
1146
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (codeRefusal || !pending) {
|
|
967
1150
|
res.status(400).json({ error: "invalid_grant" });
|
|
968
1151
|
return;
|
|
969
1152
|
}
|
|
@@ -977,7 +1160,7 @@ app.post(
|
|
|
977
1160
|
return;
|
|
978
1161
|
}
|
|
979
1162
|
pendingCodes.delete(code);
|
|
980
|
-
res.json(
|
|
1163
|
+
res.json(tokenResponse(pending.apiKey));
|
|
981
1164
|
}
|
|
982
1165
|
);
|
|
983
1166
|
var mcpLimiter = rateLimit({
|
|
@@ -985,85 +1168,63 @@ var mcpLimiter = rateLimit({
|
|
|
985
1168
|
max: 120,
|
|
986
1169
|
standardHeaders: true,
|
|
987
1170
|
legacyHeaders: false,
|
|
988
|
-
|
|
1171
|
+
handler: (req, res) => {
|
|
1172
|
+
const r = req.rateLimit;
|
|
1173
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
|
|
1174
|
+
send429(res, buildRateLimitError({
|
|
1175
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
1176
|
+
limit: r?.limit ?? 120,
|
|
1177
|
+
current: r?.used ?? 0,
|
|
1178
|
+
id: req.body?.id ?? null
|
|
1179
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
1180
|
+
}));
|
|
1181
|
+
}
|
|
989
1182
|
});
|
|
990
1183
|
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
1184
|
app.get("/health", (_req, res) => {
|
|
1019
1185
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1020
1186
|
});
|
|
1021
1187
|
var sessions = /* @__PURE__ */ new Map();
|
|
1022
1188
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1023
1189
|
var MAX_SESSIONS = 200;
|
|
1024
|
-
var
|
|
1190
|
+
var EVICT_GRACE_MS = 6e4;
|
|
1191
|
+
var MAX_SESSIONS_PER_KEY = Math.max(
|
|
1192
|
+
1,
|
|
1193
|
+
Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
|
|
1194
|
+
);
|
|
1025
1195
|
function evictStaleSessions() {
|
|
1026
1196
|
const now = Date.now();
|
|
1027
1197
|
for (const [id, entry] of sessions) {
|
|
1028
1198
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
1029
|
-
logSessionLifecycle(
|
|
1199
|
+
logSessionLifecycle(
|
|
1200
|
+
"session_deleted",
|
|
1201
|
+
id,
|
|
1202
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1203
|
+
);
|
|
1030
1204
|
entry.transport.close().catch(() => {
|
|
1031
1205
|
});
|
|
1032
1206
|
sessions.delete(id);
|
|
1033
1207
|
}
|
|
1034
1208
|
}
|
|
1035
1209
|
if (sessions.size > MAX_SESSIONS) {
|
|
1036
|
-
const sorted = [...sessions.entries()].sort(
|
|
1037
|
-
|
|
1038
|
-
);
|
|
1039
|
-
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
1210
|
+
const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
1211
|
+
const overflow = sessions.size - MAX_SESSIONS;
|
|
1212
|
+
for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
|
|
1040
1213
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
1041
1214
|
sorted[i][1].transport.close().catch(() => {
|
|
1042
1215
|
});
|
|
1043
1216
|
sessions.delete(sorted[i][0]);
|
|
1044
1217
|
}
|
|
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;
|
|
1218
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
1219
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1220
|
+
process.stderr.write(
|
|
1221
|
+
`[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
|
|
1222
|
+
`
|
|
1223
|
+
);
|
|
1062
1224
|
}
|
|
1063
|
-
return entry.apiKey;
|
|
1064
1225
|
}
|
|
1065
|
-
return null;
|
|
1066
1226
|
}
|
|
1227
|
+
setInterval(evictStaleSessions, 6e4);
|
|
1067
1228
|
function send401(req, res) {
|
|
1068
1229
|
const base = baseUrl(req);
|
|
1069
1230
|
res.status(401).set(
|
|
@@ -1086,21 +1247,25 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1086
1247
|
}
|
|
1087
1248
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1088
1249
|
const reqIp = req.ip ?? "unknown";
|
|
1089
|
-
|
|
1090
|
-
|
|
1250
|
+
const nowTs = Date.now();
|
|
1251
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1252
|
+
const rec = authFailures.get(reqIp);
|
|
1253
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1254
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1091
1255
|
return;
|
|
1092
1256
|
}
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1257
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1258
|
+
if (auth.status !== "ok") {
|
|
1095
1259
|
logRequest("POST", "auth_fail");
|
|
1096
|
-
recordAuthFailure(reqIp);
|
|
1260
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1097
1261
|
send401(req, res);
|
|
1098
1262
|
return;
|
|
1099
1263
|
}
|
|
1264
|
+
const apiKey = auth.apiKey;
|
|
1100
1265
|
const sessionId = req.headers["mcp-session-id"];
|
|
1101
1266
|
const reqStart = Date.now();
|
|
1102
1267
|
try {
|
|
1103
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1268
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1104
1269
|
if (sessionId && sessions.has(sessionId)) {
|
|
1105
1270
|
const entry = sessions.get(sessionId);
|
|
1106
1271
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1111,41 +1276,82 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1111
1276
|
});
|
|
1112
1277
|
return;
|
|
1113
1278
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1279
|
+
try {
|
|
1280
|
+
entry.inFlight++;
|
|
1281
|
+
entry.lastAccess = Date.now();
|
|
1282
|
+
await entry.transport.handleRequest(req, res, req.body);
|
|
1283
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
1284
|
+
} finally {
|
|
1285
|
+
entry.inFlight--;
|
|
1286
|
+
entry.lastAccess = Date.now();
|
|
1287
|
+
}
|
|
1117
1288
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1118
1289
|
const keyH = hashKey(apiKey);
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1290
|
+
const plan = planKeyAdmission(
|
|
1291
|
+
sessions,
|
|
1292
|
+
keyH,
|
|
1293
|
+
MAX_SESSIONS_PER_KEY,
|
|
1294
|
+
Date.now(),
|
|
1295
|
+
{ ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
|
|
1296
|
+
);
|
|
1297
|
+
for (const sid2 of plan.evict) {
|
|
1298
|
+
const victim = sessions.get(sid2);
|
|
1299
|
+
sessions.delete(sid2);
|
|
1300
|
+
victim?.transport.close().catch(() => {
|
|
1128
1301
|
});
|
|
1302
|
+
logSessionLifecycle("session_deleted", sid2, "lru_evict");
|
|
1303
|
+
}
|
|
1304
|
+
if (!plan.admit) {
|
|
1305
|
+
send429(res, buildSessionCapError({
|
|
1306
|
+
retryAfterSeconds: plan.retryAfterSeconds,
|
|
1307
|
+
limit: MAX_SESSIONS_PER_KEY,
|
|
1308
|
+
current: countKeySessions(sessions, keyH),
|
|
1309
|
+
poll: plan.retryIsPoll,
|
|
1310
|
+
id: req.body?.id ?? null
|
|
1311
|
+
}));
|
|
1129
1312
|
return;
|
|
1130
1313
|
}
|
|
1314
|
+
const sid = randomUUID();
|
|
1315
|
+
let initialized = false;
|
|
1131
1316
|
const transport = new StreamableHTTPServerTransport({
|
|
1132
|
-
sessionIdGenerator: () =>
|
|
1133
|
-
onsessioninitialized: (
|
|
1134
|
-
|
|
1135
|
-
logSessionLifecycle("session_created",
|
|
1317
|
+
sessionIdGenerator: () => sid,
|
|
1318
|
+
onsessioninitialized: (s) => {
|
|
1319
|
+
initialized = true;
|
|
1320
|
+
logSessionLifecycle("session_created", s);
|
|
1136
1321
|
}
|
|
1322
|
+
// log only — no map write
|
|
1137
1323
|
});
|
|
1324
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1325
|
+
sessions.set(sid, reserved);
|
|
1138
1326
|
transport.onclose = () => {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
sessions.delete(
|
|
1327
|
+
const s = transport.sessionId ?? sid;
|
|
1328
|
+
forgetStreamLifecycle(keyH, s);
|
|
1329
|
+
if (sessions.has(s)) {
|
|
1330
|
+
sessions.delete(s);
|
|
1331
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
1143
1332
|
}
|
|
1144
1333
|
};
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1334
|
+
try {
|
|
1335
|
+
const server = createProductBrainServer();
|
|
1336
|
+
await server.connect(transport);
|
|
1337
|
+
await transport.handleRequest(req, res, req.body);
|
|
1338
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
transport.onclose = void 0;
|
|
1341
|
+
sessions.delete(sid);
|
|
1342
|
+
throw e;
|
|
1343
|
+
} finally {
|
|
1344
|
+
if (!initialized) {
|
|
1345
|
+
transport.onclose = void 0;
|
|
1346
|
+
if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
|
|
1347
|
+
} else {
|
|
1348
|
+
const e = sessions.get(sid);
|
|
1349
|
+
if (e) {
|
|
1350
|
+
e.inFlight--;
|
|
1351
|
+
e.lastAccess = Date.now();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1149
1355
|
} else if (sessionId) {
|
|
1150
1356
|
process.stderr.write(
|
|
1151
1357
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
|
|
@@ -1181,17 +1387,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1181
1387
|
});
|
|
1182
1388
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1183
1389
|
const reqIp = req.ip ?? "unknown";
|
|
1184
|
-
|
|
1185
|
-
|
|
1390
|
+
const nowTs = Date.now();
|
|
1391
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1392
|
+
const rec = authFailures.get(reqIp);
|
|
1393
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1394
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1186
1395
|
return;
|
|
1187
1396
|
}
|
|
1188
|
-
const
|
|
1189
|
-
if (
|
|
1397
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1398
|
+
if (auth.status !== "ok") {
|
|
1190
1399
|
logRequest("GET", "auth_fail");
|
|
1191
|
-
recordAuthFailure(reqIp);
|
|
1400
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1192
1401
|
send401(req, res);
|
|
1193
1402
|
return;
|
|
1194
1403
|
}
|
|
1404
|
+
const apiKey = auth.apiKey;
|
|
1195
1405
|
const sessionId = req.headers["mcp-session-id"];
|
|
1196
1406
|
if (!sessionId) {
|
|
1197
1407
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1206,7 +1416,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1206
1416
|
return;
|
|
1207
1417
|
}
|
|
1208
1418
|
try {
|
|
1209
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1419
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1210
1420
|
const entry = sessions.get(sessionId);
|
|
1211
1421
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1212
1422
|
res.status(403).json({
|
|
@@ -1226,17 +1436,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1226
1436
|
});
|
|
1227
1437
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1228
1438
|
const reqIp = req.ip ?? "unknown";
|
|
1229
|
-
|
|
1230
|
-
|
|
1439
|
+
const nowTs = Date.now();
|
|
1440
|
+
if (checkAuthBlock(authFailures, reqIp, nowTs)) {
|
|
1441
|
+
const rec = authFailures.get(reqIp);
|
|
1442
|
+
const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
|
|
1443
|
+
send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
|
|
1231
1444
|
return;
|
|
1232
1445
|
}
|
|
1233
|
-
const
|
|
1234
|
-
if (
|
|
1446
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1447
|
+
if (auth.status !== "ok") {
|
|
1235
1448
|
logRequest("DELETE", "auth_fail");
|
|
1236
|
-
recordAuthFailure(reqIp);
|
|
1449
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
|
|
1237
1450
|
send401(req, res);
|
|
1238
1451
|
return;
|
|
1239
1452
|
}
|
|
1453
|
+
const apiKey = auth.apiKey;
|
|
1240
1454
|
const sessionId = req.headers["mcp-session-id"];
|
|
1241
1455
|
if (!sessionId) {
|
|
1242
1456
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1251,7 +1465,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1251
1465
|
return;
|
|
1252
1466
|
}
|
|
1253
1467
|
try {
|
|
1254
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1468
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1255
1469
|
const entry = sessions.get(sessionId);
|
|
1256
1470
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1257
1471
|
res.status(403).json({
|