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
JavaScript
/**
* 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