UNPKG

mcp-wayback-machine

Version:

MCP server and CLI tool for interacting with the Wayback Machine without API keys

123 lines (121 loc) 4.15 kB
/** * Production ToolContext — wires up caching, rate limiting, credentials, * and Retry-After handling. Single unified context for all tools. */ import { cachingFetcher } from "./utils/cache.js"; import { HttpError } from "./utils/http.js"; import { waybackRateLimiter } from "./utils/rate-limit.js"; import pkg from "../package.json" with { type: "json" }; const USER_AGENT = `mcp-wayback-machine/${pkg.version}`; function readCredentials() { const accessKey = process.env.WAYBACK_ACCESS_KEY; const secretKey = process.env.WAYBACK_SECRET_KEY; if (accessKey !== undefined && secretKey !== undefined) { return { accessKey, secretKey }; } return undefined; } const credentials = readCredentials(); /** * Build headers for a request, injecting User-Agent and credentials * when applicable. */ function buildHeaders(url, overrides) { const headers = { "User-Agent": USER_AGENT, ...overrides, }; // Inject S3 auth on the SPN2 save endpoint for higher rate limits. // The URL is parsed and matched on host + pathname rather than substring // so the helper match is robust to query/fragment formatting and unusual // inputs. if (credentials !== undefined && isWaybackSaveUrl(url)) { headers.Authorization = `LOW ${credentials.accessKey}:${credentials.secretKey}`; } return headers; } /** * Strict check: is this URL a request to the web.archive.org /save endpoint? * Uses URL parsing instead of substring matching so the predicate is robust * to query/fragment formatting and unusual inputs. */ function isWaybackSaveUrl(url) { let parsed; try { parsed = new URL(url); } catch { return false; } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { return false; } if (parsed.hostname !== "web.archive.org") { return false; } return parsed.pathname === "/save" || parsed.pathname.startsWith("/save/"); } /** * Handle Retry-After from 429 responses. * Throws immediately if the response is a 429, pausing for the * retry interval before retrying (up to 3 attempts). */ async function fetchWithRetryAfter(url, options, attempt = 1) { try { return await cachingFetcher.fetch(url, options); } catch (error) { if (error instanceof HttpError && (error.status === 429 || error.status === 498) && attempt < 3) { const retryHeader = error.response; // Parse Retry-After from the response or default to 5s const retryAfter = parseRetryAfter(retryHeader) ?? 5000; await new Promise((resolve) => setTimeout(resolve, retryAfter)); return fetchWithRetryAfter(url, options, attempt + 1); } throw error; } } /** * Parse Retry-After value from response body or headers. * Accepts seconds or HTTP-date format. */ function parseRetryAfter(value) { if (value === undefined) return undefined; // Try parsing as seconds const seconds = Number(value); if (!Number.isNaN(seconds) && seconds > 0) { return seconds * 1000; } // Try parsing as HTTP date const date = Date.parse(value); if (!Number.isNaN(date)) { return Math.max(0, date - Date.now()); } return undefined; } /** * Unified production context — all tools share caching, rate limiting, and credentials */ export const context = { async fetch(url, options) { // Atomic acquire (waitForSlot + recordRequest) keeps concurrent // callers under the configured limit by eliminating the check-then-act // window between the two original calls. await waybackRateLimiter.acquire(); const headers = buildHeaders(url, options?.headers); return fetchWithRetryAfter(url, { ...options, headers, }); }, async fetchJSON(url, schema) { const response = await this.fetch(url); const text = await response.text(); const parsed = JSON.parse(text); return schema.parse(parsed); }, }; //# sourceMappingURL=contexts.js.map