archive-org-mcp 1.0.2 → 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 +0 -16
- package/dist/api.d.ts +0 -1
- package/dist/api.js +0 -102
- package/dist/server.js +10 -15
- package/package.json +2 -2
- package/src/api.ts +1 -98
- package/src/server.ts +9 -13
- package/dist/license.d.ts +0 -8
- package/dist/license.js +0 -78
- package/src/license.ts +0 -88
package/README.md
CHANGED
|
@@ -24,20 +24,4 @@ node dist/index.js
|
|
|
24
24
|
|
|
25
25
|
Public free APIs only. See `src/api.ts` for exact endpoints.
|
|
26
26
|
|
|
27
|
-
<!-- paywall -->
|
|
28
|
-
## Premium tools
|
|
29
27
|
|
|
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
|
@@ -126,7 +126,6 @@ export interface SnapshotTimelineArgs {
|
|
|
126
126
|
to?: number;
|
|
127
127
|
limit?: number;
|
|
128
128
|
}
|
|
129
|
-
export declare function snapshotTimeline(args: SnapshotTimelineArgs): Promise<string>;
|
|
130
129
|
export interface SearchProfileArgs {
|
|
131
130
|
query: string;
|
|
132
131
|
mediatype?: string;
|
package/dist/api.js
CHANGED
|
@@ -186,7 +186,6 @@ export const m1_getSnapshots = m1.getSnapshots;
|
|
|
186
186
|
// newest few Wayback snapshots. The new ones answer two questions neither asks:
|
|
187
187
|
// what a search term actually looks like across the catalogue, and how one URL's
|
|
188
188
|
// archived history is distributed over time rather than merely listed.
|
|
189
|
-
const CDX_TIMELINE = "https://web.archive.org/cdx/search/cdx";
|
|
190
189
|
const ADVANCED = "https://archive.org/advancedsearch.php";
|
|
191
190
|
const IA_UA = "archive-org-mcp/1.0";
|
|
192
191
|
/**
|
|
@@ -233,107 +232,6 @@ async function iaJson(url, seconds) {
|
|
|
233
232
|
throw new ArchiveError(`The Internet Archive answered ${body.contentType} rather than JSON on this route.`);
|
|
234
233
|
}
|
|
235
234
|
}
|
|
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
235
|
export async function searchProfile(args) {
|
|
338
236
|
const query = (args?.query ?? "").trim();
|
|
339
237
|
if (!query)
|
package/dist/server.js
CHANGED
|
@@ -1,7 +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,
|
|
4
|
-
import { premiumRequired } from "./license.js";
|
|
3
|
+
import { m0_fmtSize, m0_formatItem, m0_getItemDetails, m0_searchItems, m1_formatSnapshot, m1_getAvailability, m1_getSnapshots, m1_getSnapshotText, searchProfile } from "./api.js";
|
|
5
4
|
const text = (value) => ({ content: [{ type: 'text', text: value }] });
|
|
6
5
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
7
6
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -31,7 +30,7 @@ export function createServer() {
|
|
|
31
30
|
items.map((it, i) => m0_formatItem(it, i)).join("\n\n"));
|
|
32
31
|
}
|
|
33
32
|
catch (e) {
|
|
34
|
-
return textError(
|
|
33
|
+
return textError(String(e));
|
|
35
34
|
}
|
|
36
35
|
});
|
|
37
36
|
server.registerTool("get_item", {
|
|
@@ -54,7 +53,7 @@ export function createServer() {
|
|
|
54
53
|
return text(lines.join("\n"));
|
|
55
54
|
}
|
|
56
55
|
catch (e) {
|
|
57
|
-
return textError(
|
|
56
|
+
return textError(String(e));
|
|
58
57
|
}
|
|
59
58
|
});
|
|
60
59
|
server.registerTool("get_snapshots", {
|
|
@@ -70,7 +69,7 @@ export function createServer() {
|
|
|
70
69
|
return text(`Snapshots of ${url}:\n\n${snaps.map((s, i) => m1_formatSnapshot(s, i)).join("\n\n")}`);
|
|
71
70
|
}
|
|
72
71
|
catch (e) {
|
|
73
|
-
return textError(
|
|
72
|
+
return textError(String(e));
|
|
74
73
|
}
|
|
75
74
|
});
|
|
76
75
|
server.registerTool("get_availability", {
|
|
@@ -86,7 +85,7 @@ export function createServer() {
|
|
|
86
85
|
return text(`Closest snapshot of ${url}:\n\n${m1_formatSnapshot(snap)}`);
|
|
87
86
|
}
|
|
88
87
|
catch (e) {
|
|
89
|
-
return textError(
|
|
88
|
+
return textError(String(e));
|
|
90
89
|
}
|
|
91
90
|
});
|
|
92
91
|
server.registerTool("read_snapshot", {
|
|
@@ -106,7 +105,7 @@ export function createServer() {
|
|
|
106
105
|
return text(content);
|
|
107
106
|
}
|
|
108
107
|
catch (e) {
|
|
109
|
-
return textError(
|
|
108
|
+
return textError(String(e));
|
|
110
109
|
}
|
|
111
110
|
});
|
|
112
111
|
server.registerTool("snapshot_timeline", {
|
|
@@ -120,16 +119,12 @@ export function createServer() {
|
|
|
120
119
|
}),
|
|
121
120
|
annotations: READ_ONLY,
|
|
122
121
|
}, async (args) => {
|
|
123
|
-
//
|
|
124
|
-
const paywallMessage = await premiumRequired("snapshot_timeline");
|
|
125
|
-
if (paywallMessage)
|
|
126
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
127
|
-
// ---- paywall: end ----
|
|
122
|
+
// TODO: implement timeline locally (paywall removed)
|
|
128
123
|
try {
|
|
129
|
-
return
|
|
124
|
+
return textError("Not yet implemented: timeline");
|
|
130
125
|
}
|
|
131
126
|
catch (e) {
|
|
132
|
-
return textError(
|
|
127
|
+
return textError(String(e));
|
|
133
128
|
}
|
|
134
129
|
});
|
|
135
130
|
server.registerTool("search_profile", {
|
|
@@ -146,7 +141,7 @@ export function createServer() {
|
|
|
146
141
|
return text(await searchProfile(args));
|
|
147
142
|
}
|
|
148
143
|
catch (e) {
|
|
149
|
-
return textError(
|
|
144
|
+
return textError(String(e));
|
|
150
145
|
}
|
|
151
146
|
});
|
|
152
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" },
|
|
@@ -241,7 +238,7 @@ export const m1_getSnapshots = m1.getSnapshots;
|
|
|
241
238
|
// newest few Wayback snapshots. The new ones answer two questions neither asks:
|
|
242
239
|
// what a search term actually looks like across the catalogue, and how one URL's
|
|
243
240
|
// archived history is distributed over time rather than merely listed.
|
|
244
|
-
|
|
241
|
+
|
|
245
242
|
const ADVANCED = "https://archive.org/advancedsearch.php";
|
|
246
243
|
const IA_UA = "archive-org-mcp/1.0";
|
|
247
244
|
|
|
@@ -300,11 +297,6 @@ async function iaJson(url: string, seconds: number): Promise<unknown> {
|
|
|
300
297
|
}
|
|
301
298
|
}
|
|
302
299
|
|
|
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
300
|
export interface SnapshotTimelineArgs {
|
|
309
301
|
url: string;
|
|
310
302
|
from?: number;
|
|
@@ -312,95 +304,6 @@ export interface SnapshotTimelineArgs {
|
|
|
312
304
|
limit?: number;
|
|
313
305
|
}
|
|
314
306
|
|
|
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
307
|
export interface SearchProfileArgs {
|
|
405
308
|
query: string;
|
|
406
309
|
mediatype?: string;
|
package/src/server.ts
CHANGED
|
@@ -1,8 +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, snapshotTimeline, searchProfile } from './api.js'
|
|
4
|
-
import { premiumRequired } from "./license.js"
|
|
5
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"
|
|
6
5
|
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] })
|
|
7
6
|
const textError = (t: string) => ({ content: [{ type: "text" as const, text: t }], isError: true as const })
|
|
8
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const
|
|
@@ -37,7 +36,7 @@ server.registerTool(
|
|
|
37
36
|
items.map((it, i) => m0_formatItem(it, i)).join("\n\n")
|
|
38
37
|
)
|
|
39
38
|
} catch (e) {
|
|
40
|
-
return textError(
|
|
39
|
+
return textError(String(e))
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
)
|
|
@@ -67,7 +66,7 @@ server.registerTool(
|
|
|
67
66
|
].filter(Boolean)
|
|
68
67
|
return text(lines.join("\n"))
|
|
69
68
|
} catch (e) {
|
|
70
|
-
return textError(
|
|
69
|
+
return textError(String(e))
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
)
|
|
@@ -86,7 +85,7 @@ server.registerTool(
|
|
|
86
85
|
if (snaps.length === 0) return text(`No archived snapshots of ${url}.`)
|
|
87
86
|
return text(`Snapshots of ${url}:\n\n${snaps.map((s, i) => m1_formatSnapshot(s, i)).join("\n\n")}`)
|
|
88
87
|
} catch (e) {
|
|
89
|
-
return textError(
|
|
88
|
+
return textError(String(e))
|
|
90
89
|
}
|
|
91
90
|
}
|
|
92
91
|
)
|
|
@@ -105,7 +104,7 @@ server.registerTool(
|
|
|
105
104
|
if (!snap) return text(`No archived snapshot of ${url}.`)
|
|
106
105
|
return text(`Closest snapshot of ${url}:\n\n${m1_formatSnapshot(snap)}`)
|
|
107
106
|
} catch (e) {
|
|
108
|
-
return textError(
|
|
107
|
+
return textError(String(e))
|
|
109
108
|
}
|
|
110
109
|
}
|
|
111
110
|
)
|
|
@@ -128,7 +127,7 @@ server.registerTool(
|
|
|
128
127
|
if (!content.trim()) return text(`Snapshot ${timestamp} of ${url} has no readable text.`)
|
|
129
128
|
return text(content)
|
|
130
129
|
} catch (e) {
|
|
131
|
-
return textError(
|
|
130
|
+
return textError(String(e))
|
|
132
131
|
}
|
|
133
132
|
}
|
|
134
133
|
)
|
|
@@ -147,11 +146,8 @@ server.registerTool(
|
|
|
147
146
|
annotations: READ_ONLY,
|
|
148
147
|
},
|
|
149
148
|
async (args) => {
|
|
150
|
-
//
|
|
151
|
-
|
|
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)) }
|
|
149
|
+
// TODO: implement timeline locally (paywall removed)
|
|
150
|
+
try { return textError("Not yet implemented: timeline") } catch (e) { return textError(String(e)) }
|
|
155
151
|
}
|
|
156
152
|
)
|
|
157
153
|
|
|
@@ -169,7 +165,7 @@ server.registerTool(
|
|
|
169
165
|
annotations: READ_ONLY,
|
|
170
166
|
},
|
|
171
167
|
async (args) => {
|
|
172
|
-
try { return text(await searchProfile(args)) } catch (e) { return textError(
|
|
168
|
+
try { return text(await searchProfile(args)) } catch (e) { return textError(String(e)) }
|
|
173
169
|
}
|
|
174
170
|
)
|
|
175
171
|
|
package/dist/license.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
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>;
|
package/dist/license.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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/src/license.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
}
|