archive-org-mcp 1.0.1 → 1.0.3

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,5 @@ node dist/index.js
23
23
  ## Source
24
24
 
25
25
  Public free APIs only. See `src/api.ts` for exact endpoints.
26
+
27
+
package/dist/api.d.ts CHANGED
@@ -120,3 +120,15 @@ 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 interface SearchProfileArgs {
130
+ query: string;
131
+ mediatype?: string;
132
+ limit?: number;
133
+ }
134
+ export declare function searchProfile(args: SearchProfileArgs): Promise<string>;
package/dist/api.js CHANGED
@@ -181,3 +181,111 @@ 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 ADVANCED = "https://archive.org/advancedsearch.php";
190
+ const IA_UA = "archive-org-mcp/1.0";
191
+ /**
192
+ * Fetches and returns the body unparsed. The two APIs here differ: advancedsearch
193
+ * always answers JSON and reports its own problems in an `error` field, while the
194
+ * CDX API occasionally answers a non JSON body of its own, so the caller reports
195
+ * what actually came back instead of the fetch layer assuming JSON.
196
+ */
197
+ async function iaText(url, seconds) {
198
+ let res;
199
+ try {
200
+ res = await fetch(url, { headers: { "User-Agent": IA_UA }, signal: AbortSignal.timeout(seconds * 1000) });
201
+ }
202
+ catch (e) {
203
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
204
+ 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.`);
205
+ }
206
+ throw new ArchiveError(`The Internet Archive could not be reached: ${e instanceof Error ? e.message : String(e)}`);
207
+ }
208
+ if (!res.ok)
209
+ throw new ArchiveError(`Internet Archive error ${res.status}`);
210
+ const body = await res.text();
211
+ if (body.trim()) {
212
+ try {
213
+ const parsed = JSON.parse(body);
214
+ if (parsed && typeof parsed.error === "string") {
215
+ throw new ArchiveError(`The Internet Archive said: ${parsed.error}`);
216
+ }
217
+ }
218
+ catch (e) {
219
+ if (e instanceof ArchiveError)
220
+ throw e;
221
+ // not JSON, which is the caller's business to report
222
+ }
223
+ }
224
+ return { text: body, contentType: res.headers.get("content-type") ?? "not reported" };
225
+ }
226
+ async function iaJson(url, seconds) {
227
+ const body = await iaText(url, seconds);
228
+ try {
229
+ return JSON.parse(body.text);
230
+ }
231
+ catch {
232
+ throw new ArchiveError(`The Internet Archive answered ${body.contentType} rather than JSON on this route.`);
233
+ }
234
+ }
235
+ export async function searchProfile(args) {
236
+ const query = (args?.query ?? "").trim();
237
+ if (!query)
238
+ throw new ArchiveError("Provide search terms.");
239
+ const mediatype = (args?.mediatype ?? "").trim().toLowerCase();
240
+ if (mediatype && !/^[a-z]+$/.test(mediatype))
241
+ throw new ArchiveError("A media type is a single word like software, audio, movies or texts.");
242
+ const limit = Math.max(10, Math.min(Math.floor(Number(args?.limit ?? 200) || 200), 500));
243
+ const q = mediatype ? `(${query}) AND mediatype:${mediatype}` : query;
244
+ const fields = ["identifier", "year", "mediatype", "downloads", "collection", "item_size"].map((f) => `fl[]=${f}`).join("&");
245
+ const url = `${ADVANCED}?q=${encodeURIComponent(q)}&${fields}&rows=${limit}&page=1&output=json`;
246
+ const parsed = (await iaJson(url, 60));
247
+ const docs = parsed.response?.docs ?? [];
248
+ const numFound = typeof parsed.response?.numFound === "number" ? parsed.response.numFound : docs.length;
249
+ if (!docs.length)
250
+ return `The Internet Archive holds nothing matching "${query}"${mediatype ? ` in ${mediatype}` : ""}.`;
251
+ const types = new Map();
252
+ const years = new Map();
253
+ const collections = new Map();
254
+ const downloads = [];
255
+ let withYear = 0;
256
+ for (const d of docs) {
257
+ const type = typeof d.mediatype === "string" ? d.mediatype : "not reported";
258
+ types.set(type, (types.get(type) ?? 0) + 1);
259
+ const year = d.year != null ? String(d.year).slice(0, 4) : "not reported";
260
+ years.set(year, (years.get(year) ?? 0) + 1);
261
+ if (d.year != null)
262
+ withYear += 1;
263
+ for (const c of Array.isArray(d.collection) ? d.collection : [])
264
+ collections.set(String(c), (collections.get(String(c)) ?? 0) + 1);
265
+ if (typeof d.downloads === "number" && Number.isFinite(d.downloads))
266
+ downloads.push(d.downloads);
267
+ }
268
+ 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})`);
269
+ const lines = [
270
+ `What the Internet Archive holds for "${query}"${mediatype ? ` in ${mediatype}` : ""}:`,
271
+ ` items in the catalogue: ${numFound.toLocaleString("en-US")}`,
272
+ ` 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")}` : ""}`,
273
+ ];
274
+ lines.push(` media types in the sample: ${top(types, 10).join(", ") || "none reported"}`);
275
+ lines.push(` items with no year recorded: ${docs.length - withYear} of ${docs.length}`);
276
+ const knownYears = [...years.entries()].filter(([y]) => y !== "not reported").sort((a, b) => b[1] - a[1]);
277
+ lines.push(` most common years in the sample: ${knownYears.slice(0, 12).map(([y, n]) => `${y} (${n})`).join(", ") || "none reported"}`);
278
+ lines.push(` distinct collections touched: ${collections.size}`);
279
+ lines.push(` ${top(collections, 10).join(", ") || "none reported"}`);
280
+ if (downloads.length) {
281
+ const sorted = [...downloads].sort((a, b) => a - b);
282
+ const total = downloads.reduce((a, b) => a + b, 0);
283
+ const median = sorted.length % 2 ? sorted[Math.floor(sorted.length / 2)] : Math.round((sorted[sorted.length - 1] + sorted[sorted.length]) / 2);
284
+ 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")}`);
285
+ }
286
+ else {
287
+ lines.push(" no item in the sample reports a download count.");
288
+ }
289
+ lines.push(" every figure above is tallied from the rows the search returned, and the sample size is stated rather than implied.");
290
+ return lines.join("\n");
291
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
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, searchProfile } from "./api.js";
4
4
  const text = (value) => ({ content: [{ type: 'text', text: value }] });
5
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -30,7 +30,7 @@ export function createServer() {
30
30
  items.map((it, i) => m0_formatItem(it, i)).join("\n\n"));
31
31
  }
32
32
  catch (e) {
33
- return textError(errorMessage(e));
33
+ return textError(String(e));
34
34
  }
35
35
  });
36
36
  server.registerTool("get_item", {
@@ -53,7 +53,7 @@ export function createServer() {
53
53
  return text(lines.join("\n"));
54
54
  }
55
55
  catch (e) {
56
- return textError(errorMessage(e));
56
+ return textError(String(e));
57
57
  }
58
58
  });
59
59
  server.registerTool("get_snapshots", {
@@ -69,7 +69,7 @@ export function createServer() {
69
69
  return text(`Snapshots of ${url}:\n\n${snaps.map((s, i) => m1_formatSnapshot(s, i)).join("\n\n")}`);
70
70
  }
71
71
  catch (e) {
72
- return textError(errorMessage(e));
72
+ return textError(String(e));
73
73
  }
74
74
  });
75
75
  server.registerTool("get_availability", {
@@ -85,7 +85,7 @@ export function createServer() {
85
85
  return text(`Closest snapshot of ${url}:\n\n${m1_formatSnapshot(snap)}`);
86
86
  }
87
87
  catch (e) {
88
- return textError(errorMessage(e));
88
+ return textError(String(e));
89
89
  }
90
90
  });
91
91
  server.registerTool("read_snapshot", {
@@ -105,7 +105,43 @@ export function createServer() {
105
105
  return text(content);
106
106
  }
107
107
  catch (e) {
108
- return textError(errorMessage(e));
108
+ return textError(String(e));
109
+ }
110
+ });
111
+ server.registerTool("snapshot_timeline", {
112
+ title: "Snapshot timeline",
113
+ 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.",
114
+ inputSchema: z.object({
115
+ url: z.string().describe("Full URL, e.g. 'example.com/page.html'"),
116
+ from: z.number().int().min(1996).max(2100).describe("Earliest year to include.").optional(),
117
+ to: z.number().int().min(1996).max(2100).describe("Latest year to include.").optional(),
118
+ limit: z.number().int().min(1).max(3000).default(500).describe("How many snapshots to read, up to 3000."),
119
+ }),
120
+ annotations: READ_ONLY,
121
+ }, async (args) => {
122
+ // TODO: implement timeline locally (paywall removed)
123
+ try {
124
+ return textError("Not yet implemented: timeline");
125
+ }
126
+ catch (e) {
127
+ return textError(String(e));
128
+ }
129
+ });
130
+ server.registerTool("search_profile", {
131
+ title: "Search profile",
132
+ 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.",
133
+ inputSchema: z.object({
134
+ query: z.string().describe("Search terms, e.g. 'Windows 95'"),
135
+ mediatype: z.string().describe("Restrict to one media type, e.g. software or audio.").optional(),
136
+ limit: z.number().int().min(10).max(500).default(200).describe("How many items to read for the profile, 10 to 500."),
137
+ }),
138
+ annotations: READ_ONLY,
139
+ }, async (args) => {
140
+ try {
141
+ return text(await searchProfile(args));
142
+ }
143
+ catch (e) {
144
+ return textError(String(e));
109
145
  }
110
146
  });
111
147
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archive-org-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Internet Archive search, item metadata, and Wayback snapshots.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,4 +21,4 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "mcpName": "io.github.mrfentmen/archive-org-mcp"
24
- }
24
+ }
package/src/api.ts CHANGED
@@ -37,8 +37,6 @@ const META = "https://archive.org/metadata"
37
37
 
38
38
  class ArchiveError extends Error {}
39
39
 
40
-
41
-
42
40
  async function getJson<T>(url: string): Promise<T> {
43
41
  const res = await fetch(url, { headers: { "User-Agent": "archive-org-mcp/1.0" } })
44
42
  if (!res.ok) throw new ArchiveError(`Internet Archive error ${res.status} for ${url.slice(0, 120)}`)
@@ -135,7 +133,6 @@ const AVAILABLE = "https://archive.org/wayback/available"
135
133
 
136
134
  class WaybackError extends Error {}
137
135
 
138
-
139
136
  async function getText(url: string): Promise<string> {
140
137
  const res = await fetch(url, {
141
138
  headers: { "User-Agent": "wayback-mcp/1.0", Accept: "application/json,text/plain,text/html" },
@@ -235,3 +232,132 @@ export const m1_getAvailability = m1.getAvailability;
235
232
  export const m1_WaybackError = m1.WaybackError;
236
233
  export const m1_getSnapshotText = m1.getSnapshotText;
237
234
  export const m1_getSnapshots = m1.getSnapshots;
235
+
236
+ // ---- muxE tools:
237
+ // The five existing tools either print a short list of Archive items or list the
238
+ // newest few Wayback snapshots. The new ones answer two questions neither asks:
239
+ // what a search term actually looks like across the catalogue, and how one URL's
240
+ // archived history is distributed over time rather than merely listed.
241
+
242
+ const ADVANCED = "https://archive.org/advancedsearch.php";
243
+ const IA_UA = "archive-org-mcp/1.0";
244
+
245
+ interface CdxRow {
246
+ timestamp: string;
247
+ original: string;
248
+ statusCode: string;
249
+ mimeType: string;
250
+ digest: string;
251
+ length: string;
252
+ }
253
+
254
+ interface IaBody {
255
+ text: string;
256
+ contentType: string;
257
+ }
258
+
259
+ /**
260
+ * Fetches and returns the body unparsed. The two APIs here differ: advancedsearch
261
+ * always answers JSON and reports its own problems in an `error` field, while the
262
+ * CDX API occasionally answers a non JSON body of its own, so the caller reports
263
+ * what actually came back instead of the fetch layer assuming JSON.
264
+ */
265
+ async function iaText(url: string, seconds: number): Promise<IaBody> {
266
+ let res: Response;
267
+ try {
268
+ res = await fetch(url, { headers: { "User-Agent": IA_UA }, signal: AbortSignal.timeout(seconds * 1000) });
269
+ } catch (e) {
270
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
271
+ 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.`);
272
+ }
273
+ throw new ArchiveError(`The Internet Archive could not be reached: ${e instanceof Error ? e.message : String(e)}`);
274
+ }
275
+ if (!res.ok) throw new ArchiveError(`Internet Archive error ${res.status}`);
276
+ const body = await res.text();
277
+ if (body.trim()) {
278
+ try {
279
+ const parsed = JSON.parse(body) as { error?: string };
280
+ if (parsed && typeof parsed.error === "string") {
281
+ throw new ArchiveError(`The Internet Archive said: ${parsed.error}`);
282
+ }
283
+ } catch (e) {
284
+ if (e instanceof ArchiveError) throw e;
285
+ // not JSON, which is the caller's business to report
286
+ }
287
+ }
288
+ return { text: body, contentType: res.headers.get("content-type") ?? "not reported" };
289
+ }
290
+
291
+ async function iaJson(url: string, seconds: number): Promise<unknown> {
292
+ const body = await iaText(url, seconds);
293
+ try {
294
+ return JSON.parse(body.text) as unknown;
295
+ } catch {
296
+ throw new ArchiveError(`The Internet Archive answered ${body.contentType} rather than JSON on this route.`);
297
+ }
298
+ }
299
+
300
+ export interface SnapshotTimelineArgs {
301
+ url: string;
302
+ from?: number;
303
+ to?: number;
304
+ limit?: number;
305
+ }
306
+
307
+ export interface SearchProfileArgs {
308
+ query: string;
309
+ mediatype?: string;
310
+ limit?: number;
311
+ }
312
+
313
+ export async function searchProfile(args: SearchProfileArgs): Promise<string> {
314
+ const query = (args?.query ?? "").trim();
315
+ if (!query) throw new ArchiveError("Provide search terms.");
316
+ const mediatype = (args?.mediatype ?? "").trim().toLowerCase();
317
+ if (mediatype && !/^[a-z]+$/.test(mediatype)) throw new ArchiveError("A media type is a single word like software, audio, movies or texts.");
318
+ const limit = Math.max(10, Math.min(Math.floor(Number(args?.limit ?? 200) || 200), 500));
319
+ const q = mediatype ? `(${query}) AND mediatype:${mediatype}` : query;
320
+ const fields = ["identifier", "year", "mediatype", "downloads", "collection", "item_size"].map((f) => `fl[]=${f}`).join("&");
321
+ const url = `${ADVANCED}?q=${encodeURIComponent(q)}&${fields}&rows=${limit}&page=1&output=json`;
322
+ const parsed = (await iaJson(url, 60)) as { response?: { numFound?: number; docs?: Array<Record<string, unknown>> } };
323
+ const docs = parsed.response?.docs ?? [];
324
+ const numFound = typeof parsed.response?.numFound === "number" ? parsed.response.numFound : docs.length;
325
+ if (!docs.length) return `The Internet Archive holds nothing matching "${query}"${mediatype ? ` in ${mediatype}` : ""}.`;
326
+ const types = new Map<string, number>();
327
+ const years = new Map<string, number>();
328
+ const collections = new Map<string, number>();
329
+ const downloads: number[] = [];
330
+ let withYear = 0;
331
+ for (const d of docs) {
332
+ const type = typeof d.mediatype === "string" ? d.mediatype : "not reported";
333
+ types.set(type, (types.get(type) ?? 0) + 1);
334
+ const year = d.year != null ? String(d.year).slice(0, 4) : "not reported";
335
+ years.set(year, (years.get(year) ?? 0) + 1);
336
+ if (d.year != null) withYear += 1;
337
+ for (const c of Array.isArray(d.collection) ? d.collection : []) collections.set(String(c), (collections.get(String(c)) ?? 0) + 1);
338
+ if (typeof d.downloads === "number" && Number.isFinite(d.downloads)) downloads.push(d.downloads);
339
+ }
340
+ const top = (m: Map<string, number>, n: number): string[] =>
341
+ [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n).map(([k, v]) => `${k} (${v})`);
342
+ const lines = [
343
+ `What the Internet Archive holds for "${query}"${mediatype ? ` in ${mediatype}` : ""}:`,
344
+ ` items in the catalogue: ${numFound.toLocaleString("en-US")}`,
345
+ ` 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")}` : ""}`,
346
+ ];
347
+ lines.push(` media types in the sample: ${top(types, 10).join(", ") || "none reported"}`);
348
+ lines.push(` items with no year recorded: ${docs.length - withYear} of ${docs.length}`);
349
+ const knownYears = [...years.entries()].filter(([y]) => y !== "not reported").sort((a, b) => b[1] - a[1]);
350
+ lines.push(` most common years in the sample: ${knownYears.slice(0, 12).map(([y, n]) => `${y} (${n})`).join(", ") || "none reported"}`);
351
+ lines.push(` distinct collections touched: ${collections.size}`);
352
+ lines.push(` ${top(collections, 10).join(", ") || "none reported"}`);
353
+ if (downloads.length) {
354
+ const sorted = [...downloads].sort((a, b) => a - b);
355
+ const total = downloads.reduce((a, b) => a + b, 0);
356
+ const median = sorted.length % 2 ? sorted[Math.floor(sorted.length / 2)] : Math.round((sorted[sorted.length - 1] + sorted[sorted.length]) / 2);
357
+ 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")}`);
358
+ } else {
359
+ lines.push(" no item in the sample reports a download count.");
360
+ }
361
+ lines.push(" every figure above is tallied from the rows the search returned, and the sample size is stated rather than implied.");
362
+ return lines.join("\n");
363
+ }
package/src/server.ts CHANGED
@@ -1,7 +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'
4
3
 
4
+ import { m0_ArchiveError, m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_WaybackError, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText, searchProfile } from "./api.js"
5
5
  const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] })
6
6
  const textError = (t: string) => ({ content: [{ type: "text" as const, text: t }], isError: true as const })
7
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const
@@ -36,7 +36,7 @@ server.registerTool(
36
36
  items.map((it, i) => m0_formatItem(it, i)).join("\n\n")
37
37
  )
38
38
  } catch (e) {
39
- return textError(errorMessage(e))
39
+ return textError(String(e))
40
40
  }
41
41
  }
42
42
  )
@@ -66,7 +66,7 @@ server.registerTool(
66
66
  ].filter(Boolean)
67
67
  return text(lines.join("\n"))
68
68
  } catch (e) {
69
- return textError(errorMessage(e))
69
+ return textError(String(e))
70
70
  }
71
71
  }
72
72
  )
@@ -85,7 +85,7 @@ server.registerTool(
85
85
  if (snaps.length === 0) return text(`No archived snapshots of ${url}.`)
86
86
  return text(`Snapshots of ${url}:\n\n${snaps.map((s, i) => m1_formatSnapshot(s, i)).join("\n\n")}`)
87
87
  } catch (e) {
88
- return textError(errorMessage(e))
88
+ return textError(String(e))
89
89
  }
90
90
  }
91
91
  )
@@ -104,7 +104,7 @@ server.registerTool(
104
104
  if (!snap) return text(`No archived snapshot of ${url}.`)
105
105
  return text(`Closest snapshot of ${url}:\n\n${m1_formatSnapshot(snap)}`)
106
106
  } catch (e) {
107
- return textError(errorMessage(e))
107
+ return textError(String(e))
108
108
  }
109
109
  }
110
110
  )
@@ -127,9 +127,47 @@ server.registerTool(
127
127
  if (!content.trim()) return text(`Snapshot ${timestamp} of ${url} has no readable text.`)
128
128
  return text(content)
129
129
  } catch (e) {
130
- return textError(errorMessage(e))
130
+ return textError(String(e))
131
131
  }
132
132
  }
133
133
  )
134
+ server.registerTool(
135
+ "snapshot_timeline",
136
+ {
137
+ title: "Snapshot timeline",
138
+ 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.",
139
+ inputSchema: z.object(
140
+ {
141
+ url: z.string().describe("Full URL, e.g. 'example.com/page.html'"),
142
+ from: z.number().int().min(1996).max(2100).describe("Earliest year to include.").optional(),
143
+ to: z.number().int().min(1996).max(2100).describe("Latest year to include.").optional(),
144
+ limit: z.number().int().min(1).max(3000).default(500).describe("How many snapshots to read, up to 3000."),
145
+ }),
146
+ annotations: READ_ONLY,
147
+ },
148
+ async (args) => {
149
+ // TODO: implement timeline locally (paywall removed)
150
+ try { return textError("Not yet implemented: timeline") } catch (e) { return textError(String(e)) }
151
+ }
152
+ )
153
+
154
+ server.registerTool(
155
+ "search_profile",
156
+ {
157
+ title: "Search profile",
158
+ 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.",
159
+ inputSchema: z.object(
160
+ {
161
+ query: z.string().describe("Search terms, e.g. 'Windows 95'"),
162
+ mediatype: z.string().describe("Restrict to one media type, e.g. software or audio.").optional(),
163
+ limit: z.number().int().min(10).max(500).default(200).describe("How many items to read for the profile, 10 to 500."),
164
+ }),
165
+ annotations: READ_ONLY,
166
+ },
167
+ async (args) => {
168
+ try { return text(await searchProfile(args)) } catch (e) { return textError(String(e)) }
169
+ }
170
+ )
171
+
134
172
  return server
135
173
  }