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 +2 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.js +108 -0
- package/dist/server.js +42 -6
- package/package.json +2 -2
- package/src/api.ts +129 -3
- package/src/server.ts +44 -6
package/README.md
CHANGED
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|