archive-org-mcp 1.0.0 → 1.0.2

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 CHANGED
@@ -23,3 +23,21 @@ node dist/index.js
23
23
  ## Source
24
24
 
25
25
  Public free APIs only. See `src/api.ts` for exact endpoints.
26
+
27
+ <!-- paywall -->
28
+ ## Premium tools
29
+
30
+ These tools need a license key:
31
+
32
+ * `snapshot_timeline`
33
+
34
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-archive-org-mcp and set it in your MCP client config:
35
+
36
+ ```json
37
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
38
+ ```
39
+
40
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
41
+ working offline once one check has succeeded. Every other tool on this server
42
+ stays free.
43
+ <!-- /paywall -->
package/dist/api.d.ts CHANGED
@@ -120,3 +120,16 @@ export declare const m1_WaybackError: {
120
120
  };
121
121
  export declare const m1_getSnapshotText: (timestamp: string, originalUrl: string, maxChars?: number) => Promise<string>;
122
122
  export declare const m1_getSnapshots: (url: string, limit?: number) => Promise<m1_Snapshot[]>;
123
+ export interface SnapshotTimelineArgs {
124
+ url: string;
125
+ from?: number;
126
+ to?: number;
127
+ limit?: number;
128
+ }
129
+ export declare function snapshotTimeline(args: SnapshotTimelineArgs): Promise<string>;
130
+ export interface SearchProfileArgs {
131
+ query: string;
132
+ mediatype?: string;
133
+ limit?: number;
134
+ }
135
+ export declare function searchProfile(args: SearchProfileArgs): Promise<string>;
package/dist/api.js CHANGED
@@ -181,3 +181,213 @@ export const m1_getAvailability = m1.getAvailability;
181
181
  export const m1_WaybackError = m1.WaybackError;
182
182
  export const m1_getSnapshotText = m1.getSnapshotText;
183
183
  export const m1_getSnapshots = m1.getSnapshots;
184
+ // ---- muxE tools:
185
+ // The five existing tools either print a short list of Archive items or list the
186
+ // newest few Wayback snapshots. The new ones answer two questions neither asks:
187
+ // what a search term actually looks like across the catalogue, and how one URL's
188
+ // archived history is distributed over time rather than merely listed.
189
+ const CDX_TIMELINE = "https://web.archive.org/cdx/search/cdx";
190
+ const ADVANCED = "https://archive.org/advancedsearch.php";
191
+ const IA_UA = "archive-org-mcp/1.0";
192
+ /**
193
+ * Fetches and returns the body unparsed. The two APIs here differ: advancedsearch
194
+ * always answers JSON and reports its own problems in an `error` field, while the
195
+ * CDX API occasionally answers a non JSON body of its own, so the caller reports
196
+ * what actually came back instead of the fetch layer assuming JSON.
197
+ */
198
+ async function iaText(url, seconds) {
199
+ let res;
200
+ try {
201
+ res = await fetch(url, { headers: { "User-Agent": IA_UA }, signal: AbortSignal.timeout(seconds * 1000) });
202
+ }
203
+ catch (e) {
204
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
205
+ throw new ArchiveError(`The Internet Archive did not answer within ${seconds} seconds on this route; its index query is slow for a very widely captured URL, so narrow the year range or lower the limit.`);
206
+ }
207
+ throw new ArchiveError(`The Internet Archive could not be reached: ${e instanceof Error ? e.message : String(e)}`);
208
+ }
209
+ if (!res.ok)
210
+ throw new ArchiveError(`Internet Archive error ${res.status}`);
211
+ const body = await res.text();
212
+ if (body.trim()) {
213
+ try {
214
+ const parsed = JSON.parse(body);
215
+ if (parsed && typeof parsed.error === "string") {
216
+ throw new ArchiveError(`The Internet Archive said: ${parsed.error}`);
217
+ }
218
+ }
219
+ catch (e) {
220
+ if (e instanceof ArchiveError)
221
+ throw e;
222
+ // not JSON, which is the caller's business to report
223
+ }
224
+ }
225
+ return { text: body, contentType: res.headers.get("content-type") ?? "not reported" };
226
+ }
227
+ async function iaJson(url, seconds) {
228
+ const body = await iaText(url, seconds);
229
+ try {
230
+ return JSON.parse(body.text);
231
+ }
232
+ catch {
233
+ throw new ArchiveError(`The Internet Archive answered ${body.contentType} rather than JSON on this route.`);
234
+ }
235
+ }
236
+ const cdxStamp = (ts) => /^\d{14}$/.test(ts) ? `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(8, 10)}:${ts.slice(10, 12)} UTC` : ts;
237
+ const yearOf = (ts) => (/^\d{14}$/.test(ts) ? ts.slice(0, 4) : "unknown year");
238
+ export async function snapshotTimeline(args) {
239
+ const asked = (args?.url ?? "").trim();
240
+ if (!asked)
241
+ throw new ArchiveError("Provide the URL whose archived history you want.");
242
+ // the existing snapshot tools take a bare host like example.com/page.html, so
243
+ // accept that shape too rather than being stricter than the rest of the server
244
+ const target = /^https?:\/\//i.test(asked) ? asked : `https://${asked}`;
245
+ if (!/^https?:\/\/[^/\s]+/.test(target))
246
+ throw new ArchiveError("Provide a URL such as example.com/page.html.");
247
+ const yearCheck = (value, label) => {
248
+ if (value === undefined)
249
+ return undefined;
250
+ const n = Math.floor(Number(value));
251
+ if (!Number.isInteger(n) || n < 1996 || n > 2100)
252
+ throw new ArchiveError(`${label} must be a whole year between 1996 and 2100.`);
253
+ return n;
254
+ };
255
+ const from = yearCheck(args?.from, "from");
256
+ const to = yearCheck(args?.to, "to");
257
+ if (from !== undefined && to !== undefined && from > to)
258
+ throw new ArchiveError("from must not be after to.");
259
+ const limit = Math.max(1, Math.min(Math.floor(Number(args?.limit ?? 500) || 500), 3000));
260
+ const params = new URLSearchParams({
261
+ url: target,
262
+ output: "json",
263
+ fl: "timestamp,statuscode,mimetype,digest,length",
264
+ filter: "statuscode:200",
265
+ limit: String(limit),
266
+ });
267
+ if (from !== undefined)
268
+ params.set("from", String(from));
269
+ if (to !== undefined)
270
+ params.set("to", String(to));
271
+ const raw = (await iaJson(`${CDX_TIMELINE}?${params.toString()}`, 45));
272
+ if (!Array.isArray(raw) || raw.length <= 1) {
273
+ const window = from !== undefined || to !== undefined ? ` between ${from ?? "the start"} and ${to ?? "now"}` : "";
274
+ return `The Wayback Machine holds no successful snapshot of ${target}${window}.`;
275
+ }
276
+ // the CDX API returns a header row, and the column order it uses is not
277
+ // guaranteed to be the order asked for, so the columns are read by name
278
+ const header = (raw[0] ?? []).map((h) => String(h).toLowerCase());
279
+ const at = (row, name) => {
280
+ const i = header.indexOf(name);
281
+ return i >= 0 ? (row[i] ?? "") : "";
282
+ };
283
+ const rows = raw.slice(1).map((r) => ({
284
+ timestamp: at(r, "timestamp"),
285
+ original: at(r, "original") || target,
286
+ statusCode: at(r, "statuscode"),
287
+ mimeType: at(r, "mimetype"),
288
+ digest: at(r, "digest"),
289
+ length: at(r, "length"),
290
+ }));
291
+ if (!rows.some((r) => r.timestamp)) {
292
+ throw new ArchiveError("The Wayback Machine returned rows without a readable timestamp column.");
293
+ }
294
+ const stamps = rows.map((r) => r.timestamp).filter((t) => /^\d{14}$/.test(t)).sort();
295
+ const digests = new Set(rows.map((r) => r.digest).filter(Boolean));
296
+ const types = new Map();
297
+ const years = new Map();
298
+ let bytes = 0;
299
+ let counted = 0;
300
+ for (const r of rows) {
301
+ if (r.mimeType)
302
+ types.set(r.mimeType, (types.get(r.mimeType) ?? 0) + 1);
303
+ years.set(yearOf(r.timestamp), (years.get(yearOf(r.timestamp)) ?? 0) + 1);
304
+ const size = Number(r.length);
305
+ if (Number.isFinite(size) && size > 0) {
306
+ bytes += size;
307
+ counted += 1;
308
+ }
309
+ }
310
+ const lines = [
311
+ `Archived history of ${target}${asked !== target ? ` (asked for ${asked})` : ""}${from !== undefined || to !== undefined ? `, ${from ?? 1996} to ${to ?? "the present"}` : ""}:`,
312
+ ` successful snapshots returned: ${rows.length}${rows.length >= limit ? ` (the request limit was ${limit}, so there may be more)` : ""}`,
313
+ ` distinct content digests: ${digests.size}, so ${rows.length - digests.size} capture(s) repeat a page already archived`,
314
+ ];
315
+ if (stamps.length) {
316
+ lines.push(` first snapshot: ${cdxStamp(stamps[0])}`, ` last snapshot: ${cdxStamp(stamps[stamps.length - 1])}`);
317
+ }
318
+ if (counted) {
319
+ lines.push(` payload captured: ${bytes.toLocaleString("en-US")} bytes across ${counted} snapshot(s) that report a length`);
320
+ }
321
+ if (types.size) {
322
+ lines.push(" media types captured:");
323
+ for (const [k, n] of [...types.entries()].sort((a, b) => b[1] - a[1]))
324
+ lines.push(` ${k}: ${n}`);
325
+ }
326
+ if (years.size) {
327
+ const sortedYears = [...years.entries()].sort((a, b) => a[0].localeCompare(b[0]));
328
+ lines.push(` captures per year across ${sortedYears.length} year(s):`);
329
+ for (const [y, n] of sortedYears.slice(0, 25))
330
+ lines.push(` ${y}: ${n}`);
331
+ if (sortedYears.length > 25)
332
+ lines.push(` and ${sortedYears.length - 25} more year(s) not listed here`);
333
+ }
334
+ lines.push(" the existing snapshot tool lists the newest captures one by one; this is the distribution behind that list.");
335
+ return lines.join("\n");
336
+ }
337
+ export async function searchProfile(args) {
338
+ const query = (args?.query ?? "").trim();
339
+ if (!query)
340
+ throw new ArchiveError("Provide search terms.");
341
+ const mediatype = (args?.mediatype ?? "").trim().toLowerCase();
342
+ if (mediatype && !/^[a-z]+$/.test(mediatype))
343
+ throw new ArchiveError("A media type is a single word like software, audio, movies or texts.");
344
+ const limit = Math.max(10, Math.min(Math.floor(Number(args?.limit ?? 200) || 200), 500));
345
+ const q = mediatype ? `(${query}) AND mediatype:${mediatype}` : query;
346
+ const fields = ["identifier", "year", "mediatype", "downloads", "collection", "item_size"].map((f) => `fl[]=${f}`).join("&");
347
+ const url = `${ADVANCED}?q=${encodeURIComponent(q)}&${fields}&rows=${limit}&page=1&output=json`;
348
+ const parsed = (await iaJson(url, 60));
349
+ const docs = parsed.response?.docs ?? [];
350
+ const numFound = typeof parsed.response?.numFound === "number" ? parsed.response.numFound : docs.length;
351
+ if (!docs.length)
352
+ return `The Internet Archive holds nothing matching "${query}"${mediatype ? ` in ${mediatype}` : ""}.`;
353
+ const types = new Map();
354
+ const years = new Map();
355
+ const collections = new Map();
356
+ const downloads = [];
357
+ let withYear = 0;
358
+ for (const d of docs) {
359
+ const type = typeof d.mediatype === "string" ? d.mediatype : "not reported";
360
+ types.set(type, (types.get(type) ?? 0) + 1);
361
+ const year = d.year != null ? String(d.year).slice(0, 4) : "not reported";
362
+ years.set(year, (years.get(year) ?? 0) + 1);
363
+ if (d.year != null)
364
+ withYear += 1;
365
+ for (const c of Array.isArray(d.collection) ? d.collection : [])
366
+ collections.set(String(c), (collections.get(String(c)) ?? 0) + 1);
367
+ if (typeof d.downloads === "number" && Number.isFinite(d.downloads))
368
+ downloads.push(d.downloads);
369
+ }
370
+ const top = (m, n) => [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n).map(([k, v]) => `${k} (${v})`);
371
+ const lines = [
372
+ `What the Internet Archive holds for "${query}"${mediatype ? ` in ${mediatype}` : ""}:`,
373
+ ` items in the catalogue: ${numFound.toLocaleString("en-US")}`,
374
+ ` items read for this profile: ${docs.length}${numFound > docs.length ? `, so every breakdown below is a sample of the first ${docs.length} and not of all ${numFound.toLocaleString("en-US")}` : ""}`,
375
+ ];
376
+ lines.push(` media types in the sample: ${top(types, 10).join(", ") || "none reported"}`);
377
+ lines.push(` items with no year recorded: ${docs.length - withYear} of ${docs.length}`);
378
+ const knownYears = [...years.entries()].filter(([y]) => y !== "not reported").sort((a, b) => b[1] - a[1]);
379
+ lines.push(` most common years in the sample: ${knownYears.slice(0, 12).map(([y, n]) => `${y} (${n})`).join(", ") || "none reported"}`);
380
+ lines.push(` distinct collections touched: ${collections.size}`);
381
+ lines.push(` ${top(collections, 10).join(", ") || "none reported"}`);
382
+ if (downloads.length) {
383
+ const sorted = [...downloads].sort((a, b) => a - b);
384
+ const total = downloads.reduce((a, b) => a + b, 0);
385
+ const median = sorted.length % 2 ? sorted[Math.floor(sorted.length / 2)] : Math.round((sorted[sorted.length - 1] + sorted[sorted.length]) / 2);
386
+ lines.push(` downloads across the ${downloads.length} item(s) that report it: total ${total.toLocaleString("en-US")}, median ${median.toLocaleString("en-US")}, highest ${sorted[sorted.length - 1].toLocaleString("en-US")}, lowest ${sorted[0].toLocaleString("en-US")}`);
387
+ }
388
+ else {
389
+ lines.push(" no item in the sample reports a download count.");
390
+ }
391
+ lines.push(" every figure above is tallied from the rows the search returned, and the sample size is stated rather than implied.");
392
+ return lines.join("\n");
393
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns null when the caller may run the tool, or a short message telling
3
+ * them how to get a key.
4
+ *
5
+ * A good key is remembered for an hour, so a busy session does not hit the
6
+ * license server on every call and keeps working through a brief outage.
7
+ */
8
+ export declare function premiumRequired(tool: string): Promise<string | null>;
@@ -0,0 +1,78 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "archive-org-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "snapshot_timeline",
8
+ ]);
9
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
10
+ // The license check calls the marketplace's verify endpoint directly instead of
11
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
12
+ // check it makes is rejected by Supabase before it reaches the function. The
13
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
14
+ // own JavaScript and only ever reaches their licence check.
15
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
16
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
17
+ const OK_CACHE_MS = 60 * 60 * 1000;
18
+ const BAD_CACHE_MS = 60 * 1000;
19
+ const seen = new Map();
20
+ const REASONS = {
21
+ missing_key: "no key is set",
22
+ invalid_format: "that key is not a valid MCP Marketplace key",
23
+ not_found: "that key was not found",
24
+ revoked: "that key has been revoked",
25
+ rotated: "that key was rotated, use your new one",
26
+ expired: "that key has expired, renew it",
27
+ rate_limited: "there were too many checks just now, try again shortly",
28
+ network_error: "the license server could not be reached",
29
+ };
30
+ function blocked(tool, reason) {
31
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
32
+ return `"${tool}" needs a license key, but ${why}. ` +
33
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
34
+ }
35
+ async function verify(key) {
36
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
37
+ method: "POST",
38
+ headers: {
39
+ apikey: PUBLISHABLE_KEY,
40
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
41
+ "Content-Type": "application/json",
42
+ },
43
+ body: JSON.stringify({ key, slug: SLUG }),
44
+ // an unusable key answers 400 with a JSON body, so read the body either way
45
+ signal: AbortSignal.timeout(15000),
46
+ });
47
+ const data = (await res.json());
48
+ if (typeof data?.valid !== "boolean")
49
+ return { valid: false, reason: "unexpected_response" };
50
+ return data;
51
+ }
52
+ /**
53
+ * Returns null when the caller may run the tool, or a short message telling
54
+ * them how to get a key.
55
+ *
56
+ * A good key is remembered for an hour, so a busy session does not hit the
57
+ * license server on every call and keeps working through a brief outage.
58
+ */
59
+ export async function premiumRequired(tool) {
60
+ if (!PREMIUM.has(tool))
61
+ return null;
62
+ const key = process.env.MCP_LICENSE_KEY;
63
+ if (!key)
64
+ return blocked(tool, "missing_key");
65
+ const hit = seen.get(key);
66
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
67
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
68
+ }
69
+ let result;
70
+ try {
71
+ result = await verify(key);
72
+ }
73
+ catch {
74
+ result = { valid: false, reason: "network_error" };
75
+ }
76
+ seen.set(key, { ...result, at: Date.now() });
77
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
78
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText } from './api.js';
3
+ import { m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText, snapshotTimeline, searchProfile } from './api.js';
4
+ import { premiumRequired } from "./license.js";
4
5
  const text = (value) => ({ content: [{ type: 'text', text: value }] });
5
6
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -108,5 +109,45 @@ export function createServer() {
108
109
  return textError(errorMessage(e));
109
110
  }
110
111
  });
112
+ server.registerTool("snapshot_timeline", {
113
+ title: "Snapshot timeline",
114
+ description: "The shape of one URL's archived history rather than a list of it: how many successful snapshots exist in a year range, how many distinct content digests they hold, how many captures just repeat a page already archived, the media types captured, the payload size and captures per year. The get_snapshots tool returns the newest few with no digest, no date range and no totals.",
115
+ inputSchema: z.object({
116
+ url: z.string().describe("Full URL, e.g. 'example.com/page.html'"),
117
+ from: z.number().int().min(1996).max(2100).describe("Earliest year to include.").optional(),
118
+ to: z.number().int().min(1996).max(2100).describe("Latest year to include.").optional(),
119
+ limit: z.number().int().min(1).max(3000).default(500).describe("How many snapshots to read, up to 3000."),
120
+ }),
121
+ annotations: READ_ONLY,
122
+ }, async (args) => {
123
+ // ---- paywall: snapshot_timeline ----
124
+ const paywallMessage = await premiumRequired("snapshot_timeline");
125
+ if (paywallMessage)
126
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
127
+ // ---- paywall: end ----
128
+ try {
129
+ return text(await snapshotTimeline(args));
130
+ }
131
+ catch (e) {
132
+ return textError(errorMessage(e));
133
+ }
134
+ });
135
+ server.registerTool("search_profile", {
136
+ title: "Search profile",
137
+ description: "What a search term looks like across the Internet Archive rather than eight rows of it: the catalogue total, the media type split, how many items carry no year, the most common years, the collections involved and the download distribution, with the sample size stated. The search_items tool only prints a short list.",
138
+ inputSchema: z.object({
139
+ query: z.string().describe("Search terms, e.g. 'Windows 95'"),
140
+ mediatype: z.string().describe("Restrict to one media type, e.g. software or audio.").optional(),
141
+ limit: z.number().int().min(10).max(500).default(200).describe("How many items to read for the profile, 10 to 500."),
142
+ }),
143
+ annotations: READ_ONLY,
144
+ }, async (args) => {
145
+ try {
146
+ return text(await searchProfile(args));
147
+ }
148
+ catch (e) {
149
+ return textError(errorMessage(e));
150
+ }
151
+ });
111
152
  return server;
112
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archive-org-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Internet Archive search, item metadata, and Wayback snapshots.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/mrfentmen/awesome-mcps",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.0",
9
+ "version": "1.0.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "archive-org-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.0.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }
package/src/api.ts CHANGED
@@ -235,3 +235,226 @@ export const m1_getAvailability = m1.getAvailability;
235
235
  export const m1_WaybackError = m1.WaybackError;
236
236
  export const m1_getSnapshotText = m1.getSnapshotText;
237
237
  export const m1_getSnapshots = m1.getSnapshots;
238
+
239
+ // ---- muxE tools:
240
+ // The five existing tools either print a short list of Archive items or list the
241
+ // newest few Wayback snapshots. The new ones answer two questions neither asks:
242
+ // what a search term actually looks like across the catalogue, and how one URL's
243
+ // archived history is distributed over time rather than merely listed.
244
+ const CDX_TIMELINE = "https://web.archive.org/cdx/search/cdx";
245
+ const ADVANCED = "https://archive.org/advancedsearch.php";
246
+ const IA_UA = "archive-org-mcp/1.0";
247
+
248
+ interface CdxRow {
249
+ timestamp: string;
250
+ original: string;
251
+ statusCode: string;
252
+ mimeType: string;
253
+ digest: string;
254
+ length: string;
255
+ }
256
+
257
+ interface IaBody {
258
+ text: string;
259
+ contentType: string;
260
+ }
261
+
262
+ /**
263
+ * Fetches and returns the body unparsed. The two APIs here differ: advancedsearch
264
+ * always answers JSON and reports its own problems in an `error` field, while the
265
+ * CDX API occasionally answers a non JSON body of its own, so the caller reports
266
+ * what actually came back instead of the fetch layer assuming JSON.
267
+ */
268
+ async function iaText(url: string, seconds: number): Promise<IaBody> {
269
+ let res: Response;
270
+ try {
271
+ res = await fetch(url, { headers: { "User-Agent": IA_UA }, signal: AbortSignal.timeout(seconds * 1000) });
272
+ } catch (e) {
273
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
274
+ throw new ArchiveError(`The Internet Archive did not answer within ${seconds} seconds on this route; its index query is slow for a very widely captured URL, so narrow the year range or lower the limit.`);
275
+ }
276
+ throw new ArchiveError(`The Internet Archive could not be reached: ${e instanceof Error ? e.message : String(e)}`);
277
+ }
278
+ if (!res.ok) throw new ArchiveError(`Internet Archive error ${res.status}`);
279
+ const body = await res.text();
280
+ if (body.trim()) {
281
+ try {
282
+ const parsed = JSON.parse(body) as { error?: string };
283
+ if (parsed && typeof parsed.error === "string") {
284
+ throw new ArchiveError(`The Internet Archive said: ${parsed.error}`);
285
+ }
286
+ } catch (e) {
287
+ if (e instanceof ArchiveError) throw e;
288
+ // not JSON, which is the caller's business to report
289
+ }
290
+ }
291
+ return { text: body, contentType: res.headers.get("content-type") ?? "not reported" };
292
+ }
293
+
294
+ async function iaJson(url: string, seconds: number): Promise<unknown> {
295
+ const body = await iaText(url, seconds);
296
+ try {
297
+ return JSON.parse(body.text) as unknown;
298
+ } catch {
299
+ throw new ArchiveError(`The Internet Archive answered ${body.contentType} rather than JSON on this route.`);
300
+ }
301
+ }
302
+
303
+ const cdxStamp = (ts: string): string =>
304
+ /^\d{14}$/.test(ts) ? `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(8, 10)}:${ts.slice(10, 12)} UTC` : ts;
305
+
306
+ const yearOf = (ts: string): string => (/^\d{14}$/.test(ts) ? ts.slice(0, 4) : "unknown year");
307
+
308
+ export interface SnapshotTimelineArgs {
309
+ url: string;
310
+ from?: number;
311
+ to?: number;
312
+ limit?: number;
313
+ }
314
+
315
+ export async function snapshotTimeline(args: SnapshotTimelineArgs): Promise<string> {
316
+ const asked = (args?.url ?? "").trim();
317
+ if (!asked) throw new ArchiveError("Provide the URL whose archived history you want.");
318
+ // the existing snapshot tools take a bare host like example.com/page.html, so
319
+ // accept that shape too rather than being stricter than the rest of the server
320
+ const target = /^https?:\/\//i.test(asked) ? asked : `https://${asked}`;
321
+ if (!/^https?:\/\/[^/\s]+/.test(target)) throw new ArchiveError("Provide a URL such as example.com/page.html.");
322
+ const yearCheck = (value: number | undefined, label: string): number | undefined => {
323
+ if (value === undefined) return undefined;
324
+ const n = Math.floor(Number(value));
325
+ if (!Number.isInteger(n) || n < 1996 || n > 2100) throw new ArchiveError(`${label} must be a whole year between 1996 and 2100.`);
326
+ return n;
327
+ };
328
+ const from = yearCheck(args?.from, "from");
329
+ const to = yearCheck(args?.to, "to");
330
+ if (from !== undefined && to !== undefined && from > to) throw new ArchiveError("from must not be after to.");
331
+ const limit = Math.max(1, Math.min(Math.floor(Number(args?.limit ?? 500) || 500), 3000));
332
+ const params = new URLSearchParams({
333
+ url: target,
334
+ output: "json",
335
+ fl: "timestamp,statuscode,mimetype,digest,length",
336
+ filter: "statuscode:200",
337
+ limit: String(limit),
338
+ });
339
+ if (from !== undefined) params.set("from", String(from));
340
+ if (to !== undefined) params.set("to", String(to));
341
+ const raw = (await iaJson(`${CDX_TIMELINE}?${params.toString()}`, 45)) as string[][];
342
+ if (!Array.isArray(raw) || raw.length <= 1) {
343
+ const window = from !== undefined || to !== undefined ? ` between ${from ?? "the start"} and ${to ?? "now"}` : "";
344
+ return `The Wayback Machine holds no successful snapshot of ${target}${window}.`;
345
+ }
346
+ // the CDX API returns a header row, and the column order it uses is not
347
+ // guaranteed to be the order asked for, so the columns are read by name
348
+ const header = (raw[0] ?? []).map((h) => String(h).toLowerCase());
349
+ const at = (row: string[], name: string): string => {
350
+ const i = header.indexOf(name);
351
+ return i >= 0 ? (row[i] ?? "") : "";
352
+ };
353
+ const rows: CdxRow[] = raw.slice(1).map((r) => ({
354
+ timestamp: at(r, "timestamp"),
355
+ original: at(r, "original") || target,
356
+ statusCode: at(r, "statuscode"),
357
+ mimeType: at(r, "mimetype"),
358
+ digest: at(r, "digest"),
359
+ length: at(r, "length"),
360
+ }));
361
+ if (!rows.some((r) => r.timestamp)) {
362
+ throw new ArchiveError("The Wayback Machine returned rows without a readable timestamp column.");
363
+ }
364
+ const stamps = rows.map((r) => r.timestamp).filter((t) => /^\d{14}$/.test(t)).sort();
365
+ const digests = new Set(rows.map((r) => r.digest).filter(Boolean));
366
+ const types = new Map<string, number>();
367
+ const years = new Map<string, number>();
368
+ let bytes = 0;
369
+ let counted = 0;
370
+ for (const r of rows) {
371
+ if (r.mimeType) types.set(r.mimeType, (types.get(r.mimeType) ?? 0) + 1);
372
+ years.set(yearOf(r.timestamp), (years.get(yearOf(r.timestamp)) ?? 0) + 1);
373
+ const size = Number(r.length);
374
+ if (Number.isFinite(size) && size > 0) {
375
+ bytes += size;
376
+ counted += 1;
377
+ }
378
+ }
379
+ const lines = [
380
+ `Archived history of ${target}${asked !== target ? ` (asked for ${asked})` : ""}${from !== undefined || to !== undefined ? `, ${from ?? 1996} to ${to ?? "the present"}` : ""}:`,
381
+ ` successful snapshots returned: ${rows.length}${rows.length >= limit ? ` (the request limit was ${limit}, so there may be more)` : ""}`,
382
+ ` distinct content digests: ${digests.size}, so ${rows.length - digests.size} capture(s) repeat a page already archived`,
383
+ ];
384
+ if (stamps.length) {
385
+ lines.push(` first snapshot: ${cdxStamp(stamps[0])}`, ` last snapshot: ${cdxStamp(stamps[stamps.length - 1])}`);
386
+ }
387
+ if (counted) {
388
+ lines.push(` payload captured: ${bytes.toLocaleString("en-US")} bytes across ${counted} snapshot(s) that report a length`);
389
+ }
390
+ if (types.size) {
391
+ lines.push(" media types captured:");
392
+ for (const [k, n] of [...types.entries()].sort((a, b) => b[1] - a[1])) lines.push(` ${k}: ${n}`);
393
+ }
394
+ if (years.size) {
395
+ const sortedYears = [...years.entries()].sort((a, b) => a[0].localeCompare(b[0]));
396
+ lines.push(` captures per year across ${sortedYears.length} year(s):`);
397
+ for (const [y, n] of sortedYears.slice(0, 25)) lines.push(` ${y}: ${n}`);
398
+ if (sortedYears.length > 25) lines.push(` and ${sortedYears.length - 25} more year(s) not listed here`);
399
+ }
400
+ lines.push(" the existing snapshot tool lists the newest captures one by one; this is the distribution behind that list.");
401
+ return lines.join("\n");
402
+ }
403
+
404
+ export interface SearchProfileArgs {
405
+ query: string;
406
+ mediatype?: string;
407
+ limit?: number;
408
+ }
409
+
410
+ export async function searchProfile(args: SearchProfileArgs): Promise<string> {
411
+ const query = (args?.query ?? "").trim();
412
+ if (!query) throw new ArchiveError("Provide search terms.");
413
+ const mediatype = (args?.mediatype ?? "").trim().toLowerCase();
414
+ if (mediatype && !/^[a-z]+$/.test(mediatype)) throw new ArchiveError("A media type is a single word like software, audio, movies or texts.");
415
+ const limit = Math.max(10, Math.min(Math.floor(Number(args?.limit ?? 200) || 200), 500));
416
+ const q = mediatype ? `(${query}) AND mediatype:${mediatype}` : query;
417
+ const fields = ["identifier", "year", "mediatype", "downloads", "collection", "item_size"].map((f) => `fl[]=${f}`).join("&");
418
+ const url = `${ADVANCED}?q=${encodeURIComponent(q)}&${fields}&rows=${limit}&page=1&output=json`;
419
+ const parsed = (await iaJson(url, 60)) as { response?: { numFound?: number; docs?: Array<Record<string, unknown>> } };
420
+ const docs = parsed.response?.docs ?? [];
421
+ const numFound = typeof parsed.response?.numFound === "number" ? parsed.response.numFound : docs.length;
422
+ if (!docs.length) return `The Internet Archive holds nothing matching "${query}"${mediatype ? ` in ${mediatype}` : ""}.`;
423
+ const types = new Map<string, number>();
424
+ const years = new Map<string, number>();
425
+ const collections = new Map<string, number>();
426
+ const downloads: number[] = [];
427
+ let withYear = 0;
428
+ for (const d of docs) {
429
+ const type = typeof d.mediatype === "string" ? d.mediatype : "not reported";
430
+ types.set(type, (types.get(type) ?? 0) + 1);
431
+ const year = d.year != null ? String(d.year).slice(0, 4) : "not reported";
432
+ years.set(year, (years.get(year) ?? 0) + 1);
433
+ if (d.year != null) withYear += 1;
434
+ for (const c of Array.isArray(d.collection) ? d.collection : []) collections.set(String(c), (collections.get(String(c)) ?? 0) + 1);
435
+ if (typeof d.downloads === "number" && Number.isFinite(d.downloads)) downloads.push(d.downloads);
436
+ }
437
+ const top = (m: Map<string, number>, n: number): string[] =>
438
+ [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n).map(([k, v]) => `${k} (${v})`);
439
+ const lines = [
440
+ `What the Internet Archive holds for "${query}"${mediatype ? ` in ${mediatype}` : ""}:`,
441
+ ` items in the catalogue: ${numFound.toLocaleString("en-US")}`,
442
+ ` items read for this profile: ${docs.length}${numFound > docs.length ? `, so every breakdown below is a sample of the first ${docs.length} and not of all ${numFound.toLocaleString("en-US")}` : ""}`,
443
+ ];
444
+ lines.push(` media types in the sample: ${top(types, 10).join(", ") || "none reported"}`);
445
+ lines.push(` items with no year recorded: ${docs.length - withYear} of ${docs.length}`);
446
+ const knownYears = [...years.entries()].filter(([y]) => y !== "not reported").sort((a, b) => b[1] - a[1]);
447
+ lines.push(` most common years in the sample: ${knownYears.slice(0, 12).map(([y, n]) => `${y} (${n})`).join(", ") || "none reported"}`);
448
+ lines.push(` distinct collections touched: ${collections.size}`);
449
+ lines.push(` ${top(collections, 10).join(", ") || "none reported"}`);
450
+ if (downloads.length) {
451
+ const sorted = [...downloads].sort((a, b) => a - b);
452
+ const total = downloads.reduce((a, b) => a + b, 0);
453
+ const median = sorted.length % 2 ? sorted[Math.floor(sorted.length / 2)] : Math.round((sorted[sorted.length - 1] + sorted[sorted.length]) / 2);
454
+ lines.push(` downloads across the ${downloads.length} item(s) that report it: total ${total.toLocaleString("en-US")}, median ${median.toLocaleString("en-US")}, highest ${sorted[sorted.length - 1].toLocaleString("en-US")}, lowest ${sorted[0].toLocaleString("en-US")}`);
455
+ } else {
456
+ lines.push(" no item in the sample reports a download count.");
457
+ }
458
+ lines.push(" every figure above is tallied from the rows the search returned, and the sample size is stated rather than implied.");
459
+ return lines.join("\n");
460
+ }
package/src/license.ts ADDED
@@ -0,0 +1,88 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "archive-org-mcp"
5
+
6
+ // Tools that need a key. Everything else is free.
7
+ const PREMIUM = new Set<string>([
8
+ "snapshot_timeline",
9
+ ])
10
+
11
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`
12
+
13
+ // The license check calls the marketplace's verify endpoint directly instead of
14
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
15
+ // check it makes is rejected by Supabase before it reaches the function. The
16
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
17
+ // own JavaScript and only ever reaches their licence check.
18
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key"
19
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h"
20
+
21
+ const OK_CACHE_MS = 60 * 60 * 1000
22
+ const BAD_CACHE_MS = 60 * 1000
23
+
24
+ type Check = { valid: boolean; reason?: string }
25
+
26
+ const seen = new Map<string, Check & { at: number }>()
27
+
28
+ const REASONS: Record<string, string> = {
29
+ missing_key: "no key is set",
30
+ invalid_format: "that key is not a valid MCP Marketplace key",
31
+ not_found: "that key was not found",
32
+ revoked: "that key has been revoked",
33
+ rotated: "that key was rotated, use your new one",
34
+ expired: "that key has expired, renew it",
35
+ rate_limited: "there were too many checks just now, try again shortly",
36
+ network_error: "the license server could not be reached",
37
+ }
38
+
39
+ function blocked(tool: string, reason: string): string {
40
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`
41
+ return `"${tool}" needs a license key, but ${why}. ` +
42
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`
43
+ }
44
+
45
+ async function verify(key: string): Promise<Check> {
46
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
47
+ method: "POST",
48
+ headers: {
49
+ apikey: PUBLISHABLE_KEY,
50
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
51
+ "Content-Type": "application/json",
52
+ },
53
+ body: JSON.stringify({ key, slug: SLUG }),
54
+ // an unusable key answers 400 with a JSON body, so read the body either way
55
+ signal: AbortSignal.timeout(15000),
56
+ })
57
+ const data = (await res.json()) as Check
58
+ if (typeof data?.valid !== "boolean") return { valid: false, reason: "unexpected_response" }
59
+ return data
60
+ }
61
+
62
+ /**
63
+ * Returns null when the caller may run the tool, or a short message telling
64
+ * them how to get a key.
65
+ *
66
+ * A good key is remembered for an hour, so a busy session does not hit the
67
+ * license server on every call and keeps working through a brief outage.
68
+ */
69
+ export async function premiumRequired(tool: string): Promise<string | null> {
70
+ if (!PREMIUM.has(tool)) return null
71
+
72
+ const key = process.env.MCP_LICENSE_KEY
73
+ if (!key) return blocked(tool, "missing_key")
74
+
75
+ const hit = seen.get(key)
76
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
77
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid")
78
+ }
79
+
80
+ let result: Check
81
+ try {
82
+ result = await verify(key)
83
+ } catch {
84
+ result = { valid: false, reason: "network_error" }
85
+ }
86
+ seen.set(key, { ...result, at: Date.now() })
87
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid")
88
+ }
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { z } from 'zod'
3
- import { m0_ArchiveError, m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_WaybackError, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText } from './api.js'
3
+ import { m0_ArchiveError, m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_WaybackError, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText, snapshotTimeline, searchProfile } from './api.js'
4
+ import { premiumRequired } from "./license.js"
4
5
 
5
6
  const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] })
6
7
  const textError = (t: string) => ({ content: [{ type: "text" as const, text: t }], isError: true as const })
@@ -131,5 +132,46 @@ server.registerTool(
131
132
  }
132
133
  }
133
134
  )
135
+ server.registerTool(
136
+ "snapshot_timeline",
137
+ {
138
+ title: "Snapshot timeline",
139
+ description: "The shape of one URL's archived history rather than a list of it: how many successful snapshots exist in a year range, how many distinct content digests they hold, how many captures just repeat a page already archived, the media types captured, the payload size and captures per year. The get_snapshots tool returns the newest few with no digest, no date range and no totals.",
140
+ inputSchema: z.object(
141
+ {
142
+ url: z.string().describe("Full URL, e.g. 'example.com/page.html'"),
143
+ from: z.number().int().min(1996).max(2100).describe("Earliest year to include.").optional(),
144
+ to: z.number().int().min(1996).max(2100).describe("Latest year to include.").optional(),
145
+ limit: z.number().int().min(1).max(3000).default(500).describe("How many snapshots to read, up to 3000."),
146
+ }),
147
+ annotations: READ_ONLY,
148
+ },
149
+ async (args) => {
150
+ // ---- paywall: snapshot_timeline ----
151
+ const paywallMessage = await premiumRequired("snapshot_timeline")
152
+ if (paywallMessage) return { content: [{ type: "text" as const, text: paywallMessage }], isError: true }
153
+ // ---- paywall: end ----
154
+ try { return text(await snapshotTimeline(args)) } catch (e) { return textError(errorMessage(e)) }
155
+ }
156
+ )
157
+
158
+ server.registerTool(
159
+ "search_profile",
160
+ {
161
+ title: "Search profile",
162
+ description: "What a search term looks like across the Internet Archive rather than eight rows of it: the catalogue total, the media type split, how many items carry no year, the most common years, the collections involved and the download distribution, with the sample size stated. The search_items tool only prints a short list.",
163
+ inputSchema: z.object(
164
+ {
165
+ query: z.string().describe("Search terms, e.g. 'Windows 95'"),
166
+ mediatype: z.string().describe("Restrict to one media type, e.g. software or audio.").optional(),
167
+ limit: z.number().int().min(10).max(500).default(200).describe("How many items to read for the profile, 10 to 500."),
168
+ }),
169
+ annotations: READ_ONLY,
170
+ },
171
+ async (args) => {
172
+ try { return text(await searchProfile(args)) } catch (e) { return textError(errorMessage(e)) }
173
+ }
174
+ )
175
+
134
176
  return server
135
177
  }