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 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, snapshotTimeline, searchProfile } from './api.js';
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(errorMessage(e));
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(errorMessage(e));
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(errorMessage(e));
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(errorMessage(e));
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(errorMessage(e));
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
- // ---- 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 ----
122
+ // TODO: implement timeline locally (paywall removed)
128
123
  try {
129
- return text(await snapshotTimeline(args));
124
+ return textError("Not yet implemented: timeline");
130
125
  }
131
126
  catch (e) {
132
- return textError(errorMessage(e));
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(errorMessage(e));
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.2",
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
- const CDX_TIMELINE = "https://web.archive.org/cdx/search/cdx";
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(errorMessage(e))
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(errorMessage(e))
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(errorMessage(e))
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(errorMessage(e))
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(errorMessage(e))
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
- // ---- 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)) }
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(errorMessage(e)) }
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
- }