mcp-libraryofcongress 1.0.0

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/dist/index.js ADDED
@@ -0,0 +1,1695 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+
9
+ // src/version.ts
10
+ var PKG_VERSION = "1.0.0";
11
+ var REPO_URL = "https://github.com/smeet666/mcp-libraryofcongress";
12
+
13
+ // src/config.ts
14
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
15
+ var MIN_ALLOWED_INTERVAL_MS = 3e3;
16
+ var MAX_ALLOWED_INTERVAL_MS = 6e4;
17
+ var DEFAULT_INTERVAL_MS = 6e3;
18
+ var DEFAULT_NEWSPAPER_TIMEOUT_MS = 9e4;
19
+ var DEFAULT_USER_AGENT = `mcp-libraryofcongress/${PKG_VERSION} (+${REPO_URL})`;
20
+ function createLogger(level) {
21
+ const rank = LOG_LEVELS.indexOf(level);
22
+ const write = (at, message) => {
23
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
24
+ process.stderr.write(`[mcp-libraryofcongress] ${at}: ${message}
25
+ `);
26
+ };
27
+ return {
28
+ debug: (m) => write("debug", m),
29
+ info: (m) => write("info", m),
30
+ // A warning goes out at the error level so it survives the default
31
+ // setting: a caller has to know that rows were dropped.
32
+ warn: (m) => write("error", m),
33
+ error: (m) => write("error", m)
34
+ };
35
+ }
36
+ function readInteger(env, name, fallback, min, max) {
37
+ const raw = env[name];
38
+ if (raw === void 0 || raw.trim() === "") return fallback;
39
+ const value = Number(raw);
40
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
41
+ process.stderr.write(
42
+ `[mcp-libraryofcongress] error: ${name}="${raw}" is not a whole number; using ${fallback}.
43
+ `
44
+ );
45
+ return fallback;
46
+ }
47
+ if (value < min || value > max) {
48
+ process.stderr.write(
49
+ `[mcp-libraryofcongress] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.
50
+ `
51
+ );
52
+ return fallback;
53
+ }
54
+ return value;
55
+ }
56
+ function loadConfig(env = process.env) {
57
+ const level = env.LOC_LOG_LEVEL;
58
+ const logLevel = level && LOG_LEVELS.includes(level) ? level : "error";
59
+ if (level && !LOG_LEVELS.includes(level)) {
60
+ process.stderr.write(
61
+ `[mcp-libraryofcongress] error: LOC_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
62
+ `
63
+ );
64
+ }
65
+ const custom = env.LOC_USER_AGENT?.trim();
66
+ return {
67
+ // A caller who wants to be recognised may say who they are, and the
68
+ // contact address stays attached: the Library has to be able to reach a
69
+ // human about traffic it did not expect.
70
+ userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
71
+ minIntervalMs: readInteger(
72
+ env,
73
+ "LOC_MIN_INTERVAL_MS",
74
+ DEFAULT_INTERVAL_MS,
75
+ MIN_ALLOWED_INTERVAL_MS,
76
+ MAX_ALLOWED_INTERVAL_MS
77
+ ),
78
+ // A catalogue page carries a row for every match on it.
79
+ timeoutMs: readInteger(env, "LOC_TIMEOUT_MS", 3e4, 1e3, 12e4),
80
+ newspaperTimeoutMs: readInteger(
81
+ env,
82
+ "LOC_NEWSPAPER_TIMEOUT_MS",
83
+ DEFAULT_NEWSPAPER_TIMEOUT_MS,
84
+ 1e3,
85
+ 3e5
86
+ ),
87
+ maxRetries: readInteger(env, "LOC_MAX_RETRIES", 3, 0, 8),
88
+ cacheTtlMs: readInteger(env, "LOC_CACHE_TTL_MS", 9e5, 0, 864e5),
89
+ cacheMaxEntries: readInteger(env, "LOC_CACHE_MAX_ENTRIES", 200, 1, 5e3),
90
+ logLevel
91
+ };
92
+ }
93
+
94
+ // src/errors.ts
95
+ var LocError = class extends Error {
96
+ code;
97
+ details;
98
+ constructor(code, message, details = {}) {
99
+ super(message);
100
+ this.name = "LocError";
101
+ this.code = code;
102
+ this.details = details;
103
+ }
104
+ };
105
+ var notFound = (message, details) => new LocError("not_found", message, details);
106
+ var invalidInput = (message, hint) => new LocError("invalid_input", message, hint ? { hint } : {});
107
+ var rateLimited = (message, details) => new LocError("rate_limited", message, {
108
+ hint: "Wait a moment and ask again. This says nothing about whether the Library holds what you asked for.",
109
+ ...details
110
+ });
111
+ var parseFailure = (message, details) => new LocError("parse_failure", message, {
112
+ hint: "The Library may have changed how it answers. Please report this at https://github.com/smeet666/mcp-libraryofcongress/issues with the arguments you used.",
113
+ ...details
114
+ });
115
+ var networkError = (message, details) => new LocError("network_error", message, details);
116
+ var timeout = (message, details) => new LocError("timeout", message, details);
117
+
118
+ // src/loc/cache.ts
119
+ var Cache = class {
120
+ constructor(ttlMs, maxEntries) {
121
+ this.ttlMs = ttlMs;
122
+ this.maxEntries = maxEntries;
123
+ }
124
+ ttlMs;
125
+ maxEntries;
126
+ entries = /* @__PURE__ */ new Map();
127
+ get(key) {
128
+ const entry = this.entries.get(key);
129
+ if (!entry) return void 0;
130
+ if (entry.expiresAt <= Date.now()) {
131
+ this.entries.delete(key);
132
+ return void 0;
133
+ }
134
+ this.entries.delete(key);
135
+ this.entries.set(key, entry);
136
+ return entry.value;
137
+ }
138
+ set(key, value) {
139
+ if (this.ttlMs <= 0) return;
140
+ this.entries.delete(key);
141
+ this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
142
+ while (this.entries.size > this.maxEntries) {
143
+ const oldest = this.entries.keys().next();
144
+ if (oldest.done) break;
145
+ this.entries.delete(oldest.value);
146
+ }
147
+ }
148
+ get size() {
149
+ return this.entries.size;
150
+ }
151
+ };
152
+
153
+ // src/loc/http.ts
154
+ var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
155
+ var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
156
+ var LONGEST_WAIT_MS = 3e4;
157
+ var RETRIES_AFTER_SILENCE = 1;
158
+ function parseRetryAfter(value, now = Date.now()) {
159
+ if (!value) return null;
160
+ const trimmed = value.trim();
161
+ const seconds = Number(trimmed);
162
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
163
+ const at = Date.parse(trimmed);
164
+ if (Number.isNaN(at)) return null;
165
+ return Math.max(0, at - now);
166
+ }
167
+ function backoffMs(attempt) {
168
+ const base2 = Math.min(16e3, 1e3 * 2 ** attempt);
169
+ return base2 + Math.floor(Math.random() * 500);
170
+ }
171
+ async function fetchText(options) {
172
+ const { url, userAgent, timeoutMs, maxRetries, limiter, logger } = options;
173
+ const doFetch = options.fetchImpl ?? fetch;
174
+ let lastError = null;
175
+ let askedWaitMs = 0;
176
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
177
+ if (askedWaitMs > 0) {
178
+ logger.debug(`waiting ${askedWaitMs}ms, as asked`);
179
+ await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
180
+ askedWaitMs = 0;
181
+ }
182
+ await limiter.beforeRequest();
183
+ const controller = new AbortController();
184
+ const deadline = setTimeout(() => controller.abort(), timeoutMs);
185
+ try {
186
+ logger.debug(`GET ${url}`);
187
+ const response = await doFetch(url, {
188
+ signal: controller.signal,
189
+ redirect: "follow",
190
+ headers: { "user-agent": userAgent, accept: "application/json, text/plain, */*" }
191
+ });
192
+ if (response.ok) {
193
+ limiter.succeeded();
194
+ return await response.text();
195
+ }
196
+ if (PUSH_BACK.has(response.status)) {
197
+ limiter.pushBack();
198
+ await response.body?.cancel().catch(() => void 0);
199
+ const asked = parseRetryAfter(response.headers.get("retry-after"));
200
+ if (asked !== null && asked > LONGEST_WAIT_MS) {
201
+ throw rateLimited(
202
+ `The Library of Congress asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
203
+ { url, status: response.status }
204
+ );
205
+ }
206
+ if (attempt >= maxRetries) {
207
+ throw rateLimited(
208
+ `The Library of Congress asked this client to slow down (HTTP ${response.status}).`,
209
+ { url, status: response.status }
210
+ );
211
+ }
212
+ askedWaitMs = asked ?? backoffMs(attempt);
213
+ lastError = new Error(`HTTP ${response.status}`);
214
+ continue;
215
+ }
216
+ if (RETRYABLE.has(response.status) && attempt < maxRetries) {
217
+ await response.body?.cancel().catch(() => void 0);
218
+ lastError = new Error(`HTTP ${response.status}`);
219
+ askedWaitMs = backoffMs(attempt);
220
+ continue;
221
+ }
222
+ if (response.status === 400 || response.status === 422) {
223
+ throw invalidInput(
224
+ "The Library of Congress would not accept this request.",
225
+ "Check the query. A quotation mark or bracket left unbalanced is read as syntax."
226
+ );
227
+ }
228
+ if (response.status === 404 || response.status === 410) {
229
+ throw notFound("The Library of Congress holds nothing at this address.", {
230
+ url,
231
+ status: response.status
232
+ });
233
+ }
234
+ throw networkError(`The Library of Congress answered HTTP ${response.status}.`, {
235
+ url,
236
+ status: response.status
237
+ });
238
+ } catch (error) {
239
+ clearTimeout(deadline);
240
+ if (error instanceof Error && error.name === "LocError") throw error;
241
+ if (error instanceof Error && error.name === "AbortError") {
242
+ lastError = error;
243
+ if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
244
+ throw timeout(
245
+ `No answer from the Library of Congress within ${timeoutMs}ms. The newspaper corpus in particular can take tens of seconds.`,
246
+ { url }
247
+ );
248
+ }
249
+ askedWaitMs = backoffMs(attempt);
250
+ continue;
251
+ }
252
+ lastError = error instanceof Error ? error : new Error(String(error));
253
+ if (attempt >= maxRetries) {
254
+ throw networkError(`Could not reach the Library of Congress: ${lastError.message}`, {
255
+ url
256
+ });
257
+ }
258
+ askedWaitMs = backoffMs(attempt);
259
+ continue;
260
+ } finally {
261
+ clearTimeout(deadline);
262
+ }
263
+ }
264
+ throw networkError(
265
+ `Could not reach the Library of Congress: ${lastError?.message ?? "no attempt was made"}`,
266
+ { url }
267
+ );
268
+ }
269
+ async function fetchJson(options) {
270
+ const body = await fetchText(options);
271
+ try {
272
+ return JSON.parse(body);
273
+ } catch {
274
+ throw parseFailure("The Library of Congress answered with something that is not JSON.", {
275
+ url: options.url
276
+ });
277
+ }
278
+ }
279
+
280
+ // src/loc/paths.ts
281
+ var HOST = "https://www.loc.gov";
282
+ var FORMAT_ROUTE = {
283
+ books: "/books/",
284
+ photos: "/photos/",
285
+ maps: "/maps/",
286
+ audio: "/audio/",
287
+ "film-and-videos": "/film-and-videos/",
288
+ manuscripts: "/manuscripts/",
289
+ "notated-music": "/notated-music/",
290
+ newspapers: "/newspapers/"
291
+ };
292
+ var FORMAT_ROUTES = Object.keys(FORMAT_ROUTE);
293
+ var NEWSPAPER_PAGES_ROUTE = "/collections/chronicling-america/";
294
+ var COLLECTIONS_ROUTE = "/collections/";
295
+ var ITEM_ROUTE = "/item/";
296
+ var PARAM = {
297
+ /** Asks for JSON rather than a web page. */
298
+ format: "fo",
299
+ /** The words to look for. */
300
+ query: "q",
301
+ /** Results per page. */
302
+ perPage: "c",
303
+ /** Which page, from 1. */
304
+ page: "sp",
305
+ /** A year range, written from/to. */
306
+ dates: "dates",
307
+ /** Facet filters, several of them separated by a vertical bar. */
308
+ facets: "fa",
309
+ /** Sort order. */
310
+ sort: "sb",
311
+ /** Which top-level blocks to return, which is what keeps a page small. */
312
+ attributes: "at",
313
+ /** Widens a search past what the Library has digitised. */
314
+ all: "all"
315
+ };
316
+ var WANTED_BLOCKS = "results,pagination";
317
+ var FACET_FIELD = {
318
+ subject: "subject",
319
+ location: "location",
320
+ language: "language",
321
+ contributor: "contributor",
322
+ partof: "partof",
323
+ /** The state a newspaper was published in, as the newspaper corpus files it. */
324
+ state: "location_state",
325
+ /** A newspaper, named with the town and the years it ran. */
326
+ publication: "partof_title"
327
+ };
328
+ var SORT = {
329
+ relevance: "",
330
+ newest: "date_desc",
331
+ oldest: "date",
332
+ title: "title_s"
333
+ };
334
+ var ROW_FIELD = {
335
+ id: "id",
336
+ url: "url",
337
+ title: "title",
338
+ date: "date",
339
+ contributor: "contributor",
340
+ originalFormat: "original_format",
341
+ onlineFormat: "online_format",
342
+ location: "location",
343
+ subject: "subject",
344
+ description: "description",
345
+ partOf: "partof",
346
+ digitized: "digitized",
347
+ /** Newspaper pages only, from here down. */
348
+ pageNumber: "number_page",
349
+ publicationTitle: "partof_title",
350
+ state: "location_state",
351
+ /** How many records a collection gathers. */
352
+ count: "count",
353
+ /** Lists what a collection holds. */
354
+ items: "items"
355
+ };
356
+ var ITEM_FIELD = {
357
+ id: "id",
358
+ url: "url",
359
+ title: "title",
360
+ date: "date",
361
+ contributorNames: "contributor_names",
362
+ description: "description",
363
+ notes: "notes",
364
+ subjects: "subjects",
365
+ location: "location",
366
+ language: "language",
367
+ partOf: "partof",
368
+ repository: "repository",
369
+ callNumber: "call_number",
370
+ rights: "rights_advisory",
371
+ rightsFallback: "rights_information",
372
+ originalFormat: "original_format"
373
+ };
374
+ var MOST_SUBJECTS = 12;
375
+ var itemUrl = (identifier) => `${HOST}${ITEM_ROUTE}${identifier.split("/").map(encodeURIComponent).join("/")}/`;
376
+
377
+ // src/loc/urls.ts
378
+ var YEAR_BOUND = { earliest: 1e3, latest: 9999 };
379
+ function base(path) {
380
+ const url = new URL(HOST + path);
381
+ url.searchParams.set(PARAM.format, "json");
382
+ url.searchParams.set(PARAM.attributes, WANTED_BLOCKS);
383
+ return url;
384
+ }
385
+ function facetString(facets) {
386
+ return Object.entries(facets).filter(([, value]) => typeof value === "string" && value.trim() !== "").map(([field, value]) => `${FACET_FIELD[field]}:${value.trim().toLowerCase()}`).join("|");
387
+ }
388
+ function catalogueUrl(q) {
389
+ const url = base(FORMAT_ROUTE[q.format]);
390
+ url.searchParams.set(PARAM.query, q.query);
391
+ url.searchParams.set(PARAM.perPage, String(q.limit));
392
+ url.searchParams.set(PARAM.page, String(q.page));
393
+ if (q.yearFrom !== void 0 || q.yearTo !== void 0) {
394
+ const from = q.yearFrom ?? YEAR_BOUND.earliest;
395
+ const to = q.yearTo ?? YEAR_BOUND.latest;
396
+ url.searchParams.set(PARAM.dates, `${from}/${to}`);
397
+ }
398
+ const facets = facetString(q.facets ?? {});
399
+ if (facets !== "") url.searchParams.set(PARAM.facets, facets);
400
+ const sort = q.sort ? SORT[q.sort] : "";
401
+ if (sort !== "") url.searchParams.set(PARAM.sort, sort);
402
+ if (!q.onlineOnly) url.searchParams.set(PARAM.all, "true");
403
+ return url.toString();
404
+ }
405
+ function newspaperPagesUrl(query, limit, page, filters = {}) {
406
+ const url = base(NEWSPAPER_PAGES_ROUTE);
407
+ url.searchParams.set(PARAM.query, query);
408
+ url.searchParams.set(PARAM.perPage, String(limit));
409
+ url.searchParams.set(PARAM.page, String(page));
410
+ if (filters.yearFrom !== void 0 || filters.yearTo !== void 0) {
411
+ const from = filters.yearFrom ?? YEAR_BOUND.earliest;
412
+ const to = filters.yearTo ?? YEAR_BOUND.latest;
413
+ url.searchParams.set(PARAM.dates, `${from}/${to}`);
414
+ }
415
+ const facets = facetString(filters.facets ?? {});
416
+ if (facets !== "") url.searchParams.set(PARAM.facets, facets);
417
+ return url.toString();
418
+ }
419
+ function collectionsUrl(limit, page) {
420
+ const url = base(COLLECTIONS_ROUTE);
421
+ url.searchParams.set(PARAM.perPage, String(limit));
422
+ url.searchParams.set(PARAM.page, String(page));
423
+ return url.toString();
424
+ }
425
+ function itemDocumentUrl(identifier) {
426
+ const segments = identifier.trim().replace(/^\/+|\/+$/g, "").split("/").filter((segment) => segment !== "");
427
+ if (segments.some((segment) => /^\.+$/.test(segment))) {
428
+ throw invalidInput(
429
+ `"${identifier}" is not an identifier: it carries a relative path segment.`,
430
+ "Take the identifier from a search result rather than building one."
431
+ );
432
+ }
433
+ const path = segments.map(encodeURIComponent).join("/");
434
+ const url = new URL(`${HOST}${ITEM_ROUTE}${path}/`);
435
+ url.searchParams.set(PARAM.format, "json");
436
+ return url.toString();
437
+ }
438
+ function identifierFrom(address) {
439
+ if (!address) return null;
440
+ const trimmed = address.trim();
441
+ if (trimmed === "") return null;
442
+ const absolute = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
443
+ let parsed;
444
+ try {
445
+ parsed = new URL(absolute);
446
+ } catch {
447
+ return null;
448
+ }
449
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
450
+ if (path === "") return null;
451
+ const segments = path.split("/");
452
+ if (parsed.hostname === "lccn.loc.gov") return segments[0] ?? null;
453
+ const first = segments[0];
454
+ if (first === "item" || first === "resource") {
455
+ const rest = segments.slice(1);
456
+ return rest.length > 0 ? rest.join("/") : null;
457
+ }
458
+ if (first === "collections") return segments[1] ?? null;
459
+ return null;
460
+ }
461
+
462
+ // src/loc/parse.ts
463
+ var asObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
464
+ function asStrings(value) {
465
+ if (typeof value === "string") return value.trim() === "" ? [] : [value];
466
+ if (Array.isArray(value))
467
+ return value.filter((v) => typeof v === "string" && v.trim() !== "");
468
+ return [];
469
+ }
470
+ var asString = (value) => asStrings(value)[0] ?? null;
471
+ function asLabels(value) {
472
+ const entries = Array.isArray(value) ? value : [value];
473
+ const labels = [];
474
+ for (const entry of entries) {
475
+ if (typeof entry === "string") {
476
+ if (entry.trim() !== "") labels.push(entry);
477
+ continue;
478
+ }
479
+ const object = asObject(entry);
480
+ if (!object) continue;
481
+ const titled = asString(object.title);
482
+ if (titled !== null) {
483
+ labels.push(titled);
484
+ continue;
485
+ }
486
+ for (const key of Object.keys(object)) {
487
+ if (key.trim() !== "") labels.push(key);
488
+ }
489
+ }
490
+ return labels;
491
+ }
492
+ function asNumber(value) {
493
+ if (typeof value === "number" && Number.isFinite(value)) return value;
494
+ if (typeof value === "string" && value.trim() !== "") {
495
+ const n = Number(value);
496
+ if (Number.isFinite(n)) return n;
497
+ }
498
+ if (Array.isArray(value)) return asNumber(value[0]);
499
+ return null;
500
+ }
501
+ function asPageNumber(value) {
502
+ const text = asString(value);
503
+ if (text === null) return null;
504
+ const digits = text.trim();
505
+ if (!/^\d+$/.test(digits)) return null;
506
+ const n = Number(digits);
507
+ return Number.isFinite(n) && n > 0 ? n : null;
508
+ }
509
+ var PLAUSIBLE_YEAR = { first: 1e3, last: 2200 };
510
+ function asYear(value) {
511
+ const plausible = (year) => Number.isFinite(year) && year >= PLAUSIBLE_YEAR.first && year <= PLAUSIBLE_YEAR.last ? Math.trunc(year) : null;
512
+ const text = asString(value);
513
+ if (text !== null) {
514
+ const m = /(\d{4})/.exec(text);
515
+ if (m) return plausible(Number(m[1]));
516
+ }
517
+ const direct = asNumber(value);
518
+ return direct === null ? null : plausible(direct);
519
+ }
520
+ function plainText(value) {
521
+ return value.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
522
+ }
523
+ function toPaging(payload, url) {
524
+ const root = asObject(payload);
525
+ const pagination = root ? asObject(root.pagination) : null;
526
+ if (!pagination) {
527
+ throw parseFailure(
528
+ "The answer carried no pagination block, so nothing states how many results match.",
529
+ {
530
+ url
531
+ }
532
+ );
533
+ }
534
+ const resultCount = asNumber(pagination.of);
535
+ if (resultCount === null) {
536
+ throw parseFailure("The answer carried no readable count of results.", { url });
537
+ }
538
+ return {
539
+ resultCount,
540
+ pageCount: asNumber(pagination.total),
541
+ currentPage: asNumber(pagination.current),
542
+ perPage: asNumber(pagination.perpage)
543
+ };
544
+ }
545
+ function rowsOf(payload, url) {
546
+ const root = asObject(payload);
547
+ if (!root || !Array.isArray(root.results)) {
548
+ throw parseFailure("The answer carried its rows in a shape this server cannot read.", { url });
549
+ }
550
+ return root.results.map(asObject).filter((r) => r !== null);
551
+ }
552
+ var creatorOf = (row) => asStrings(row[ROW_FIELD.contributor]).map(plainText).join(", ") || null;
553
+ function sourceUrlOf(row, identifier) {
554
+ const raw = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
555
+ if (raw) {
556
+ const trimmed = raw.trim();
557
+ if (trimmed.startsWith("//")) return `https:${trimmed}`;
558
+ if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
559
+ if (trimmed.startsWith("https://")) return trimmed;
560
+ }
561
+ return identifier === null ? "" : itemUrl(identifier);
562
+ }
563
+ function toSearchResults(payload, url, onSkip) {
564
+ const rows = rowsOf(payload, url);
565
+ const records = [];
566
+ let skipped = 0;
567
+ for (const row of rows) {
568
+ const title = asString(row[ROW_FIELD.title]);
569
+ const identifier = identifierFrom(asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]));
570
+ const source = sourceUrlOf(row, identifier);
571
+ if (title === null && source === "") {
572
+ skipped += 1;
573
+ continue;
574
+ }
575
+ records.push({
576
+ identifier,
577
+ title: title === null ? null : plainText(title),
578
+ creator: creatorOf(row),
579
+ year: asYear(row[ROW_FIELD.date]),
580
+ date: asString(row[ROW_FIELD.date]),
581
+ format: asString(row[ROW_FIELD.originalFormat]),
582
+ location: asStrings(row[ROW_FIELD.location]),
583
+ subjects: asStrings(row[ROW_FIELD.subject]).slice(0, MOST_SUBJECTS),
584
+ online: row[ROW_FIELD.digitized] === true,
585
+ sourceUrl: source
586
+ });
587
+ }
588
+ if (skipped > 0) onSkip(skipped);
589
+ if (rows.length > 0 && records.length === 0) {
590
+ throw parseFailure(`${rows.length} rows came back and none could be read.`, { url });
591
+ }
592
+ return { paging: toPaging(payload, url), records };
593
+ }
594
+ var JOINING_WORDS = /* @__PURE__ */ new Set([
595
+ "the",
596
+ "and",
597
+ "for",
598
+ "with",
599
+ "that",
600
+ "this",
601
+ "from",
602
+ "was",
603
+ "were",
604
+ "are",
605
+ "his",
606
+ "her",
607
+ "its",
608
+ "not",
609
+ "but",
610
+ "had",
611
+ "has",
612
+ "you",
613
+ "all",
614
+ "any",
615
+ "who"
616
+ ]);
617
+ function queryTerms(query) {
618
+ const terms = [];
619
+ const phrases = query.match(/"([^"]+)"/g) ?? [];
620
+ for (const phrase of phrases) {
621
+ const inner = phrase.slice(1, -1).trim().toLowerCase();
622
+ if (inner !== "") terms.push(inner);
623
+ }
624
+ const rest = query.replace(/"[^"]*"/g, " ");
625
+ for (const word of rest.split(/[^\p{L}\p{N}'-]+/u)) {
626
+ const lower = word.trim().toLowerCase();
627
+ if (lower.length < 3 || JOINING_WORDS.has(lower)) continue;
628
+ terms.push(lower);
629
+ }
630
+ return [...new Set(terms)];
631
+ }
632
+ function toWordBoundary(text, index, direction) {
633
+ const limit = direction === -1 ? 0 : text.length;
634
+ let at = index;
635
+ for (let step = 0; step < 30 && at !== limit; step += 1) {
636
+ if (text[at] === " ") return at;
637
+ at += direction;
638
+ }
639
+ return index;
640
+ }
641
+ function excerptsFor(text, terms, budget) {
642
+ const clean = text.replace(/\s+/g, " ").trim();
643
+ if (clean === "") return { passages: [], located: false };
644
+ const haystack = clean.toLowerCase();
645
+ const found = [];
646
+ for (const term of terms) {
647
+ let from = 0;
648
+ while (from <= haystack.length - term.length) {
649
+ const at = haystack.indexOf(term, from);
650
+ if (at === -1) break;
651
+ found.push({ at, length: term.length });
652
+ from = at + term.length;
653
+ }
654
+ }
655
+ if (found.length === 0) {
656
+ const opening = clean.slice(0, budget.maxChars);
657
+ const cut = opening.length < clean.length;
658
+ return {
659
+ passages: [cut ? `${opening.trimEnd()}\u2026` : opening],
660
+ located: false
661
+ };
662
+ }
663
+ found.sort((a, b) => a.at - b.at);
664
+ const windows = [];
665
+ for (const hit of found) {
666
+ const half = Math.max(0, Math.floor((budget.maxChars - hit.length) / 2));
667
+ const start = toWordBoundary(clean, Math.max(0, hit.at - half), -1);
668
+ const end = toWordBoundary(clean, Math.min(clean.length, hit.at + hit.length + half), 1);
669
+ const previous = windows[windows.length - 1];
670
+ if (previous && start <= previous.end) {
671
+ previous.end = Math.max(previous.end, end);
672
+ continue;
673
+ }
674
+ windows.push({ start, end });
675
+ }
676
+ const passages = windows.slice(0, budget.maxCount).map(({ start, end }) => {
677
+ const body = clean.slice(start, Math.min(end, start + budget.maxChars)).trim();
678
+ const opensMidway = start > 0;
679
+ const closesEarly = Math.min(end, start + budget.maxChars) < clean.length;
680
+ return `${opensMidway ? "\u2026" : ""}${body}${closesEarly ? "\u2026" : ""}`;
681
+ });
682
+ return { passages, located: true };
683
+ }
684
+ function toNewspaperResults(payload, url, query, budget, onSkip) {
685
+ const rows = rowsOf(payload, url);
686
+ const terms = queryTerms(query);
687
+ const hits = [];
688
+ let skipped = 0;
689
+ for (const row of rows) {
690
+ const address = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
691
+ const source = sourceUrlOf(row, identifierFrom(address));
692
+ if (source === "") {
693
+ skipped += 1;
694
+ continue;
695
+ }
696
+ const pageText = asStrings(row[ROW_FIELD.description]).join(" ");
697
+ const { passages, located } = excerptsFor(pageText, terms, budget);
698
+ const publication = asString(row[ROW_FIELD.publicationTitle]);
699
+ const title = asString(row[ROW_FIELD.title]);
700
+ hits.push({
701
+ identifier: identifierFrom(address),
702
+ title: title === null ? null : plainText(title),
703
+ // A newspaper page is credited to whoever contributed the scan, which is
704
+ // a library rather than a writer.
705
+ creator: creatorOf(row),
706
+ year: asYear(row[ROW_FIELD.date]),
707
+ pageNumber: asPageNumber(row[ROW_FIELD.pageNumber]),
708
+ publishedOn: asString(row[ROW_FIELD.date]),
709
+ publication,
710
+ state: asString(row[ROW_FIELD.state]),
711
+ wordsLocated: located,
712
+ excerpts: passages,
713
+ sourceUrl: source
714
+ });
715
+ }
716
+ if (skipped > 0) onSkip(skipped);
717
+ if (rows.length > 0 && hits.length === 0) {
718
+ throw parseFailure(`${rows.length} matches came back and none could be read.`, { url });
719
+ }
720
+ return { paging: toPaging(payload, url), hits };
721
+ }
722
+ function toResources(value) {
723
+ if (!Array.isArray(value)) return [];
724
+ const resources = [];
725
+ for (const entry of value) {
726
+ const resource = asObject(entry);
727
+ if (!resource) continue;
728
+ const files = resource.files;
729
+ const fileCount = Array.isArray(files) ? files.reduce((sum, group) => sum + (Array.isArray(group) ? group.length : 1), 0) : asNumber(files);
730
+ resources.push({
731
+ caption: asString(resource.caption),
732
+ fileCount,
733
+ url: asString(resource.url),
734
+ imageUrl: asString(resource.image)
735
+ });
736
+ }
737
+ return resources;
738
+ }
739
+ function toCitations(value) {
740
+ const block = asObject(value);
741
+ if (!block) return {};
742
+ const citations = {};
743
+ for (const [style, text] of Object.entries(block)) {
744
+ if (typeof text !== "string" || text.trim() === "") continue;
745
+ citations[style] = plainText(text);
746
+ }
747
+ return citations;
748
+ }
749
+ function toItemDetail(payload, identifier, url) {
750
+ const root = asObject(payload);
751
+ if (!root) throw parseFailure("The item answer was not an object.", { url });
752
+ if (asNumber(root.status) === 404) {
753
+ throw notFound(`The Library of Congress has no record called "${identifier}".`, { url });
754
+ }
755
+ const item = asObject(root.item);
756
+ if (!item) {
757
+ throw parseFailure(
758
+ `The record for "${identifier}" came back without the item block this server reads.`,
759
+ { url }
760
+ );
761
+ }
762
+ if (Object.keys(item).length === 0) {
763
+ throw notFound(`The Library of Congress has no record called "${identifier}".`, { url });
764
+ }
765
+ const description = asStrings(item[ITEM_FIELD.description]).map(plainText).join("\n\n");
766
+ const title = asString(item[ITEM_FIELD.title]);
767
+ const source = asString(item[ITEM_FIELD.url]) ?? itemUrl(identifier);
768
+ return {
769
+ identifier,
770
+ title: title === null ? null : plainText(title),
771
+ creator: asStrings(item[ITEM_FIELD.contributorNames]).map(plainText).join(", ") || null,
772
+ year: asYear(item[ITEM_FIELD.date]),
773
+ date: asString(item[ITEM_FIELD.date]),
774
+ format: asString(item[ITEM_FIELD.originalFormat]),
775
+ description: description === "" ? null : description,
776
+ notes: asStrings(item[ITEM_FIELD.notes]).map(plainText),
777
+ subjects: asLabels(item[ITEM_FIELD.subjects]).slice(0, MOST_SUBJECTS),
778
+ location: asLabels(item[ITEM_FIELD.location]),
779
+ language: asLabels(item[ITEM_FIELD.language]),
780
+ partOf: asLabels(item[ITEM_FIELD.partOf]),
781
+ repository: asStrings(item[ITEM_FIELD.repository]).map(plainText).join(" ") || null,
782
+ callNumber: asString(item[ITEM_FIELD.callNumber]),
783
+ rights: asString(item[ITEM_FIELD.rights]) ?? asString(item[ITEM_FIELD.rightsFallback]),
784
+ citations: toCitations(root.cite_this),
785
+ resources: toResources(root.resources),
786
+ sourceUrl: source.startsWith("//") ? `https:${source}` : source,
787
+ raw: item
788
+ };
789
+ }
790
+ function toCollections(payload, url, onSkip) {
791
+ const rows = rowsOf(payload, url);
792
+ const collections = [];
793
+ let skipped = 0;
794
+ for (const row of rows) {
795
+ const title = asString(row[ROW_FIELD.title]);
796
+ if (title === null) {
797
+ skipped += 1;
798
+ continue;
799
+ }
800
+ const address = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
801
+ const inner = asObject(row.item);
802
+ const description = asStrings(row[ROW_FIELD.description]).map(plainText).join("\n\n");
803
+ collections.push({
804
+ identifier: identifierFrom(address),
805
+ title: plainText(title),
806
+ description: description === "" ? null : description,
807
+ itemCount: asNumber(row[ROW_FIELD.count]),
808
+ subjects: asStrings(row[ROW_FIELD.subject]).slice(0, MOST_SUBJECTS),
809
+ formats: inner ? asStrings(inner.formats) : [],
810
+ sourceUrl: sourceUrlOf(row, null),
811
+ itemsUrl: asString(row[ROW_FIELD.items])
812
+ });
813
+ }
814
+ if (skipped > 0) onSkip(skipped);
815
+ if (rows.length > 0 && collections.length === 0) {
816
+ throw parseFailure(`${rows.length} collections came back and none could be read.`, { url });
817
+ }
818
+ return { paging: toPaging(payload, url), collections };
819
+ }
820
+
821
+ // src/loc/rateLimiter.ts
822
+ var RateLimiter = class {
823
+ baseIntervalMs;
824
+ maxIntervalMs;
825
+ intervalMs;
826
+ lastStartedAt = 0;
827
+ queue = Promise.resolve();
828
+ calmStreak = 0;
829
+ constructor(options) {
830
+ this.baseIntervalMs = options.intervalMs;
831
+ this.maxIntervalMs = options.maxIntervalMs ?? options.intervalMs * 8;
832
+ this.intervalMs = options.intervalMs;
833
+ }
834
+ /** The spacing in force, which callers report rather than guess. */
835
+ get currentIntervalMs() {
836
+ return this.intervalMs;
837
+ }
838
+ /**
839
+ * Run `task` with nothing else in flight.
840
+ *
841
+ * This only serialises. Spacing is claimed per attempt through
842
+ * `beforeRequest`, because a task that retries makes several requests and
843
+ * each of them owes the site the same gap.
844
+ */
845
+ schedule(task) {
846
+ const result = this.queue.then(task, task);
847
+ this.queue = result.then(
848
+ () => void 0,
849
+ () => void 0
850
+ );
851
+ return result;
852
+ }
853
+ /** Wait until enough time has passed since the previous request began. */
854
+ async beforeRequest() {
855
+ const elapsed = Date.now() - this.lastStartedAt;
856
+ const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
857
+ if (wait > 0) await sleep(wait);
858
+ this.lastStartedAt = Date.now();
859
+ }
860
+ /** Called when the site asks for room: double the gap, up to the ceiling. */
861
+ pushBack() {
862
+ this.calmStreak = 0;
863
+ this.intervalMs = Math.min(this.maxIntervalMs, this.intervalMs * 2);
864
+ }
865
+ /**
866
+ * Called on a clean answer. Recovery takes several in a row, so one lucky
867
+ * response after a rough patch does not undo the caution that earned it.
868
+ */
869
+ succeeded() {
870
+ if (this.intervalMs === this.baseIntervalMs) return;
871
+ this.calmStreak += 1;
872
+ if (this.calmStreak < 3) return;
873
+ this.calmStreak = 0;
874
+ this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
875
+ }
876
+ };
877
+ function sleep(ms) {
878
+ return new Promise((resolve) => setTimeout(resolve, ms));
879
+ }
880
+
881
+ // src/loc/client.ts
882
+ function withGuarantees(config) {
883
+ const defaults = loadConfig({});
884
+ const number = (value, fallback) => {
885
+ const parsed = Number(value);
886
+ return Number.isFinite(parsed) ? parsed : fallback;
887
+ };
888
+ const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
889
+ const identifier = defaults.userAgent;
890
+ return {
891
+ ...config,
892
+ // A caller may say who they are. Appending rather than replacing means the
893
+ // Library can always tell which software is calling, and reach someone.
894
+ userAgent: claimed === "" || claimed.includes(REPO_URL) ? identifier : `${claimed} ${identifier}`,
895
+ minIntervalMs: Math.max(
896
+ MIN_ALLOWED_INTERVAL_MS,
897
+ number(config.minIntervalMs, defaults.minIntervalMs)
898
+ ),
899
+ timeoutMs: number(config.timeoutMs, defaults.timeoutMs),
900
+ newspaperTimeoutMs: number(config.newspaperTimeoutMs, defaults.newspaperTimeoutMs),
901
+ maxRetries: number(config.maxRetries, defaults.maxRetries),
902
+ cacheTtlMs: number(config.cacheTtlMs, defaults.cacheTtlMs),
903
+ cacheMaxEntries: number(config.cacheMaxEntries, defaults.cacheMaxEntries)
904
+ };
905
+ }
906
+ var LocClient = class {
907
+ config;
908
+ logger;
909
+ limiter;
910
+ cache;
911
+ fetchImpl;
912
+ constructor(options = {}) {
913
+ const base2 = { ...loadConfig(), ...options.config };
914
+ this.config = withGuarantees(base2);
915
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
916
+ this.limiter = new RateLimiter({ intervalMs: this.config.minIntervalMs });
917
+ this.cache = new Cache(this.config.cacheTtlMs, this.config.cacheMaxEntries);
918
+ this.fetchImpl = options.fetchImpl;
919
+ }
920
+ /** The pacing in force, which widens when the Library pushes back. */
921
+ get intervalMs() {
922
+ return this.limiter.currentIntervalMs;
923
+ }
924
+ /** What the Library sees this client call itself. */
925
+ get userAgent() {
926
+ return this.config.userAgent;
927
+ }
928
+ /**
929
+ * `cacheKey` is stated rather than taken from the address, because two reads
930
+ * of one address can produce different values: a passage cut to three hundred
931
+ * characters is not the same value as the same passage cut to a thousand, and
932
+ * serving one for the other would answer a budget the caller never set.
933
+ */
934
+ async read(cacheKey, url, parse, timeoutMs = this.config.timeoutMs) {
935
+ const cached = this.cache.get(cacheKey);
936
+ if (cached !== void 0) {
937
+ this.logger.debug(`cache hit ${cacheKey}`);
938
+ return { data: cached, cached: true };
939
+ }
940
+ let skipped = 0;
941
+ const payload = await this.limiter.schedule(
942
+ () => fetchJson({
943
+ url,
944
+ userAgent: this.config.userAgent,
945
+ timeoutMs,
946
+ maxRetries: this.config.maxRetries,
947
+ limiter: this.limiter,
948
+ logger: this.logger,
949
+ ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
950
+ })
951
+ );
952
+ const data = parse(payload, (n) => {
953
+ skipped += n;
954
+ this.logger.warn(`skipped ${n} unreadable row(s) from ${url}`);
955
+ });
956
+ this.cache.set(cacheKey, data);
957
+ return skipped > 0 ? { data, cached: false, skipped } : { data, cached: false };
958
+ }
959
+ searchItems(query) {
960
+ const url = catalogueUrl(query);
961
+ return this.read(url, url, (payload, onSkip) => toSearchResults(payload, url, onSkip));
962
+ }
963
+ searchNewspapers(query, limit, page, budget, filters = {}) {
964
+ const trimmed = query.trim();
965
+ if (trimmed === "") {
966
+ return Promise.reject(invalidInput("Words to look for are required."));
967
+ }
968
+ const url = newspaperPagesUrl(trimmed, limit, page, filters);
969
+ const key = `${url}#${budget.maxChars}x${budget.maxCount}`;
970
+ return this.read(
971
+ key,
972
+ url,
973
+ (payload, onSkip) => toNewspaperResults(payload, url, trimmed, budget, onSkip),
974
+ // Reading the text of millions of pages takes the site far longer than
975
+ // answering from the catalogue, so this route carries a budget of its own.
976
+ this.config.newspaperTimeoutMs
977
+ );
978
+ }
979
+ getItem(identifier) {
980
+ const trimmed = identifier.trim();
981
+ if (trimmed === "") {
982
+ return Promise.reject(invalidInput("A record identifier is required."));
983
+ }
984
+ const url = itemDocumentUrl(trimmed);
985
+ return this.read(url, url, (payload) => toItemDetail(payload, trimmed, url));
986
+ }
987
+ listCollections(limit, page) {
988
+ const url = collectionsUrl(limit, page);
989
+ return this.read(url, url, (payload, onSkip) => toCollections(payload, url, onSkip));
990
+ }
991
+ };
992
+
993
+ // src/tools/getItem.ts
994
+ import { z as z2 } from "zod";
995
+
996
+ // src/tools/shared.ts
997
+ import { z } from "zod";
998
+ var MAX_TEXT_CHARS = 2200;
999
+ var ATTRIBUTION = "Source: Library of Congress";
1000
+ var recordSchema = z.object({
1001
+ identifier: z.string().nullable().describe(
1002
+ "Pass this to get_item to read the full record. Null when the row names no address a record can be read at."
1003
+ ),
1004
+ title: z.string().nullable(),
1005
+ creator: z.string().nullable().describe("Whoever the Library credits for the work."),
1006
+ year: z.number().int().nullable().describe("Read off 'date' when that date names a year."),
1007
+ date: z.string().nullable().describe("The date as published, which is often a range."),
1008
+ format: z.string().nullable().describe("What the thing is: book, photo, map, newspaper."),
1009
+ location: z.array(z.string()).describe("Places the record is catalogued under."),
1010
+ subjects: z.array(z.string()),
1011
+ online: z.boolean().describe("Whether a digitised copy can be read online."),
1012
+ source_url: z.string().describe("Public page. Show this when citing the record.")
1013
+ });
1014
+ function indentMarkerLines(body) {
1015
+ return body.replace(/^(Note:|Source:)/gm, " $1");
1016
+ }
1017
+ function ok(structured, body, options = {}) {
1018
+ const credit = options.sourceUrl ? `${ATTRIBUTION} \u2014 ${options.sourceUrl}` : ATTRIBUTION;
1019
+ const noteLines = (options.notes ?? []).map((note) => `Note: ${note}`);
1020
+ while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) noteLines.pop();
1021
+ const trailer = [...noteLines, credit].join("\n");
1022
+ const cut = "\n\n[shortened; the full result is in the structured output]";
1023
+ const budget = MAX_TEXT_CHARS - `
1024
+
1025
+ ${trailer}`.length;
1026
+ const safe = indentMarkerLines(body);
1027
+ const text = safe.length <= budget ? `${safe}
1028
+
1029
+ ${trailer}` : `${truncate(safe, Math.max(0, budget - cut.length))}${cut}
1030
+
1031
+ ${trailer}`;
1032
+ return { content: [{ type: "text", text }], structuredContent: structured };
1033
+ }
1034
+ function toToolError(error) {
1035
+ const known = error instanceof LocError ? error : new LocError("network_error", error instanceof Error ? error.message : String(error));
1036
+ const lines = [`[${known.code}] ${known.message}`];
1037
+ if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);
1038
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1039
+ }
1040
+ function truncate(text, maxChars) {
1041
+ if (text.length <= maxChars) return text;
1042
+ return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
1043
+ }
1044
+ function sliceAtLineBoundary(text, offset, maxChars) {
1045
+ const rest = text.slice(offset);
1046
+ if (rest.length <= maxChars) return { slice: rest, nextOffset: null };
1047
+ const window = rest.slice(0, maxChars);
1048
+ const lastBreak = window.lastIndexOf("\n");
1049
+ let cut = lastBreak > 0 ? lastBreak : maxChars;
1050
+ const code = rest.charCodeAt(cut - 1);
1051
+ if (code >= 55296 && code <= 56319) cut -= 1;
1052
+ return { slice: rest.slice(0, cut), nextOffset: offset + cut };
1053
+ }
1054
+ function renderRecords(records) {
1055
+ return records.map((record, index) => {
1056
+ const bits = [
1057
+ `${index + 1}. ${record.title ?? record.identifier ?? "untitled"}`,
1058
+ record.date ? `(${record.date})` : "",
1059
+ record.creator ? `\xB7 ${record.creator}` : "",
1060
+ record.format ? `\xB7 ${record.format}` : "",
1061
+ record.identifier ? `\xB7 id: ${record.identifier}` : ""
1062
+ ];
1063
+ return `${bits.filter(Boolean).join(" ")}
1064
+ ${record.source_url}`;
1065
+ }).join("\n");
1066
+ }
1067
+ var OCR_CAVEAT = "Excerpts are the text a machine read off the scanned page, so misreadings are normal and words may be wrong. Quote them as scanned text, and follow source_url to check the page itself.";
1068
+ var RIGHTS_CAVEAT = "The Library states no terms of use on this record. Silence is not permission: check the record's page before republishing.";
1069
+
1070
+ // src/tools/getItem.ts
1071
+ var SECTIONS = ["basic", "citations", "resources", "full_metadata"];
1072
+ var getItemDescription = [
1073
+ "Read one Library of Congress record by its identifier, as returned by search_items or search_newspapers.",
1074
+ "An identifier can carry slashes: a single newspaper issue is named by its paper, its date and its edition together.",
1075
+ "Sections are opt-in: 'basic' is the default and covers what a description needs.",
1076
+ "'citations' returns the ready-made citations the Library publishes for the record.",
1077
+ "'resources' lists the served copies, such as page images and downloadable files.",
1078
+ "'full_metadata' returns every field the Library publishes for the record, which is large and rarely needed.",
1079
+ "A long description paginates: when 'next_offset' is not null, call again with 'offset' set to it."
1080
+ ].join(" ");
1081
+ var getItemInput = z2.object({
1082
+ identifier: z2.string().min(1).max(300).describe("Record identifier, such as '2017645459' or 'sn83045462/1929-02-03/ed-1'."),
1083
+ sections: z2.array(z2.enum(SECTIONS)).default(["basic"]).describe("Which parts to return. Each one beyond 'basic' adds to the size of the answer."),
1084
+ offset: z2.number().int().min(0).default(0).describe("Where to resume the description."),
1085
+ max_description_chars: z2.number().int().min(200).max(2e4).default(2e3).describe("Characters of the description to return in one call.")
1086
+ });
1087
+ var getItemOutput = z2.object({
1088
+ item: z2.object({
1089
+ identifier: z2.string(),
1090
+ title: z2.string().nullable(),
1091
+ creator: z2.string().nullable(),
1092
+ year: z2.number().int().nullable(),
1093
+ date: z2.string().nullable(),
1094
+ format: z2.string().nullable(),
1095
+ source_url: z2.string().describe("Public page. Show this when citing the record.")
1096
+ }),
1097
+ description: z2.string().nullable(),
1098
+ offset: z2.number().int(),
1099
+ next_offset: z2.number().int().nullable().describe("Pass as 'offset' to read the rest of the description. Null when it ends here."),
1100
+ notes_on_record: z2.array(z2.string()).describe("Notes the Library published about the record itself."),
1101
+ subjects: z2.array(z2.string()),
1102
+ location: z2.array(z2.string()),
1103
+ language: z2.array(z2.string()),
1104
+ part_of: z2.array(z2.string()).describe("Collections and divisions the record sits in."),
1105
+ repository: z2.string().nullable().describe("Where the original is held."),
1106
+ call_number: z2.string().nullable(),
1107
+ rights: z2.string().nullable().describe(
1108
+ "What the Library says about reuse. Null when it says nothing, which is not permission."
1109
+ ),
1110
+ citations: z2.record(z2.string(), z2.string()).optional(),
1111
+ resources: z2.array(
1112
+ z2.object({
1113
+ caption: z2.string().nullable(),
1114
+ file_count: z2.number().int().nullable(),
1115
+ url: z2.string().nullable(),
1116
+ image_url: z2.string().nullable()
1117
+ })
1118
+ ).optional(),
1119
+ full_metadata: z2.record(z2.string(), z2.unknown()).optional(),
1120
+ notes: z2.array(z2.string())
1121
+ });
1122
+ async function runGetItem(client, args) {
1123
+ try {
1124
+ const wanted = new Set(args.sections);
1125
+ const { data, cached } = await client.getItem(args.identifier);
1126
+ const notes = [];
1127
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1128
+ const full = data.description ?? "";
1129
+ const { slice, nextOffset } = sliceAtLineBoundary(
1130
+ full,
1131
+ args.offset,
1132
+ args.max_description_chars
1133
+ );
1134
+ if (nextOffset !== null) {
1135
+ notes.push(
1136
+ `The description runs to ${full.length} characters. Call again with offset=${nextOffset} for the rest.`
1137
+ );
1138
+ }
1139
+ if (slice === "" && args.offset > 0 && full.length > 0) {
1140
+ notes.push(
1141
+ `offset=${args.offset} is past the end of a description of ${full.length} characters. Call again with offset=0 to read it from the start.`
1142
+ );
1143
+ }
1144
+ const structured = {
1145
+ item: {
1146
+ identifier: data.identifier,
1147
+ title: data.title,
1148
+ creator: data.creator,
1149
+ year: data.year,
1150
+ date: data.date,
1151
+ format: data.format,
1152
+ source_url: data.sourceUrl
1153
+ },
1154
+ description: slice === "" ? null : slice,
1155
+ offset: args.offset,
1156
+ next_offset: nextOffset,
1157
+ notes_on_record: data.notes,
1158
+ subjects: data.subjects,
1159
+ location: data.location,
1160
+ language: data.language,
1161
+ part_of: data.partOf,
1162
+ repository: data.repository,
1163
+ call_number: data.callNumber,
1164
+ rights: data.rights,
1165
+ notes
1166
+ };
1167
+ if (wanted.has("citations")) {
1168
+ structured.citations = data.citations;
1169
+ if (Object.keys(data.citations).length === 0) {
1170
+ notes.push("The Library publishes no ready-made citation for this record.");
1171
+ }
1172
+ }
1173
+ if (wanted.has("resources")) {
1174
+ structured.resources = data.resources.map((resource) => ({
1175
+ caption: resource.caption,
1176
+ file_count: resource.fileCount,
1177
+ url: resource.url,
1178
+ image_url: resource.imageUrl
1179
+ }));
1180
+ if (data.resources.length === 0) {
1181
+ notes.push("The Library serves no copy of this record online.");
1182
+ }
1183
+ }
1184
+ if (wanted.has("full_metadata")) structured.full_metadata = data.raw ?? {};
1185
+ if (!data.rights) notes.push(RIGHTS_CAVEAT);
1186
+ const lines = [
1187
+ [data.title ?? data.identifier, data.date ? `(${data.date})` : ""].filter(Boolean).join(" "),
1188
+ data.creator ? `By ${data.creator}` : "",
1189
+ data.format ? `Kind: ${data.format}` : "",
1190
+ data.subjects.length > 0 ? `Subjects: ${data.subjects.join(", ")}` : "",
1191
+ data.partOf.length > 0 ? `Part of: ${data.partOf.join(", ")}` : "",
1192
+ data.repository ? `Held at: ${data.repository}` : "",
1193
+ data.rights ? `Rights: ${data.rights}` : ""
1194
+ ].filter(Boolean);
1195
+ if (slice !== "") lines.push("", slice);
1196
+ const resources = structured.resources;
1197
+ if (resources && resources.length > 0) {
1198
+ lines.push("", "Copies served online:");
1199
+ for (const resource of resources) {
1200
+ lines.push(` ${resource.caption ?? "copy"}${resource.url ? ` \u2014 ${resource.url}` : ""}`);
1201
+ }
1202
+ }
1203
+ return ok(structured, lines.join("\n"), { notes, sourceUrl: data.sourceUrl });
1204
+ } catch (error) {
1205
+ return toToolError(error);
1206
+ }
1207
+ }
1208
+
1209
+ // src/tools/listCollections.ts
1210
+ import { z as z3 } from "zod";
1211
+ var listCollectionsDescription = [
1212
+ "List the digital collections of the Library of Congress: bodies of material a curator chose, described and published together.",
1213
+ "Use it to see what is there before searching, since a collection names a corpus that a query would have to guess at.",
1214
+ "'collection_filter' on each row is the wording search_items takes as its 'collection' argument.",
1215
+ "'item_count' is how many records the collection gathers, which is the size of the corpus rather than the number of rows here."
1216
+ ].join(" ");
1217
+ var listCollectionsInput = z3.object({
1218
+ limit: z3.number().int().min(1).max(50).default(20).describe("Collections to return."),
1219
+ page: z3.number().int().min(1).max(100).default(1).describe("Which page of collections, from 1."),
1220
+ max_description_chars: z3.number().int().min(80).max(2e3).default(300).describe("Budget for one collection's description.")
1221
+ });
1222
+ var listCollectionsOutput = z3.object({
1223
+ total: z3.number().int().describe("Collections the Library publishes, not the number returned."),
1224
+ page: z3.number().int(),
1225
+ collections: z3.array(
1226
+ z3.object({
1227
+ identifier: z3.string().nullable().describe("The slug the collection is addressed by."),
1228
+ title: z3.string(),
1229
+ collection_filter: z3.string().describe("Pass as 'collection' to search_items to keep results within this collection."),
1230
+ description: z3.string().nullable(),
1231
+ item_count: z3.number().int().nullable().describe("Records the collection gathers. Null when the Library states none."),
1232
+ subjects: z3.array(z3.string()),
1233
+ formats: z3.array(z3.string()).describe("Kinds of thing the collection holds."),
1234
+ source_url: z3.string(),
1235
+ items_url: z3.string().nullable().describe("Lists what the collection holds, page by page.")
1236
+ })
1237
+ ),
1238
+ notes: z3.array(z3.string())
1239
+ });
1240
+ async function runListCollections(client, args) {
1241
+ try {
1242
+ const { data, cached, skipped } = await client.listCollections(args.limit, args.page);
1243
+ const notes = [];
1244
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1245
+ if (skipped) {
1246
+ notes.push(
1247
+ `${skipped} row(s) came back in a shape this server could not read and were left out.`
1248
+ );
1249
+ }
1250
+ const collections = data.collections.map((collection) => ({
1251
+ identifier: collection.identifier,
1252
+ title: collection.title,
1253
+ // The filter takes the collection as the rows spell it, which is the
1254
+ // title in lower case.
1255
+ collection_filter: collection.title.toLowerCase(),
1256
+ description: collection.description === null ? null : truncate(collection.description, args.max_description_chars),
1257
+ item_count: collection.itemCount,
1258
+ subjects: collection.subjects,
1259
+ formats: collection.formats,
1260
+ source_url: collection.sourceUrl,
1261
+ items_url: collection.itemsUrl
1262
+ }));
1263
+ const total = data.paging.resultCount;
1264
+ if (total > collections.length) {
1265
+ notes.push(
1266
+ `${total} collections exist and ${collections.length} are shown. Ask for page ${args.page + 1} to continue.`
1267
+ );
1268
+ }
1269
+ if (collections.length === 0 && total > 0) {
1270
+ notes.push(
1271
+ `Page ${args.page} is past the last collection. ${total} exist, so ask for a lower page.`
1272
+ );
1273
+ }
1274
+ const body = collections.length === 0 ? total > 0 ? `Page ${args.page} is past the last of ${total} collections.` : "The Library published no collection here." : `${collections.length} of ${total} collections:
1275
+ ` + collections.map((collection, index) => {
1276
+ const head = [
1277
+ `${index + 1}. ${collection.title}`,
1278
+ collection.item_count === null ? "" : `\xB7 ${collection.item_count} records`,
1279
+ collection.formats.length > 0 ? `\xB7 ${collection.formats.join(", ")}` : ""
1280
+ ].filter(Boolean).join(" ");
1281
+ return `${head}
1282
+ ${collection.source_url}`;
1283
+ }).join("\n");
1284
+ return ok({ total, page: args.page, collections, notes }, body, { notes });
1285
+ } catch (error) {
1286
+ return toToolError(error);
1287
+ }
1288
+ }
1289
+
1290
+ // src/tools/searchItems.ts
1291
+ import { z as z4 } from "zod";
1292
+ var searchItemsDescription = [
1293
+ "Search the Library of Congress catalogue: books, photographs, maps, recordings, films, manuscripts, sheet music and newspaper titles.",
1294
+ "'media_type' is required, because the Library keeps a separate catalogue for each kind of thing.",
1295
+ "This matches titles, creators and catalogue descriptions. It does not read the text inside a scan; use search_newspapers for a phrase printed on a newspaper page.",
1296
+ "'subject', 'location', 'language' and 'collection' take the words the Library itself uses, as they appear on the rows this tool returns. A filter matching nothing is dropped and the answer says so.",
1297
+ "By default only material with a digitised copy is returned; set 'online_only' to false to take in records the Library holds on a shelf alone.",
1298
+ "Every row carries an 'identifier', which get_item takes."
1299
+ ].join(" ");
1300
+ var mediaTypes = FORMAT_ROUTES;
1301
+ var searchItemsInput = z4.object({
1302
+ query: z4.string().min(1).max(300).describe("Words to look for in titles, creators and catalogue descriptions."),
1303
+ media_type: z4.enum(mediaTypes).describe(
1304
+ "Which catalogue to search: books, photos, maps, audio, film-and-videos, manuscripts, notated-music or newspapers. 'newspapers' holds the papers themselves; a phrase printed on a page belongs in search_newspapers."
1305
+ ),
1306
+ year_from: z4.number().int().min(1e3).max(9999).optional().describe("Earliest year, inclusive."),
1307
+ year_to: z4.number().int().min(1e3).max(9999).optional().describe("Latest year, inclusive."),
1308
+ subject: z4.string().max(120).optional().describe("A subject heading, such as 'crime'."),
1309
+ location: z4.string().max(120).optional().describe("A place, such as 'oklahoma'."),
1310
+ language: z4.string().max(120).optional().describe("A language, written in English: 'english'."),
1311
+ collection: z4.string().max(160).optional().describe(
1312
+ "A collection, named exactly as list_collections reports it under 'collection_filter'."
1313
+ ),
1314
+ online_only: z4.boolean().default(true).describe("Keep to material with a digitised copy. Set false to include shelf-only records."),
1315
+ sort: z4.enum(["relevance", "newest", "oldest", "title"]).default("relevance").describe("Order of the results."),
1316
+ limit: z4.number().int().min(1).max(50).default(10),
1317
+ page: z4.number().int().min(1).max(100).default(1)
1318
+ });
1319
+ var searchItemsOutput = z4.object({
1320
+ query: z4.string(),
1321
+ total: z4.number().int().describe("Records matching across this catalogue, not the number returned."),
1322
+ page: z4.number().int(),
1323
+ items: z4.array(recordSchema),
1324
+ notes: z4.array(z4.string())
1325
+ });
1326
+ function describeNarrowing(args) {
1327
+ const written = [];
1328
+ if (args.subject) written.push(`subject="${args.subject}"`);
1329
+ if (args.location) written.push(`location="${args.location}"`);
1330
+ if (args.language) written.push(`language="${args.language}"`);
1331
+ if (args.collection) written.push(`collection="${args.collection}"`);
1332
+ if (args.year_from !== void 0 || args.year_to !== void 0) {
1333
+ written.push(`years ${args.year_from ?? "any"} to ${args.year_to ?? "any"}`);
1334
+ }
1335
+ return written;
1336
+ }
1337
+ async function runSearchItems(client, args) {
1338
+ try {
1339
+ if (args.year_from !== void 0 && args.year_to !== void 0 && args.year_from > args.year_to) {
1340
+ return toToolError(
1341
+ invalidInput(
1342
+ `year_from ${args.year_from} is later than year_to ${args.year_to}, so no record can match.`,
1343
+ "Put the earlier year in 'year_from'."
1344
+ )
1345
+ );
1346
+ }
1347
+ const facets = {};
1348
+ const put = (field, value) => {
1349
+ if (value && value.trim() !== "") facets[field] = value;
1350
+ };
1351
+ put("subject", args.subject);
1352
+ put("location", args.location);
1353
+ put("language", args.language);
1354
+ put("partof", args.collection);
1355
+ const narrowing = describeNarrowing(args);
1356
+ const wide = {
1357
+ query: args.query,
1358
+ format: args.media_type,
1359
+ onlineOnly: args.online_only,
1360
+ sort: args.sort,
1361
+ limit: args.limit,
1362
+ page: args.page
1363
+ };
1364
+ const narrowed = {
1365
+ ...wide,
1366
+ facets,
1367
+ ...args.year_from !== void 0 ? { yearFrom: args.year_from } : {},
1368
+ ...args.year_to !== void 0 ? { yearTo: args.year_to } : {}
1369
+ };
1370
+ const notes = [];
1371
+ let result = await client.searchItems(narrowed);
1372
+ let narrowingSetAside = false;
1373
+ if (result.data.paging.resultCount === 0 && narrowing.length > 0) {
1374
+ const wider = await client.searchItems(wide);
1375
+ if (wider.data.paging.resultCount > 0) {
1376
+ notes.push(
1377
+ `Nothing matches "${args.query}" with ${narrowing.join(", ")}. Those were set aside and the search was asked again without them, so the rows below are unfiltered. The Library uses its own wording for subjects, places and languages: read 'subjects' and 'location' on a row to see the form it expects.`
1378
+ );
1379
+ result = wider;
1380
+ narrowingSetAside = true;
1381
+ }
1382
+ }
1383
+ const { data, cached, skipped } = result;
1384
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1385
+ if (skipped) {
1386
+ notes.push(
1387
+ `${skipped} row(s) came back in a shape this server could not read and were left out.`
1388
+ );
1389
+ }
1390
+ const items = data.records.map((record) => ({
1391
+ identifier: record.identifier,
1392
+ title: record.title,
1393
+ creator: record.creator,
1394
+ year: record.year,
1395
+ date: record.date,
1396
+ format: record.format,
1397
+ location: record.location,
1398
+ subjects: record.subjects,
1399
+ online: record.online,
1400
+ source_url: record.sourceUrl
1401
+ }));
1402
+ const total = data.paging.resultCount;
1403
+ if (total > items.length) {
1404
+ notes.push(`${total} records match and ${items.length} are shown.`);
1405
+ }
1406
+ if (total === 0) {
1407
+ notes.push(
1408
+ `Nothing in the ${args.media_type} catalogue matches. A search here reads titles and catalogue descriptions only, so a phrase printed inside a newspaper belongs in search_newspapers.`
1409
+ );
1410
+ }
1411
+ if (items.length === 0 && total > 0) {
1412
+ const pages = data.paging.pageCount;
1413
+ notes.push(
1414
+ `Page ${args.page} is past the last row. ${total} records match${pages === null ? "" : ` across ${pages} pages at this page size`}, so ask for a lower page.`
1415
+ );
1416
+ }
1417
+ if (!narrowingSetAside && (args.year_from !== void 0 || args.year_to !== void 0)) {
1418
+ notes.push(
1419
+ "A record spanning several years matches on any of them, so a row can carry a date outside the range asked for while still belonging to it."
1420
+ );
1421
+ }
1422
+ if (!args.online_only && items.length > 0) {
1423
+ notes.push(
1424
+ "This search took in records with no digitised copy. Read 'online' on a row before promising something can be read from a browser."
1425
+ );
1426
+ }
1427
+ const body = items.length === 0 ? `Nothing in the ${args.media_type} catalogue for "${args.query}".` : `${items.length} of ${total} records for "${args.query}":
1428
+ ${renderRecords(items)}`;
1429
+ return ok({ query: args.query, total, page: args.page, items, notes }, body, { notes });
1430
+ } catch (error) {
1431
+ return toToolError(error);
1432
+ }
1433
+ }
1434
+
1435
+ // src/tools/searchNewspapers.ts
1436
+ import { z as z5 } from "zod";
1437
+ var searchNewspapersDescription = [
1438
+ "Search the text inside digitised American newspaper pages held by the Library of Congress.",
1439
+ "This reads what optical recognition took off the scanned pages, so it finds a phrase that appears nowhere in a title or a catalogue record.",
1440
+ "Put a phrase in double quotes to match it whole; without quotes the words are matched separately, which finds far more.",
1441
+ "'total' counts the pages that match, and they page: ask for page 2, 3 and so on to see beyond the first answer. It is not a count of how many times the words occur.",
1442
+ "Each match names the newspaper, the date, the leaf of the issue and the state it was published in, and 'source_url' opens that leaf with the query applied.",
1443
+ "'location' keeps to papers published in one state, 'publication' to a single paper, and 'year_from' with 'year_to' to a span of years. A filter matching nothing is dropped and the answer says so.",
1444
+ "The Library returns the opening of a page's text with each row rather than the whole page, so the searched words are often further down than the excerpts reach: 'words_located' says which of the two happened for each match.",
1445
+ "Use search_items instead when looking for a work by its title, creator or subject."
1446
+ ].join(" ");
1447
+ var searchNewspapersInput = z5.object({
1448
+ query: z5.string().min(2).max(300).describe(`Words or a quoted phrase, such as '"cure for influenza"'.`),
1449
+ location: z5.string().max(120).optional().describe(
1450
+ "The state a paper was published in, written as the Library writes it: 'new york', 'district of columbia'. It is the value 'state' carries on the matches this tool returns."
1451
+ ),
1452
+ publication: z5.string().max(200).optional().describe(
1453
+ "One newspaper, named with its town and the years it ran: 'new-york tribune (new york [n.y.]) 1866-1924'. Take the wording from 'publication' on a match here, or from a title in search_items with media_type 'newspapers'."
1454
+ ),
1455
+ year_from: z5.number().int().min(1e3).max(9999).optional().describe("Earliest year, inclusive."),
1456
+ year_to: z5.number().int().min(1e3).max(9999).optional().describe("Latest year, inclusive."),
1457
+ limit: z5.number().int().min(1).max(25).default(10).describe("Matches to return."),
1458
+ page: z5.number().int().min(1).max(100).default(1).describe("Which page of matches, from 1."),
1459
+ max_excerpt_chars: z5.number().int().min(80).max(1200).default(300).describe(
1460
+ "Budget for one passage. Read it together with 'max_excerpts_per_match': the size of the answer is the product of the two and the number of matches."
1461
+ ),
1462
+ max_excerpts_per_match: z5.number().int().min(1).max(10).default(3).describe(
1463
+ "Passages to keep per match. A page holding the words several times yields several, and the later ones rarely say anything the first did not."
1464
+ )
1465
+ });
1466
+ var searchNewspapersOutput = z5.object({
1467
+ query: z5.string(),
1468
+ total: z5.number().int().describe(
1469
+ "Newspaper pages that match, not the number returned and not a count of occurrences. Raise 'page' to read further into it."
1470
+ ),
1471
+ page: z5.number().int(),
1472
+ hits: z5.array(
1473
+ z5.object({
1474
+ identifier: z5.string().nullable().describe("Pass to get_item for the record of the issue this leaf belongs to."),
1475
+ title: z5.string().nullable().describe("How the Library names the leaf, paper and date included."),
1476
+ creator: z5.string().nullable().describe("Whoever contributed the scan, which is a library."),
1477
+ year: z5.number().int().nullable(),
1478
+ page_number: z5.number().int().nullable().describe("The leaf within the issue."),
1479
+ published_on: z5.string().nullable().describe("Date of the issue, as published."),
1480
+ publication: z5.string().nullable().describe("The newspaper, with the years it ran."),
1481
+ state: z5.string().nullable().describe("Where the paper was published."),
1482
+ words_located: z5.boolean().describe(
1483
+ "True when the searched words were found in the text returned with this row, in which case the excerpts are centred on them. False when they sit further down the page than that text reaches, in which case the excerpts are its opening."
1484
+ ),
1485
+ excerpts: z5.array(z5.string()).describe("Passages as a machine read them off the page."),
1486
+ source_url: z5.string().describe("Opens the leaf itself, with the query applied.")
1487
+ })
1488
+ ),
1489
+ notes: z5.array(z5.string())
1490
+ });
1491
+ function describeNarrowing2(args) {
1492
+ const written = [];
1493
+ if (args.location) written.push(`location="${args.location}"`);
1494
+ if (args.publication) written.push(`publication="${args.publication}"`);
1495
+ if (args.year_from !== void 0 || args.year_to !== void 0) {
1496
+ written.push(`years ${args.year_from ?? "any"} to ${args.year_to ?? "any"}`);
1497
+ }
1498
+ return written;
1499
+ }
1500
+ async function runSearchNewspapers(client, args) {
1501
+ try {
1502
+ if (args.year_from !== void 0 && args.year_to !== void 0 && args.year_from > args.year_to) {
1503
+ return toToolError(
1504
+ invalidInput(
1505
+ `year_from ${args.year_from} is later than year_to ${args.year_to}, so no page can match.`,
1506
+ "Put the earlier year in 'year_from'."
1507
+ )
1508
+ );
1509
+ }
1510
+ const budget = { maxChars: args.max_excerpt_chars, maxCount: args.max_excerpts_per_match };
1511
+ const facets = {};
1512
+ const put = (field, value) => {
1513
+ if (value && value.trim() !== "") facets[field] = value;
1514
+ };
1515
+ put("state", args.location);
1516
+ put("publication", args.publication);
1517
+ const narrowing = describeNarrowing2(args);
1518
+ const filters = {
1519
+ facets,
1520
+ ...args.year_from !== void 0 ? { yearFrom: args.year_from } : {},
1521
+ ...args.year_to !== void 0 ? { yearTo: args.year_to } : {}
1522
+ };
1523
+ const notes = [];
1524
+ let result = await client.searchNewspapers(args.query, args.limit, args.page, budget, filters);
1525
+ if (result.data.paging.resultCount === 0 && narrowing.length > 0) {
1526
+ const wider = await client.searchNewspapers(args.query, args.limit, args.page, budget);
1527
+ if (wider.data.paging.resultCount > 0) {
1528
+ notes.push(
1529
+ `No page carrying ${args.query} matches ${narrowing.join(", ")}. Those were set aside and the search was asked again without them, so the matches below are unfiltered. The corpus uses its own wording: read 'state' and 'publication' on a match to see the form it expects.`
1530
+ );
1531
+ result = wider;
1532
+ }
1533
+ }
1534
+ const { data, cached, skipped } = result;
1535
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1536
+ if (skipped) {
1537
+ notes.push(
1538
+ `${skipped} match(es) came back in a shape this server could not read and were left out. The count above is what the Library reported.`
1539
+ );
1540
+ }
1541
+ const hits = data.hits.map((hit) => ({
1542
+ identifier: hit.identifier,
1543
+ title: hit.title,
1544
+ creator: hit.creator,
1545
+ year: hit.year,
1546
+ page_number: hit.pageNumber,
1547
+ published_on: hit.publishedOn,
1548
+ publication: hit.publication,
1549
+ state: hit.state,
1550
+ words_located: hit.wordsLocated,
1551
+ excerpts: hit.excerpts.map((excerpt) => truncate(excerpt, args.max_excerpt_chars + 2)),
1552
+ source_url: hit.sourceUrl
1553
+ }));
1554
+ const total = data.paging.resultCount;
1555
+ if (hits.length > 0) notes.push(OCR_CAVEAT);
1556
+ const elsewhere = hits.filter((hit) => !hit.words_located).length;
1557
+ if (elsewhere > 0) {
1558
+ notes.push(
1559
+ `On ${elsewhere} of ${hits.length} match(es) the searched words sit further down the page than the text returned with the row, so those excerpts are the opening of the page rather than the passage that matched. Follow source_url, which opens the leaf with the query applied.`
1560
+ );
1561
+ }
1562
+ if (total > hits.length) {
1563
+ notes.push(
1564
+ `${total} pages match and ${hits.length} are shown. Ask for page ${args.page + 1} to continue: these results page, so the answer in hand is not the whole of it.`
1565
+ );
1566
+ }
1567
+ if (total === 0) {
1568
+ notes.push(
1569
+ "No digitised newspaper page carries these words. An unquoted query matches the words separately, which usually finds more."
1570
+ );
1571
+ }
1572
+ if (hits.length === 0 && total > 0) {
1573
+ notes.push(
1574
+ `Page ${args.page} is past the last match. ${total} pages match, so ask for a lower page.`
1575
+ );
1576
+ }
1577
+ const body = hits.length === 0 ? total > 0 ? `Page ${args.page} is past the last of ${total} newspaper pages carrying ${args.query}.` : `Nothing found in the scanned newspapers for ${args.query}.` : `${hits.length} of ${total} newspaper pages carrying ${args.query}:
1578
+ ` + hits.map((hit, index) => {
1579
+ const where = [
1580
+ `${index + 1}. ${hit.publication ?? hit.title ?? "untitled"}`,
1581
+ hit.published_on ? `\xB7 ${hit.published_on}` : "",
1582
+ hit.page_number === null ? "" : `\xB7 page ${hit.page_number}`,
1583
+ hit.state ? `\xB7 ${hit.state}` : ""
1584
+ ].filter(Boolean).join(" ");
1585
+ const passages = hit.excerpts.map((excerpt) => ` ${excerpt}`).join("\n");
1586
+ return `${where}
1587
+ ${passages}
1588
+ ${hit.source_url}`;
1589
+ }).join("\n");
1590
+ return ok({ query: args.query, total, page: args.page, hits, notes }, body, { notes });
1591
+ } catch (error) {
1592
+ return toToolError(error);
1593
+ }
1594
+ }
1595
+
1596
+ // src/server.ts
1597
+ var READ_ONLY = {
1598
+ readOnlyHint: true,
1599
+ destructiveHint: false,
1600
+ idempotentHint: true,
1601
+ openWorldHint: true
1602
+ };
1603
+ var INSTRUCTIONS = [
1604
+ "Tools for the Library of Congress. No API key and no account are needed.",
1605
+ "Two questions lead to two different tools, and choosing the wrong one gives a confident empty answer.",
1606
+ "To find a phrase printed on a page of an American newspaper, use search_newspapers: it reads the text scanned off the pages and returns the paper, the date, the leaf and the passage.",
1607
+ "To find a work by its title, creator or subject, use search_items, which requires a 'media_type' because the Library keeps a separate catalogue per kind of thing.",
1608
+ "list_collections shows the corpora a curator built, and each row carries the wording search_items takes as its 'collection' filter.",
1609
+ "search_newspapers reports 'total' as the number of pages that match, and they page: ask for the next page rather than treating the first answer as the whole of it.",
1610
+ "Excerpts come from optical character recognition, so the words can be wrong; repeat them as scanned text and link the page. When 'words_located' is false the searched words sit further down the page than the text this server received, and the excerpt is the opening of that page.",
1611
+ "Metadata here is in the public domain, and individual items are not: a record states its own terms, and a record stating none is not a record granting permission.",
1612
+ "This server paces itself to the ceiling the Library publishes, so answers take a few seconds. A rate_limited error means it was asked to slow down, never that the thing you asked for is missing.",
1613
+ "Every result carries a source_url. Credit the Library of Congress and link what you use."
1614
+ ].join(" ");
1615
+ function createServer(options = {}) {
1616
+ const config = options.config ?? loadConfig();
1617
+ const logger = options.logger ?? createLogger(config.logLevel);
1618
+ const client = new LocClient({
1619
+ config,
1620
+ logger,
1621
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
1622
+ });
1623
+ const server = new McpServer(
1624
+ { name: "mcp-libraryofcongress", version: PKG_VERSION },
1625
+ { instructions: INSTRUCTIONS }
1626
+ );
1627
+ server.registerTool(
1628
+ "search_newspapers",
1629
+ {
1630
+ title: "Search inside scanned newspaper pages",
1631
+ description: searchNewspapersDescription,
1632
+ inputSchema: searchNewspapersInput,
1633
+ outputSchema: searchNewspapersOutput,
1634
+ annotations: READ_ONLY
1635
+ },
1636
+ async (args) => runSearchNewspapers(client, args)
1637
+ );
1638
+ server.registerTool(
1639
+ "search_items",
1640
+ {
1641
+ title: "Search the catalogue",
1642
+ description: searchItemsDescription,
1643
+ inputSchema: searchItemsInput,
1644
+ outputSchema: searchItemsOutput,
1645
+ annotations: READ_ONLY
1646
+ },
1647
+ async (args) => runSearchItems(client, args)
1648
+ );
1649
+ server.registerTool(
1650
+ "get_item",
1651
+ {
1652
+ title: "Read a record",
1653
+ description: getItemDescription,
1654
+ inputSchema: getItemInput,
1655
+ outputSchema: getItemOutput,
1656
+ annotations: READ_ONLY
1657
+ },
1658
+ async (args) => runGetItem(client, args)
1659
+ );
1660
+ server.registerTool(
1661
+ "list_collections",
1662
+ {
1663
+ title: "List the digital collections",
1664
+ description: listCollectionsDescription,
1665
+ inputSchema: listCollectionsInput,
1666
+ outputSchema: listCollectionsOutput,
1667
+ annotations: READ_ONLY
1668
+ },
1669
+ async (args) => runListCollections(client, args)
1670
+ );
1671
+ logger.info(
1672
+ `ready: user-agent="${config.userAgent}", ${config.minIntervalMs}ms between requests, cache ${config.cacheTtlMs}ms`
1673
+ );
1674
+ return server;
1675
+ }
1676
+
1677
+ // src/index.ts
1678
+ async function main() {
1679
+ const server = createServer();
1680
+ const transport = new StdioServerTransport();
1681
+ await server.connect(transport);
1682
+ const shutdown = async () => {
1683
+ await server.close().catch(() => void 0);
1684
+ process.exit(0);
1685
+ };
1686
+ process.on("SIGINT", shutdown);
1687
+ process.on("SIGTERM", shutdown);
1688
+ }
1689
+ main().catch((error) => {
1690
+ const message = error instanceof Error ? error.message : String(error);
1691
+ process.stderr.write(`[mcp-libraryofcongress] fatal: ${message}
1692
+ `);
1693
+ process.exit(1);
1694
+ });
1695
+ //# sourceMappingURL=index.js.map