File size: 25,288 Bytes
3464008 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 | import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// @ts-expect-error β JS module, no declaration file
import { resolveBearerToContext } from '../_oauth-token.js';
// @ts-expect-error β JS module, no declaration file
import { timingSafeIncludes } from '../_crypto.js';
// @ts-expect-error β JS module, no declaration file
import { getClientIp } from '../_client-ip.js';
// @ts-expect-error β JS module, no declaration file
import { captureSilentError } from '../_sentry-edge.js';
import { redisPipeline as rawRedisPipeline } from '../_upstash-json.js';
import { resolvePlanDrivenMcpAllowance } from './quota';
import {
getBillingVerificationDenial,
getEntitlements,
isEntitlementBackendConfigured,
} from '../../server/_shared/entitlement-check';
import { checkProMcpAccess } from '../../server/_shared/pro-mcp-gate';
import type { BillingVerificationCode } from './billing-denial';
import {
buildInternalMcpHeaders,
signInternalMcpRequest,
} from '../../server/_shared/mcp-internal-hmac';
import { validateProMcpTokenOrNull } from '../../server/_shared/pro-mcp-token';
import { validateUserApiKey } from '../../server/_shared/user-api-key';
import { checkFailClosedScopedIpRateLimit } from '../../server/_shared/rate-limit';
import { rpcError, withMcpNoStore } from './rpc';
import type {
AuthResolution,
AuthResolutionRejected,
McpAuthContext,
McpHandlerDeps,
McpPreCheckResult,
} from './types';
import { emitMcpRateLimitHit } from './telemetry';
// ---------------------------------------------------------------------------
// Rate limiters
// ---------------------------------------------------------------------------
// - Legacy per-key 60/min (Starter+ env-key bearers): prefix `rl:mcp`,
// keyed `key:<apiKey>`. Unchanged from pre-U7.
// - Pro per-user 60/min: prefix `rl:mcp:pro-min`, keyed `pro-user:<userId>`.
// Independent limiter so a Pro user with two Claude installations sees
// combined 60/min across both bearers (same userId).
// ---------------------------------------------------------------------------
let mcpRatelimit: Ratelimit | null = null;
let mcpProMinRatelimit: Ratelimit | null = null;
// Anonymous MCP discovery limiter (initialize / tools/list without credentials).
// Keyed by client IP so a public discovery surface can't be hammered by an
// unauthenticated caller. Separate prefix from the authed per-key/per-user
// limiters above so anon traffic never shares a bucket with a real principal.
let mcpAnonRatelimit: Ratelimit | null = null;
function getMcpRatelimit(): Ratelimit | null {
if (mcpRatelimit) return mcpRatelimit;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
mcpRatelimit = new Ratelimit({
redis: new Redis({ url, token, retry: false }),
limiter: Ratelimit.slidingWindow(60, '60 s'),
prefix: 'rl:mcp',
analytics: false,
});
return mcpRatelimit;
}
function getMcpProMinRatelimit(): Ratelimit | null {
if (mcpProMinRatelimit) return mcpProMinRatelimit;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
mcpProMinRatelimit = new Ratelimit({
redis: new Redis({ url, token, retry: false }),
limiter: Ratelimit.slidingWindow(60, '60 s'),
prefix: 'rl:mcp:pro-min',
analytics: false,
});
return mcpProMinRatelimit;
}
function getMcpAnonRatelimit(): Ratelimit | null {
if (mcpAnonRatelimit) return mcpAnonRatelimit;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
mcpAnonRatelimit = new Ratelimit({
redis: new Redis({ url, token, retry: false }),
limiter: Ratelimit.slidingWindow(60, '60 s'),
prefix: 'rl:mcp:anon',
analytics: false,
});
return mcpAnonRatelimit;
}
/**
* Build the Authorization header set for a downstream `_execute` fetch.
*
* - env_key β `X-WorldMonitor-Key: <apiKey>` (existing, unchanged).
* - pro β `X-WM-MCP-Internal: <ts>.<sig>` + `X-WM-MCP-User-Id: <userId>`.
* Signature binds method+pathname+queryHash+bodyHash+userId.
*
* `body` MUST be the EXACT bytes the caller passes to `fetch()` so the
* signed payload matches the wire bytes. For JSON, pre-stringify on the
* caller side and pass the same string here.
*/
export async function buildAuthHeaders(
context: McpAuthContext,
method: string,
url: string,
body: BodyInit | null | undefined,
): Promise<Record<string, string>> {
if (context.kind === 'env_key' || context.kind === 'user_key') {
// user_key (#4859): the downstream REST gateway validates the raw key
// itself (Convex hash lookup + the #4611 apiAccess gate + per-account
// limits), so usage attributes to the key owner exactly like a direct
// REST call β no internal-HMAC identity smuggling needed.
return { 'X-WorldMonitor-Key': context.apiKey };
}
// context.kind === 'pro'
const secret = process.env.MCP_INTERNAL_HMAC_SECRET ?? '';
if (!secret) {
// Should never happen in production (deploy gate at U10) β surface as
// an error so the tool fetch fails fast rather than silently 401-ing
// at the gateway with a confusing "invalid_internal_mcp_signature".
throw new Error('MCP_INTERNAL_HMAC_SECRET not configured');
}
const signed = await signInternalMcpRequest({
method,
url,
body,
userId: context.userId,
secret,
});
return buildInternalMcpHeaders(signed);
}
export const PRODUCTION_DEPS: McpHandlerDeps = {
resolveBearerToContext,
// Per-request validate path uses the legacy `userId | null` wrapper β
// transient Convex blips fail-closed (401 prompts the client to retry
// via OAuth, which is the correct safety direction here). The refresh-
// grant path in api/oauth/token.ts uses the discriminated-union form
// to distinguish revoked from transient (F3 of the U7+U8 review pass).
validateProMcpToken: validateProMcpTokenOrNull,
getEntitlements,
validateUserApiKey,
guardUserApiKeyValidation: (request, corsHeaders) => checkFailClosedScopedIpRateLimit(
request,
'mcp:user-api-key:pre-auth-validation',
60,
'60 s',
corsHeaders,
),
redisPipeline: rawRedisPipeline,
};
// ---------------------------------------------------------------------------
// Auth + Pro-pre-check helpers (extracted from mcpHandler so the top-level
// handler stays under the cognitive-complexity threshold).
// ---------------------------------------------------------------------------
export function wwwAuthHeader(resourceMetadataUrl: string, errorParam = ''): string {
const errSegment = errorParam ? `, error="${errorParam}"` : '';
return `Bearer realm="worldmonitor"${errSegment}, resource_metadata="${resourceMetadataUrl}"`;
}
function userKeyValidationBackpressureResponse(response: Response, corsHeaders: Record<string, string>): Response {
const limited = response.status === 429;
return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: null,
error: {
code: limited ? -32029 : -32603,
message: limited ? 'Too many requests' : 'Auth service temporarily unavailable. Try again.',
},
}),
{
status: response.status,
headers: withMcpNoStore({
...Object.fromEntries(response.headers.entries()),
...corsHeaders,
'Content-Type': 'application/json',
}),
},
);
}
export function getMcpBillingVerificationDenial(
entitlements: {
billingStatus?: BillingVerificationCode;
retryAfterSeconds?: number;
// Transient entitlement-lookup failure marker from getEntitlements()
// (server/_shared/entitlement-check.ts) β mapped to the same retryable
// envelope as a gateway-synthesized entitlement_verification_unavailable.
verificationUnavailable?: boolean;
} | null | undefined,
corsHeaders: Record<string, string>,
id: unknown = null,
): Response | null {
const billingStatus = entitlements?.verificationUnavailable
? 'entitlement_verification_unavailable'
: entitlements?.billingStatus;
if (billingStatus === 'entitlement_verification_unavailable') {
// Gateway-synthesized backend-unreachable 503 (server/gateway.ts wm_-key
// branch). The shared Convex-facing helper doesn't recognize this code, so
// build the same retryable envelope here; clamp mirrors the shared helper.
const raw = entitlements?.retryAfterSeconds;
const retryAfter = Number.isFinite(raw)
? Math.max(1, Math.min(60, Math.ceil(raw as number)))
: 5;
return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: id ?? null,
error: {
code: -32603,
message: 'Unable to verify API access. Retry shortly.',
data: { code: billingStatus },
},
}),
{
status: 503,
headers: new Headers({
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': String(retryAfter),
'X-Billing-Verification': billingStatus,
}),
},
);
}
// The shared helper owns status, retry normalization, no-store, and billing
// headers. Its parameter asks only for the billing fields, so both the
// McpHandlerDeps entitlement shape and dispatch's synthesized
// BillingDenialError shape are directly assignable.
const denial = getBillingVerificationDenial(
billingStatus ? { billingStatus, retryAfterSeconds: entitlements?.retryAfterSeconds } : null,
corsHeaders,
);
if (!denial || !billingStatus) return null;
const retryable = denial.status === 503;
const message = {
subscription_lapsed: 'Subscription lapsed. Re-authenticating will not help β resubscribe to restore access.',
renewal_verification_pending: 'Renewal verification pending. Retry shortly.',
renewal_verification_failed: 'Renewal verification failed. Retry shortly.',
}[billingStatus];
const headers = new Headers(denial.headers);
headers.set('Cache-Control', 'no-store');
headers.set('Content-Type', 'application/json');
return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: id ?? null,
error: {
// -32002 is the confirmed-lapse code (HTTP 403, no WWW-Authenticate).
// -32001 stays reserved for authentication failures at HTTP 401 per
// docs/mcp-error-catalog.mdx β reusing it here sent doc-following
// agents into a pointless OAuth re-auth loop.
code: retryable ? -32603 : -32002,
message,
data: { code: billingStatus },
},
}),
{ status: denial.status, headers },
);
}
export async function resolveAuthContext(
req: Request,
deps: McpHandlerDeps,
resourceMetadataUrl: string,
corsHeaders: Record<string, string>,
): Promise<AuthResolution | AuthResolutionRejected> {
const authHeader = req.headers.get('Authorization') ?? '';
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
let context: McpAuthContext | null;
try {
context = await deps.resolveBearerToContext(token);
} catch {
return {
ok: false,
response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }),
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
),
};
}
if (!context) {
return {
ok: false,
response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired OAuth token. Re-authenticate via /oauth/token.' } }),
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
),
};
}
return { ok: true, context };
}
const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? '';
if (!candidateKey) {
return {
ok: false,
response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Authentication required. Use OAuth (/oauth/token) or pass your API key via X-WorldMonitor-Key header.' } }),
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl), ...corsHeaders }) },
),
};
}
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
if (await timingSafeIncludes(candidateKey, validKeys)) {
return { ok: true, context: { kind: 'env_key', apiKey: candidateKey } };
}
// #4859: customer-issued dashboard keys (Convex userApiKeys). The env
// allowlist above holds only legacy operator keys; every key a user mints
// in the dashboard lives in Convex β before this fallback, ALL of them got
// "Invalid API key" here while the same keys worked on the REST gateway.
// Identity resolution only: the owner's mcpAccess entitlement is enforced
// at the gated-method pre-check (runUserKeyPreChecks), symmetric with the
// pro path, so a lapsed owner can still list tools but never call them.
if (candidateKey.startsWith('wm_')) {
let userKey: { userId: string } | null = null;
try {
// Identity is not known until after this Convex-backed lookup, so the
// normal per-user MCP limit cannot protect it. Bound rotating unknown
// wm_ guesses by client IP first; otherwise each unique key evades the
// per-hash negative cache and reaches the auth backend.
const validationGuardResponse = await deps.guardUserApiKeyValidation(req, corsHeaders);
if (validationGuardResponse) {
return {
ok: false,
response: userKeyValidationBackpressureResponse(validationGuardResponse, corsHeaders),
};
}
userKey = await deps.validateUserApiKey(candidateKey);
} catch {
// validateUserApiKey throws UserApiKeyUnavailableError when Convex is
// unreachable/misconfigured β 503 mirrors the bearer path (not 401).
return {
ok: false,
response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }),
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
),
};
}
if (userKey) {
return { ok: true, context: { kind: 'user_key', apiKey: candidateKey, userId: userKey.userId } };
}
}
return {
ok: false,
response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid API key' } }),
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
),
};
}
/**
* Pro-only pre-checks: validate Convex row + cross-user-binding + entitlement
* re-check. On success the result also carries the plan's daily MCP allowance
* (plan 2026-07-25-001 U3) β this is the one place on the gated path that has
* the entitlement object in hand, so resolving it here spares the dispatcher a
* second Convex round-trip.
*/
export async function runProPreChecks(
context: Extract<McpAuthContext, { kind: 'pro' }>,
deps: McpHandlerDeps,
resourceMetadataUrl: string,
corsHeaders: Record<string, string>,
ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<McpPreCheckResult> {
// F12: Pro path is unusable without MCP_INTERNAL_HMAC_SECRET β every
// tool fetch will throw inside buildAuthHeaders. Surface the misconfig
// at auth-resolution time so operators see a single clear 503 rather
// than a confusing mid-tool-fetch -32603. Belt-and-suspenders with the
// U10 deploy gate; matches the runtime check in `buildAuthHeaders`.
if (!process.env.MCP_INTERNAL_HMAC_SECRET) {
captureSilentError(new Error('MCP_INTERNAL_HMAC_SECRET unset'), {
tags: { route: 'api/mcp', step: 'pro-secret-preflight' },
ctx,
});
return { ok: false, response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Service temporarily unavailable, retry in a moment.' } }),
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
) };
}
// #4860: this await was the only unguarded step on the gated path β the
// wired helper never rejects today, but a rejection here previously escaped
// mcpHandler (no top-level catch) as a raw 500 with zero Sentry. Fail
// closed with the same retryable 503 shape as the bearer-resolve catch.
let validation: Awaited<ReturnType<typeof deps.validateProMcpToken>> = null;
try {
validation = await deps.validateProMcpToken(context.mcpTokenId);
} catch (err) {
captureSilentError(err, { tags: { route: 'api/mcp', step: 'pro-token-validate' }, ctx });
return { ok: false, response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Service temporarily unavailable, retry in a moment.' } }),
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
) };
}
if (!validation || validation.userId !== context.userId) {
return { ok: false, response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'MCP authorization revoked. Re-authorize at https://worldmonitor.app/mcp-grant.' } }),
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
) };
}
return checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'pro-entitlement-recheck', ctx);
}
/**
* Shared mcpAccess entitlement gate for identity-resolved contexts (pro AND
* user_key). Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
* Passes when the owner has an active tier>=1 + mcpAccess entitlement; rejects
* with a 401 Response otherwise.
*
* A passing result also reports `mcpDailyLimit`, read straight off the
* entitlement this call already fetched β but only for plan-driven plan
* families (`resolvePlanDrivenMcpAllowance`): API-tier subscribers reach this
* gate through the same OAuth door, and their catalog allowance must not
* out-rank the 50/day their `user_key` is capped at. A row with no
* `planLimits` (legacy shape) or a non-plan-driven plan reports `undefined`,
* which the quota layer resolves to the plan default β the entitlement is
* NOT re-fetched to fill the gap.
*/
async function checkMcpEntitlementGate(
userId: string,
deps: McpHandlerDeps,
resourceMetadataUrl: string,
corsHeaders: Record<string, string>,
sentryStep: string,
ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<McpPreCheckResult> {
const rejected = (): McpPreCheckResult => ({ ok: false, response: new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Subscription not active.' } }),
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
) });
let ent: Awaited<ReturnType<typeof deps.getEntitlements>> = null;
try {
ent = await deps.getEntitlements(userId);
} catch (err) {
captureSilentError(err, { tags: { route: 'api/mcp', step: sentryStep }, ctx });
return rejected();
}
const passed = (): McpPreCheckResult => ({
ok: true,
mcpDailyLimit: resolvePlanDrivenMcpAllowance(ent?.planKey, ent?.features?.planLimits?.mcpCallsPerDay),
});
// Single-source Pro MCP decision. A current fallback entitlement still wins
// over billing uncertainty; this caller keeps the JSON-RPC denial rendering.
const gate = checkProMcpAccess(ent, Date.now(), {
backendConfigured: isEntitlementBackendConfigured(),
});
if (!gate) {
return passed();
}
const billingDenial = getMcpBillingVerificationDenial(ent, corsHeaders);
if (billingDenial) return { ok: false, response: billingDenial };
return rejected();
}
/**
* user_key (#4859) pre-check: the key row proved identity at auth-resolution
* time; data methods must additionally verify the OWNER still has an active
* mcpAccess entitlement. Without this, a user_key context would be the one
* credential class that skips the entitlement gate (env_key is operator-owned
* and intentionally ungated; pro re-checks on every gated call).
*/
export async function runUserKeyPreChecks(
context: Extract<McpAuthContext, { kind: 'user_key' }>,
deps: McpHandlerDeps,
resourceMetadataUrl: string,
corsHeaders: Record<string, string>,
ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<McpPreCheckResult> {
const gate = await checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'user-key-entitlement', ctx);
// KTD6: the entitlement verdict applies, the plan's MCP allowance does NOT.
// user_key callers stay on the hardcoded daily cap whatever their API plan
// advertises β raising API-tier MCP allowances is a deliberate follow-up, and
// dropping the limit HERE (rather than guarding at the metering site) keeps
// one place to change when that follow-up lands.
return gate.ok ? { ok: true } : gate;
}
/**
* Kind-dispatched pre-checks for gated (data/quota) methods. env_key needs
* none; pro and user_key each run their own. Single entry point so a future
* context kind can't silently ship without deciding its gate (the tracer
* finding on #4859: mapping user keys onto env_key would have bypassed
* entitlements entirely).
*/
export async function runContextPreChecks(
context: McpAuthContext,
deps: McpHandlerDeps,
resourceMetadataUrl: string,
corsHeaders: Record<string, string>,
ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<McpPreCheckResult> {
if (context.kind === 'pro') {
return runProPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
}
if (context.kind === 'user_key') {
return runUserKeyPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
}
// env_key: operator-owned, ungated, and never metered by the daily counter.
return { ok: true };
}
/** Per-minute rate limit. Both paths fail-OPEN on Upstash error (graceful);
* the daily quota is the hard-cap fail-CLOSED gate. Returns null on success
* or pass-through, a Response on a real 60/min limit hit.
* user_key (#4859) shares the per-USER limiter with pro β the principal is
* the key OWNER, so a user with an OAuth connection and a dashboard key gets
* one combined 60/min budget instead of two stackable ones. */
export async function applyPerMinuteLimit(context: McpAuthContext, headers: Record<string, string> = {}): Promise<Response | null> {
if (context.kind === 'env_key') {
const rl = getMcpRatelimit();
if (!rl) return null;
try {
const { success } = await rl.limit(`key:${context.apiKey}`);
if (!success) {
emitMcpRateLimitHit(context, {
dimension: 'mcp_minute_burst',
limit: 60,
windowSeconds: 60,
});
return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.', headers);
}
} catch { /* graceful degradation */ }
return null;
}
const rl = getMcpProMinRatelimit();
if (!rl) return null;
try {
const { success } = await rl.limit(`pro-user:${context.userId}`);
if (!success) {
emitMcpRateLimitHit(context, {
dimension: 'mcp_minute_burst',
limit: 60,
windowSeconds: 60,
});
return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per user.', headers);
}
} catch { /* graceful degradation */ }
return null;
}
/** Per-IP rate limit for the UNAUTHENTICATED discovery path (initialize /
* tools/list without credentials β the metadata surface agent scanners probe).
* Keyed on the trusted client IP (cf-connecting-ip / x-real-ip; falls back to a
* shared bucket so x-forwarded-for spoofing can't rotate identities). Fail-OPEN
* on Upstash error, matching `applyPerMinuteLimit` β the discovery response is a
* cheap in-memory payload, so availability beats strict enforcement here.
* Returns null on success/skip, a Response on a real 60/min limit hit. */
export async function applyAnonDiscoveryLimit(req: Request, headers: Record<string, string> = {}): Promise<Response | null> {
const rl = getMcpAnonRatelimit();
if (!rl) return null;
try {
const { success } = await rl.limit(`ip:${getClientIp(req)}`);
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 unauthenticated discovery requests per minute per IP.', headers);
} catch { /* graceful degradation */ }
return null;
}
|