UNPKG

@upstash/context7-mcp

Version:
148 lines (147 loc) • 5.81 kB
import { createHash } from "node:crypto"; import { CONTEXT7_API_BASE_URL, SERVER_VERSION } from "./constants.js"; import { observeUpstreamRequest } from "./telemetry-runtime.js"; export const EXPIRED_OAUTH_TOKEN_ERROR = "The access token expired or is invalid"; /** Response body `error` code the Context7 API uses for a rejected `oat_` bearer. */ export const INVALID_OAUTH_TOKEN_ERROR_CODE = "invalid_oauth_token"; const AUTH_CHECK_PATH = "/v2/auth/check"; const AUTH_CHECK_TIMEOUT_MS = 5_000; const VALID_TTL_MS = 60_000; // A rejected token never becomes valid again (Clerk mints a new one on // refresh), so a long negative TTL only cuts repeated checks from clients // that keep retrying with the same token. export const INVALID_TTL_MS = 10 * 60_000; // A short negative cache keeps an outage from adding the check's latency to // every request while still retrying soon after the API recovers. const UNAVAILABLE_TTL_MS = 10_000; const MAX_CACHE_ENTRIES = 10_000; // Keyed by the SHA-256 of the token so a heap dump or a debug print of the // cache never exposes a usable credential. Map insertion order doubles as the // eviction order once the cache is full. const verdicts = new Map(); const inFlight = new Map(); export function isOpaqueOAuthToken(token) { return typeof token === "string" && token.startsWith("oat_"); } /** * `MCP_OAUTH_TOKEN_VALIDATION=off` is the rollback switch: opaque OAuth tokens * then pass through to the Context7 API exactly as before. */ export function isOpaqueOAuthTokenValidationEnabled(raw = process.env.MCP_OAUTH_TOKEN_VALIDATION) { const value = raw?.trim().toLowerCase(); return !(value === "off" || value === "false" || value === "0"); } function cacheKey(token) { return createHash("sha256").update(token).digest("hex"); } function ttlFor(verdict) { if (verdict === "valid") return VALID_TTL_MS; if (verdict === "invalid") return INVALID_TTL_MS; return UNAVAILABLE_TTL_MS; } function remember(key, verdict, now) { verdicts.delete(key); while (verdicts.size >= MAX_CACHE_ENTRIES) { const oldest = verdicts.keys().next().value; if (oldest === undefined) break; verdicts.delete(oldest); } verdicts.set(key, { verdict, expiresAt: now + ttlFor(verdict) }); } function recall(key, now) { const cached = verdicts.get(key); if (!cached) return undefined; if (cached.expiresAt <= now) { verdicts.delete(key); return undefined; } return cached.verdict; } async function readErrorCode(response) { try { const body = (await response.json()); return typeof body.error === "string" ? body.error : undefined; } catch { return undefined; } } async function requestVerdict(token) { const abortSignal = AbortSignal.timeout(AUTH_CHECK_TIMEOUT_MS); try { return await observeUpstreamRequest("auth_check", () => fetch(`${CONTEXT7_API_BASE_URL}${AUTH_CHECK_PATH}`, { headers: { Authorization: `Bearer ${token}`, "X-Context7-Source": "mcp-server", "X-Context7-Server-Version": SERVER_VERSION, "X-Context7-Transport": "http", }, signal: abortSignal, }), async (response) => { if (response.ok) return "valid"; // Only the API's explicit rejection of the OAuth token counts; any other // status (404 before the endpoint ships, 429, 5xx) fails open. if (response.status === 401) { const code = await readErrorCode(response); if (code === INVALID_OAUTH_TOKEN_ERROR_CODE) return "invalid"; } console.error(`[Context7] OAuth token check unavailable (status ${response.status})`); return "unavailable"; }, { abortSignal }); } catch (error) { const reason = error instanceof Error ? error.name : "unknown error"; console.error(`[Context7] OAuth token check failed (${reason})`); return "unavailable"; } } /** * Validate an opaque OAuth token against the Context7 API. Verdicts are cached * token hash in process memory, so each replica checks a token at most about * once a minute, and concurrent requests carrying the same token share one check. */ export async function validateOpaqueOAuthToken(token, now = Date.now()) { const key = cacheKey(token); const cached = recall(key, now); if (cached) return cached; const pending = inFlight.get(key); if (pending) return pending; // The TTL counts from when the check started, so a slow check never extends // the window during which a stale verdict is served. const check = requestVerdict(token) .then((verdict) => { remember(key, verdict, now); return verdict; }) .finally(() => { inFlight.delete(key); }); inFlight.set(key, check); return check; } /** * The Context7 API rejected the token (opaque `oat_` or Clerk OAuth JWT) on a * data request. Remember that so the next MCP request gets the 401 immediately * instead of waiting for the cached positive verdict to expire, or, for a JWT * that still verifies locally, at all. */ export function rememberInvalidOAuthToken(token, now = Date.now()) { remember(cacheKey(token), "invalid", now); } /** Whether a data request rejected this token within the last `INVALID_TTL_MS`. */ export function isOAuthTokenRememberedInvalid(token, now = Date.now()) { return recall(cacheKey(token), now) === "invalid"; } /** Test hook: drop every cached verdict and in-flight check. */ export function resetOpaqueOAuthTokenValidation() { verdicts.clear(); inFlight.clear(); }