mcp-databnf 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,2265 @@
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-databnf";
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 = 12e4;
17
+ var DEFAULT_INTERVAL_MS = 3e3;
18
+ var DEFAULT_TIMEOUT_MS = 6e4;
19
+ var DEFAULT_USER_AGENT = `mcp-databnf/${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-databnf] ${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-databnf] 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-databnf] 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.BNF_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-databnf] error: BNF_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
62
+ `
63
+ );
64
+ }
65
+ const custom = env.BNF_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 BnF has to be able to reach a human
69
+ // about traffic it did not expect.
70
+ userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
71
+ minIntervalMs: readInteger(
72
+ env,
73
+ "BNF_MIN_INTERVAL_MS",
74
+ DEFAULT_INTERVAL_MS,
75
+ MIN_ALLOWED_INTERVAL_MS,
76
+ MAX_ALLOWED_INTERVAL_MS
77
+ ),
78
+ timeoutMs: readInteger(env, "BNF_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, 1e3, 3e5),
79
+ maxRetries: readInteger(env, "BNF_MAX_RETRIES", 3, 0, 8),
80
+ cacheTtlMs: readInteger(env, "BNF_CACHE_TTL_MS", 9e5, 0, 864e5),
81
+ cacheMaxEntries: readInteger(env, "BNF_CACHE_MAX_ENTRIES", 200, 1, 5e3),
82
+ logLevel
83
+ };
84
+ }
85
+
86
+ // src/errors.ts
87
+ var BnfError = class extends Error {
88
+ code;
89
+ details;
90
+ constructor(code, message, details = {}) {
91
+ super(message);
92
+ this.name = "BnfError";
93
+ this.code = code;
94
+ this.details = details;
95
+ }
96
+ };
97
+ var notFound = (message, details) => new BnfError("not_found", message, details);
98
+ var invalidInput = (message, hint) => new BnfError("invalid_input", message, hint ? { hint } : {});
99
+ var rateLimited = (message, details) => new BnfError("rate_limited", message, {
100
+ hint: "Wait a moment and ask again. This says nothing about whether the BnF describes what you asked for.",
101
+ ...details
102
+ });
103
+ var parseFailure = (message, details) => new BnfError("parse_failure", message, {
104
+ hint: "data.bnf.fr may have changed how it answers. Please report this at https://github.com/smeet666/mcp-databnf/issues with the arguments you used.",
105
+ ...details
106
+ });
107
+ var networkError = (message, details) => new BnfError("network_error", message, details);
108
+ var timeout = (message, details) => new BnfError("timeout", message, details);
109
+
110
+ // src/bnf/cache.ts
111
+ var Cache = class {
112
+ constructor(ttlMs, maxEntries) {
113
+ this.ttlMs = ttlMs;
114
+ this.maxEntries = maxEntries;
115
+ }
116
+ ttlMs;
117
+ maxEntries;
118
+ entries = /* @__PURE__ */ new Map();
119
+ get(key) {
120
+ const entry = this.entries.get(key);
121
+ if (!entry) return void 0;
122
+ if (entry.expiresAt <= Date.now()) {
123
+ this.entries.delete(key);
124
+ return void 0;
125
+ }
126
+ this.entries.delete(key);
127
+ this.entries.set(key, entry);
128
+ return { value: entry.value, retrievedAt: entry.retrievedAt };
129
+ }
130
+ set(key, value, retrievedAt) {
131
+ if (this.ttlMs <= 0) return;
132
+ this.entries.delete(key);
133
+ this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, retrievedAt });
134
+ while (this.entries.size > this.maxEntries) {
135
+ const oldest = this.entries.keys().next();
136
+ if (oldest.done) break;
137
+ this.entries.delete(oldest.value);
138
+ }
139
+ }
140
+ get size() {
141
+ return this.entries.size;
142
+ }
143
+ };
144
+
145
+ // src/bnf/endpoint.ts
146
+ var SPARQL_ENDPOINT = "https://data.bnf.fr/sparql";
147
+ var ALLOWED_HOST = "data.bnf.fr";
148
+ var GALLICA_HOST = "gallica.bnf.fr";
149
+ function assertRequestable(url) {
150
+ let parsed;
151
+ try {
152
+ parsed = new URL(url);
153
+ } catch {
154
+ throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`);
155
+ }
156
+ const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
157
+ if (host !== ALLOWED_HOST) {
158
+ throw refusal(
159
+ `mcp-databnf requests ${ALLOWED_HOST} and nothing else; "${host}" was refused. Gallica in particular is described from the catalogue and never read.`
160
+ );
161
+ }
162
+ }
163
+ var refusal = (message) => new BnfError("network_error", message, {
164
+ hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host."
165
+ });
166
+ function isGallicaAddress(address) {
167
+ try {
168
+ const host = new URL(address).hostname.toLowerCase().replace(/\.$/, "");
169
+ return host === GALLICA_HOST || host.endsWith(`.${GALLICA_HOST}`);
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+ var PREFIXES = {
175
+ foaf: "http://xmlns.com/foaf/0.1/",
176
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
177
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#",
178
+ owl: "http://www.w3.org/2002/07/owl#",
179
+ skos: "http://www.w3.org/2004/02/skos/core#",
180
+ dcterms: "http://purl.org/dc/terms/",
181
+ bio: "http://vocab.org/bio/0.1/",
182
+ bnf: "http://data.bnf.fr/ontology/bnf-onto/",
183
+ bibo: "http://purl.org/ontology/bibo/",
184
+ isni: "http://isni.org/ontology#",
185
+ /** RDA group 2 elements, which carry the biographical fields. */
186
+ rdag2: "http://rdvocab.info/ElementsGr2/",
187
+ /** RDA elements, which carry the bibliographic fields. */
188
+ rdae: "http://rdvocab.info/Elements/",
189
+ /** RDA relationships between work, expression, manifestation and item. */
190
+ rdarel: "http://rdvocab.info/RDARelationshipsWEMI/",
191
+ /** The FRBR classes data.bnf.fr types its entities with. */
192
+ rdafrbr: "http://rdvocab.info/uri/schema/FRBRentitiesRDA/"
193
+ };
194
+ var PREFIX_BLOCK = Object.entries(PREFIXES).map(([name, iri]) => `PREFIX ${name}: <${iri}>`).join("\n");
195
+ var publicPageFor = (entityIri) => entityIri.replace(/#.*$/, "").replace(/^http:/, "https:");
196
+
197
+ // src/bnf/http.ts
198
+ var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
199
+ var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
200
+ var LONGEST_WAIT_MS = 3e4;
201
+ var RETRIES_AFTER_SILENCE = 1;
202
+ var MAX_REDIRECTS = 2;
203
+ function parseRetryAfter(value, now = Date.now()) {
204
+ if (!value) return null;
205
+ const trimmed = value.trim();
206
+ const seconds = Number(trimmed);
207
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
208
+ const at = Date.parse(trimmed);
209
+ if (Number.isNaN(at)) return null;
210
+ return Math.max(0, at - now);
211
+ }
212
+ function backoffMs(attempt) {
213
+ const base = Math.min(16e3, 1e3 * 2 ** attempt);
214
+ return base + Math.floor(Math.random() * 500);
215
+ }
216
+ function readEngineError(body) {
217
+ const match = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/.exec(body.trim());
218
+ if (!match) return null;
219
+ const state = match[1] ?? "";
220
+ const text2 = (match[2] ?? "").split("\n")[0]?.trim() ?? "";
221
+ return { kind: state === "37000" ? "compile" : "runtime", text: text2 };
222
+ }
223
+ async function postOnce(options, signal) {
224
+ assertRequestable(SPARQL_ENDPOINT);
225
+ const doFetch = options.fetchImpl ?? fetch;
226
+ const form = new URLSearchParams();
227
+ form.set("query", options.query);
228
+ form.set("format", "application/sparql-results+json");
229
+ let target = SPARQL_ENDPOINT;
230
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
231
+ const response = await doFetch(target, {
232
+ method: "POST",
233
+ signal,
234
+ redirect: "manual",
235
+ headers: {
236
+ "user-agent": options.userAgent,
237
+ accept: "application/sparql-results+json",
238
+ "content-type": "application/x-www-form-urlencoded"
239
+ },
240
+ body: form.toString()
241
+ });
242
+ const location = response.headers.get("location");
243
+ if (response.status >= 300 && response.status < 400 && location !== null) {
244
+ const next = new URL(location, target).toString();
245
+ assertRequestable(next);
246
+ target = next;
247
+ continue;
248
+ }
249
+ return {
250
+ status: response.status,
251
+ headers: response.headers,
252
+ body: await response.text(),
253
+ ok: response.ok
254
+ };
255
+ }
256
+ throw networkError(`data.bnf.fr redirected this request more than ${MAX_REDIRECTS} times.`, {
257
+ url: SPARQL_ENDPOINT
258
+ });
259
+ }
260
+ async function runQuery(options) {
261
+ const { timeoutMs, maxRetries, limiter, logger } = options;
262
+ let lastError = null;
263
+ let askedWaitMs = 0;
264
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
265
+ if (askedWaitMs > 0) {
266
+ logger.debug(`waiting ${askedWaitMs}ms, as asked`);
267
+ await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
268
+ askedWaitMs = 0;
269
+ }
270
+ await limiter.beforeRequest();
271
+ const controller = new AbortController();
272
+ const deadline = setTimeout(() => controller.abort(), timeoutMs);
273
+ try {
274
+ logger.debug(`POST ${SPARQL_ENDPOINT} (${options.query.length} characters)`);
275
+ const response = await postOnce(options, controller.signal);
276
+ if (response.ok) {
277
+ let parsed;
278
+ try {
279
+ parsed = parseResults(response.body);
280
+ } catch (error) {
281
+ limiter.pushBack();
282
+ throw error;
283
+ }
284
+ limiter.succeeded();
285
+ return parsed;
286
+ }
287
+ if (RETRYABLE.has(response.status)) limiter.pushBack();
288
+ if (PUSH_BACK.has(response.status)) {
289
+ const asked = parseRetryAfter(response.headers.get("retry-after"));
290
+ if (asked !== null && asked > LONGEST_WAIT_MS) {
291
+ throw rateLimited(
292
+ `data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
293
+ { url: SPARQL_ENDPOINT, status: response.status }
294
+ );
295
+ }
296
+ if (attempt >= maxRetries) {
297
+ throw rateLimited(
298
+ `data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
299
+ {
300
+ url: SPARQL_ENDPOINT,
301
+ status: response.status
302
+ }
303
+ );
304
+ }
305
+ askedWaitMs = asked ?? backoffMs(attempt);
306
+ lastError = new Error(`HTTP ${response.status}`);
307
+ continue;
308
+ }
309
+ if (RETRYABLE.has(response.status) && attempt < maxRetries) {
310
+ lastError = new Error(`HTTP ${response.status}`);
311
+ askedWaitMs = backoffMs(attempt);
312
+ continue;
313
+ }
314
+ if (response.status === 400 || response.status === 422) {
315
+ const engine = readEngineError(response.body);
316
+ throw invalidInput(
317
+ `data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
318
+ "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
319
+ );
320
+ }
321
+ throw networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
322
+ url: SPARQL_ENDPOINT,
323
+ status: response.status
324
+ });
325
+ } catch (error) {
326
+ clearTimeout(deadline);
327
+ if (error instanceof Error && error.name === "BnfError") throw error;
328
+ if (error instanceof Error && error.name === "AbortError") {
329
+ lastError = error;
330
+ if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
331
+ throw timeout(
332
+ `No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
333
+ { url: SPARQL_ENDPOINT }
334
+ );
335
+ }
336
+ askedWaitMs = backoffMs(attempt);
337
+ continue;
338
+ }
339
+ lastError = error instanceof Error ? error : new Error(String(error));
340
+ if (attempt >= maxRetries) {
341
+ throw networkError(`Could not reach data.bnf.fr: ${lastError.message}`, {
342
+ url: SPARQL_ENDPOINT
343
+ });
344
+ }
345
+ askedWaitMs = backoffMs(attempt);
346
+ continue;
347
+ } finally {
348
+ clearTimeout(deadline);
349
+ }
350
+ }
351
+ throw networkError(
352
+ `Could not reach data.bnf.fr: ${lastError?.message ?? "no attempt was made"}`,
353
+ {
354
+ url: SPARQL_ENDPOINT
355
+ }
356
+ );
357
+ }
358
+ function parseResults(body) {
359
+ if (body.trim() === "") {
360
+ throw parseFailure(
361
+ "data.bnf.fr returned an empty body, which is what it does when it abandons a query part way through.",
362
+ { url: SPARQL_ENDPOINT }
363
+ );
364
+ }
365
+ const engine = readEngineError(body);
366
+ if (engine) {
367
+ if (engine.kind === "compile") {
368
+ throw invalidInput(
369
+ `data.bnf.fr could not read this query: ${engine.text}`,
370
+ "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
371
+ );
372
+ }
373
+ throw timeout(`data.bnf.fr started this query and gave it up: ${engine.text}`, {
374
+ url: SPARQL_ENDPOINT
375
+ });
376
+ }
377
+ let payload;
378
+ try {
379
+ payload = JSON.parse(body);
380
+ } catch {
381
+ throw parseFailure("data.bnf.fr answered with something that is not JSON.", {
382
+ url: SPARQL_ENDPOINT
383
+ });
384
+ }
385
+ if (typeof payload !== "object" || payload === null || !("results" in payload) || typeof payload.results !== "object" || !Array.isArray(payload.results?.bindings)) {
386
+ throw parseFailure("data.bnf.fr answered with JSON that is not a SPARQL result set.", {
387
+ url: SPARQL_ENDPOINT
388
+ });
389
+ }
390
+ return payload;
391
+ }
392
+
393
+ // src/bnf/sparql.ts
394
+ function escapeLiteral(value) {
395
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
396
+ }
397
+ var literal = (value) => `"${escapeLiteral(value)}"`;
398
+ function toSearchWords(input) {
399
+ const cleaned = input.normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
400
+ const words = [];
401
+ for (const raw of cleaned.split(" ")) {
402
+ const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
403
+ if (word === "") continue;
404
+ words.push(word.length > 60 ? word.slice(0, 60) : word);
405
+ if (words.length >= 12) break;
406
+ }
407
+ return words;
408
+ }
409
+ function containsAllWords(words) {
410
+ if (words.length === 0) {
411
+ throw invalidInput(
412
+ "No word to search for: the text held no letters or digits.",
413
+ "Write the words to look for, such as a surname or a title."
414
+ );
415
+ }
416
+ const clause = words.map((word) => `'${word.replace(/'/g, "''")}'`).join(" AND ");
417
+ return literal(clause);
418
+ }
419
+ var ARK_ID = /^c[bc][0-9a-z]{6,20}$/;
420
+ var TEMP_WORK_ID = /^[0-9a-f]{32}$/;
421
+ function parseEntityId(input) {
422
+ const trimmed = input.trim();
423
+ if (trimmed === "") {
424
+ throw invalidInput("An identifier is required.");
425
+ }
426
+ const arkMatch = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(trimmed);
427
+ const bare = arkMatch ? arkMatch[1] : trimmed.replace(/^#/, "").split(/[#?]/)[0];
428
+ const candidate = (bare ?? "").toLowerCase();
429
+ if (ARK_ID.test(candidate)) {
430
+ return {
431
+ kind: "ark",
432
+ id: candidate,
433
+ iri: `http://data.bnf.fr/ark:/12148/${candidate}#about`,
434
+ pageUrl: `https://data.bnf.fr/ark:/12148/${candidate}`
435
+ };
436
+ }
437
+ const tempMatch = /temp-work\/([0-9a-f]{32})/i.exec(trimmed);
438
+ const digest = (tempMatch?.[1] ?? candidate).toLowerCase();
439
+ if (TEMP_WORK_ID.test(digest)) {
440
+ return {
441
+ kind: "temp-work",
442
+ id: `temp-work/${digest}`,
443
+ iri: `http://data.bnf.fr/temp-work/${digest}/#about`,
444
+ pageUrl: `https://data.bnf.fr/temp-work/${digest}/`
445
+ };
446
+ }
447
+ throw invalidInput(`"${input}" is not a data.bnf.fr identifier.`, hintFor(trimmed));
448
+ }
449
+ var GALLICA_DOCUMENT = /^(?:bpt6k|btv1b|bd6t|cb32|btv1b)[0-9a-z]+$/i;
450
+ var PAGE_ADDRESS = /data\.bnf\.fr\/(?:[a-z]{2}\/)?(\d{6,12})\/([a-z0-9_]+)/i;
451
+ var GENERAL_HINT = "An identifier looks like 'cb119219976' for an established record, or 'temp-work/22d7f68c1a4bdd081ad7ca791fd3b730' for a provisional one. Take it from a search result rather than building one.";
452
+ function hintFor(input) {
453
+ const page2 = PAGE_ADDRESS.exec(input);
454
+ if (page2) {
455
+ const name = (page2[2] ?? "").replace(/_/g, " ");
456
+ return `That is the readable address of a data.bnf.fr page, and ${page2[1]} in it is the record's FRBNF number rather than the identifier these tools take. Search for "${name}" with search_authors or search_works, and use the identifier the row carries.`;
457
+ }
458
+ const gallicaArk = /ark:\/12148\/([0-9a-z]+)/i.exec(input)?.[1] ?? input;
459
+ if (GALLICA_DOCUMENT.test(gallicaArk)) {
460
+ return "That is a Gallica identifier: it names a digitised document rather than a record in the catalogue, and these tools read the catalogue. Search for the work with search_works, then list_editions will give you the Gallica link alongside the edition it belongs to.";
461
+ }
462
+ return GENERAL_HINT;
463
+ }
464
+ var iriOf = (id) => `<${id.iri}>`;
465
+ function boundedInteger(value, min, max, name) {
466
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
467
+ throw invalidInput(`'${name}' has to be a whole number.`);
468
+ }
469
+ if (value < min || value > max) {
470
+ throw invalidInput(`'${name}' has to be between ${min} and ${max}.`);
471
+ }
472
+ return value;
473
+ }
474
+
475
+ // src/bnf/queries.ts
476
+ var TEXT_WINDOW = 400;
477
+ var prefixed = (body) => `${PREFIX_BLOCK}
478
+ ${body.trim()}
479
+ `;
480
+ function searchAuthorsQuery(words, limit, offset) {
481
+ const take = boundedInteger(limit, 1, 50, "limit") + 1;
482
+ const skip = boundedInteger(offset, 0, 5e3, "offset");
483
+ return prefixed(`
484
+ SELECT ?person ?name ?label ?birthYear ?deathYear ?role WHERE {
485
+ {
486
+ SELECT DISTINCT ?person WHERE {
487
+ {
488
+ SELECT DISTINCT ?person ?name WHERE {
489
+ ?person foaf:name ?name .
490
+ ?name bif:contains ${containsAllWords(words)} .
491
+ }
492
+ LIMIT ${TEXT_WINDOW}
493
+ }
494
+ ?person a foaf:Person .
495
+ }
496
+ ORDER BY ?person
497
+ LIMIT ${take} OFFSET ${skip}
498
+ }
499
+ OPTIONAL { ?person foaf:name ?name }
500
+ OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
501
+ OPTIONAL { ?person bnf:firstYear ?birthYear }
502
+ OPTIONAL { ?person bnf:lastYear ?deathYear }
503
+ OPTIONAL { ?person rdag2:biographicalInformation ?role }
504
+ }`);
505
+ }
506
+ var MOST_TRIPLES = 2e3;
507
+ function authorQuery(id) {
508
+ const person = iriOf(id);
509
+ return prefixed(`
510
+ SELECT ?p ?o ?lang WHERE {
511
+ {
512
+ ${person} ?p ?o .
513
+ BIND(LANG(?o) AS ?lang)
514
+ }
515
+ UNION
516
+ {
517
+ ?record foaf:focus ${person} ; ?p ?o .
518
+ BIND(LANG(?o) AS ?lang)
519
+ }
520
+ }
521
+ LIMIT ${MOST_TRIPLES}`);
522
+ }
523
+ function searchWorksQuery(words, limit, offset) {
524
+ const take = boundedInteger(limit, 1, 50, "limit") + 1;
525
+ const skip = boundedInteger(offset, 0, 5e3, "offset");
526
+ return prefixed(`
527
+ SELECT ?work ?title ?date ?status ?creator ?creatorName WHERE {
528
+ {
529
+ SELECT DISTINCT ?work WHERE {
530
+ {
531
+ SELECT DISTINCT ?work ?title WHERE {
532
+ ?work dcterms:title ?title .
533
+ ?title bif:contains ${containsAllWords(words)} .
534
+ }
535
+ LIMIT ${TEXT_WINDOW}
536
+ }
537
+ ?work a rdafrbr:Work .
538
+ }
539
+ ORDER BY ?work
540
+ LIMIT ${take} OFFSET ${skip}
541
+ }
542
+ OPTIONAL { ?work dcterms:title ?title }
543
+ OPTIONAL { ?work dcterms:date ?date }
544
+ OPTIONAL { ?work rdae:statusOfIdentification ?status }
545
+ OPTIONAL {
546
+ ?work dcterms:creator ?creator .
547
+ OPTIONAL { ?creator foaf:name ?creatorName }
548
+ }
549
+ }`);
550
+ }
551
+ function workQuery(id) {
552
+ const work = iriOf(id);
553
+ return prefixed(`
554
+ SELECT ?p ?o ?lang ?name WHERE {
555
+ ${work} ?p ?o .
556
+ BIND(LANG(?o) AS ?lang)
557
+ OPTIONAL { ?o foaf:name ?name }
558
+ }
559
+ LIMIT ${MOST_TRIPLES}`);
560
+ }
561
+ function editionsQuery(id, limit, offset) {
562
+ const work = iriOf(id);
563
+ const take = boundedInteger(limit, 1, 50, "limit") + 1;
564
+ const skip = boundedInteger(offset, 0, 5e3, "offset");
565
+ return prefixed(`
566
+ SELECT ?edition ?title ?date ?year ?publisher ?place ?editionStatement
567
+ ?extent ?isbn ?note ?catalogue ?reproduction ?ocr WHERE {
568
+ {
569
+ SELECT DISTINCT ?edition WHERE {
570
+ ?edition rdarel:workManifested ${work} .
571
+ }
572
+ ORDER BY ?edition
573
+ LIMIT ${take} OFFSET ${skip}
574
+ }
575
+ OPTIONAL { ?edition dcterms:title ?title }
576
+ OPTIONAL { ?edition dcterms:date ?date }
577
+ OPTIONAL { ?edition bnf:firstYear ?year }
578
+ OPTIONAL { ?edition rdae:publishersName ?publisher }
579
+ OPTIONAL { ?edition rdae:placeOfPublication ?place }
580
+ OPTIONAL { ?edition rdae:editionStatement ?editionStatement }
581
+ OPTIONAL { ?edition dcterms:description ?extent }
582
+ OPTIONAL { ?edition bnf:isbn ?isbn }
583
+ OPTIONAL { ?edition rdae:note ?note }
584
+ OPTIONAL { ?edition rdfs:seeAlso ?catalogue }
585
+ OPTIONAL { ?edition rdarel:electronicReproduction ?reproduction }
586
+ OPTIONAL { ?edition bnf:OCR ?ocr }
587
+ }`);
588
+ }
589
+ function digitisedForWorkQuery(id, limit) {
590
+ const work = iriOf(id);
591
+ const take = boundedInteger(limit, 1, 200, "limit") + 1;
592
+ return prefixed(`
593
+ SELECT DISTINCT ?rank ?edition ?title ?reproduction ?ocr ?depiction WHERE {
594
+ {
595
+ ?edition rdarel:workManifested ${work} .
596
+ OPTIONAL { ?edition dcterms:title ?title }
597
+ { ?edition rdarel:electronicReproduction ?reproduction }
598
+ UNION
599
+ { ?edition bnf:OCR ?ocr }
600
+ BIND(0 AS ?rank)
601
+ }
602
+ UNION
603
+ {
604
+ ${work} foaf:depiction ?depiction .
605
+ BIND(${work} AS ?edition)
606
+ BIND(1 AS ?rank)
607
+ }
608
+ }
609
+ ORDER BY ?rank
610
+ LIMIT ${take}`);
611
+ }
612
+ function digitisedForPersonQuery(id, limit) {
613
+ const person = iriOf(id);
614
+ const take = boundedInteger(limit, 1, 200, "limit") + 1;
615
+ return prefixed(`
616
+ SELECT DISTINCT ?rank ?edition ?title ?reproduction ?ocr ?depiction WHERE {
617
+ {
618
+ ?work dcterms:creator ${person} .
619
+ ?edition rdarel:workManifested ?work .
620
+ OPTIONAL { ?edition dcterms:title ?title }
621
+ { ?edition rdarel:electronicReproduction ?reproduction }
622
+ UNION
623
+ { ?edition bnf:OCR ?ocr }
624
+ BIND(0 AS ?rank)
625
+ }
626
+ UNION
627
+ {
628
+ ${person} foaf:depiction ?depiction .
629
+ BIND(${person} AS ?edition)
630
+ BIND(1 AS ?rank)
631
+ }
632
+ }
633
+ ORDER BY ?rank
634
+ LIMIT ${take}`);
635
+ }
636
+ function kindQuery(id) {
637
+ return prefixed(`
638
+ SELECT DISTINCT ?type WHERE {
639
+ ${iriOf(id)} a ?type .
640
+ }
641
+ LIMIT 20`);
642
+ }
643
+
644
+ // src/bnf/parse.ts
645
+ var P = {
646
+ type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
647
+ label: "http://www.w3.org/2000/01/rdf-schema#label",
648
+ seeAlso: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
649
+ sameAs: "http://www.w3.org/2002/07/owl#sameAs",
650
+ exactMatch: "http://www.w3.org/2004/02/skos/core#exactMatch",
651
+ closeMatch: "http://www.w3.org/2004/02/skos/core#closeMatch",
652
+ prefLabel: "http://www.w3.org/2004/02/skos/core#prefLabel",
653
+ altLabel: "http://www.w3.org/2004/02/skos/core#altLabel",
654
+ created: "http://purl.org/dc/terms/created",
655
+ modified: "http://purl.org/dc/terms/modified",
656
+ title: "http://purl.org/dc/terms/title",
657
+ date: "http://purl.org/dc/terms/date",
658
+ creator: "http://purl.org/dc/terms/creator",
659
+ language: "http://purl.org/dc/terms/language",
660
+ subject: "http://purl.org/dc/terms/subject",
661
+ name: "http://xmlns.com/foaf/0.1/name",
662
+ givenName: "http://xmlns.com/foaf/0.1/givenName",
663
+ familyName: "http://xmlns.com/foaf/0.1/familyName",
664
+ depiction: "http://xmlns.com/foaf/0.1/depiction",
665
+ birth: "http://vocab.org/bio/0.1/birth",
666
+ death: "http://vocab.org/bio/0.1/death",
667
+ firstYear: "http://data.bnf.fr/ontology/bnf-onto/firstYear",
668
+ lastYear: "http://data.bnf.fr/ontology/bnf-onto/lastYear",
669
+ subjectLabel: "http://data.bnf.fr/ontology/bnf-onto/subject",
670
+ isni: "http://isni.org/ontology#identifierValid",
671
+ placeOfBirth: "http://rdvocab.info/ElementsGr2/placeOfBirth",
672
+ placeOfDeath: "http://rdvocab.info/ElementsGr2/placeOfDeath",
673
+ biographicalInformation: "http://rdvocab.info/ElementsGr2/biographicalInformation",
674
+ languageOfThePerson: "http://rdvocab.info/ElementsGr2/languageOfThePerson",
675
+ countryAssociated: "http://rdvocab.info/ElementsGr2/countryAssociatedWithThePerson",
676
+ fieldOfActivity: "http://rdvocab.info/ElementsGr2/fieldOfActivityOfThePerson",
677
+ formOfWork: "http://rdvocab.info/Elements/formOfWork",
678
+ statusOfIdentification: "http://rdvocab.info/Elements/statusOfIdentification",
679
+ /** The occupation, which RDA numbers rather than names. */
680
+ occupation: "http://rdaregistry.info/Elements/a/#P50113",
681
+ /** Expressions of a work, which are not editions. */
682
+ expressionOfWork: "http://rdaregistry.info/Elements/w/#P10078"
683
+ };
684
+ var ALIGNMENT_HOSTS = [
685
+ { host: "viaf.org", name: "viaf" },
686
+ { host: "idref.fr", name: "idref" },
687
+ { host: "dbpedia.org", name: "dbpedia" },
688
+ { host: "wikidata.org", name: "wikidata" },
689
+ { host: "isni.org", name: "isni" },
690
+ { host: "d-nb.info", name: "gnd" },
691
+ { host: "id.loc.gov", name: "loc" },
692
+ { host: "datos.bne.es", name: "bne" },
693
+ { host: "wikipedia.org", name: "wikipedia" },
694
+ { host: "musicbrainz.org", name: "musicbrainz" },
695
+ { host: "francearchives.gouv.fr", name: "francearchives" },
696
+ { host: "imslp.org", name: "imslp" },
697
+ { host: "catalogue.bnf.fr", name: "catalogue" }
698
+ ];
699
+ var text = (term) => {
700
+ if (!term) return null;
701
+ const value = term.value.trim();
702
+ return value === "" ? null : value;
703
+ };
704
+ var integer = (term) => {
705
+ const raw = text(term);
706
+ if (raw === null) return null;
707
+ const value = Number(raw);
708
+ return Number.isInteger(value) ? value : null;
709
+ };
710
+ function idFromIri(iri) {
711
+ const ark = /ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(iri);
712
+ if (ark?.[1]) return ark[1].toLowerCase();
713
+ const temp = /temp-work\/([0-9a-f]{32})/i.exec(iri);
714
+ if (temp?.[1]) return `temp-work/${temp[1].toLowerCase()}`;
715
+ return null;
716
+ }
717
+ var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(/[/#]$/, "").split(/[/#]/).pop() ?? iri);
718
+ function toDigitisedLink(address, role, fromId, fromTitle) {
719
+ if (!address || !isGallicaAddress(address)) return null;
720
+ const segment = /ark:\/12148\/([^/?#]+)/.exec(address)?.[1] ?? null;
721
+ if (!segment) return null;
722
+ const ark = segment.split(".")[0] ?? segment;
723
+ if (ark === "") return null;
724
+ return { ark, url: address, role, fromId, fromTitle };
725
+ }
726
+ function page(rows, limit, skipped) {
727
+ const more = rows.length > limit;
728
+ return skipped > 0 ? { rows: rows.slice(0, limit), hasMore: more, skipped } : { rows: rows.slice(0, limit), hasMore: more };
729
+ }
730
+ function byPredicate(results) {
731
+ const index = /* @__PURE__ */ new Map();
732
+ for (const row of results.results.bindings) {
733
+ const predicate = text(row.p);
734
+ if (predicate === null) continue;
735
+ const bucket = index.get(predicate);
736
+ if (bucket) bucket.push(row);
737
+ else index.set(predicate, [row]);
738
+ }
739
+ return index;
740
+ }
741
+ var firstValue = (index, predicate) => text(index.get(predicate)?.[0]?.o);
742
+ var allValues = (index, predicate) => {
743
+ const seen = /* @__PURE__ */ new Set();
744
+ for (const row of index.get(predicate) ?? []) {
745
+ const value = text(row.o);
746
+ if (value !== null) seen.add(value);
747
+ }
748
+ return [...seen];
749
+ };
750
+ var allTerms = (index, predicate) => allValues(index, predicate).filter((value) => value.startsWith("http")).map(vocabularyTerm);
751
+ var deweyClasses = (index, predicate) => allValues(index, predicate).filter((value) => value.includes("dewey.info/class/")).map(vocabularyTerm);
752
+ var allWords = (index, predicate) => allValues(index, predicate).filter((value) => !value.startsWith("http"));
753
+ function alignments(index, selfId) {
754
+ const found = {};
755
+ for (const predicate of [P.sameAs, P.exactMatch, P.closeMatch, P.seeAlso]) {
756
+ for (const address of allValues(index, predicate)) {
757
+ if (!address.startsWith("http")) continue;
758
+ if (selfId && address.includes(selfId)) continue;
759
+ let host;
760
+ try {
761
+ host = new URL(address).hostname.toLowerCase();
762
+ } catch {
763
+ continue;
764
+ }
765
+ const known = ALIGNMENT_HOSTS.find(
766
+ (entry) => host === entry.host || host.endsWith(`.${entry.host}`)
767
+ );
768
+ if (!known) continue;
769
+ (found[known.name] ??= /* @__PURE__ */ new Set()).add(address);
770
+ }
771
+ }
772
+ return Object.fromEntries(Object.entries(found).map(([name, set]) => [name, [...set]]));
773
+ }
774
+ function isniAddresses(index) {
775
+ return allValues(index, P.isni).map(
776
+ (value) => `https://isni.org/isni/${value.replace(/\s/g, "")}`
777
+ );
778
+ }
779
+ function toAuthorSummaries(results, limit) {
780
+ const rows = [];
781
+ const seen = /* @__PURE__ */ new Set();
782
+ let skipped = 0;
783
+ for (const row of results.results.bindings) {
784
+ const iri = text(row.person);
785
+ const id = iri === null ? null : idFromIri(iri);
786
+ if (iri === null || id === null) {
787
+ skipped += 1;
788
+ continue;
789
+ }
790
+ if (seen.has(id)) continue;
791
+ seen.add(id);
792
+ rows.push({
793
+ id,
794
+ name: text(row.name),
795
+ label: text(row.label),
796
+ birthYear: integer(row.birthYear),
797
+ deathYear: integer(row.deathYear),
798
+ role: text(row.role),
799
+ sourceUrl: publicPageFor(iri)
800
+ });
801
+ }
802
+ return page(rows, limit, skipped);
803
+ }
804
+ function toAuthorDetail(results, id, pageUrl) {
805
+ const index = byPredicate(results);
806
+ const types = allValues(index, P.type);
807
+ const otherNames = [];
808
+ const seenLabels = /* @__PURE__ */ new Set();
809
+ for (const row of index.get(P.altLabel) ?? []) {
810
+ const label = text(row.o);
811
+ if (label === null || seenLabels.has(label)) continue;
812
+ seenLabels.add(label);
813
+ otherNames.push({ label, language: text(row.lang) });
814
+ }
815
+ const sameAs = alignments(index, id);
816
+ const isni = isniAddresses(index);
817
+ if (isni.length > 0) sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
818
+ const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
819
+ const depictions = [];
820
+ for (const address of allValues(index, P.depiction)) {
821
+ const link = toDigitisedLink(address, "depiction", id, null);
822
+ if (link) depictions.push(link);
823
+ }
824
+ return {
825
+ id,
826
+ name: firstValue(index, P.name),
827
+ label: firstValue(index, P.prefLabel),
828
+ givenName: firstValue(index, P.givenName),
829
+ familyName: firstValue(index, P.familyName),
830
+ otherNames,
831
+ birthDate: firstValue(index, P.birth),
832
+ deathDate: firstValue(index, P.death),
833
+ birthYear: integer(index.get(P.firstYear)?.[0]?.o),
834
+ deathYear: integer(index.get(P.lastYear)?.[0]?.o),
835
+ birthPlace: firstValue(index, P.placeOfBirth),
836
+ deathPlace: firstValue(index, P.placeOfDeath),
837
+ biographicalInformation: firstValue(index, P.biographicalInformation),
838
+ occupation: firstValue(index, P.occupation),
839
+ languages: allTerms(index, P.languageOfThePerson),
840
+ countries: allTerms(index, P.countryAssociated),
841
+ fields: allWords(index, P.fieldOfActivity),
842
+ deweyClasses: deweyClasses(index, P.fieldOfActivity),
843
+ sameAs,
844
+ catalogueUrl,
845
+ recordCreated: firstValue(index, P.created),
846
+ recordModified: firstValue(index, P.modified),
847
+ depictions,
848
+ types,
849
+ truncated: results.results.bindings.length >= MOST_TRIPLES,
850
+ sourceUrl: pageUrl
851
+ };
852
+ }
853
+ function readStatus(statement, iri) {
854
+ if (statement !== null) {
855
+ const said = statement.toLowerCase();
856
+ if (said.includes("provisional")) return "provisional";
857
+ if (said.includes("established")) return "established";
858
+ }
859
+ return iri.includes("/temp-work/") ? "provisional" : "established";
860
+ }
861
+ function toWorkSummaries(results, limit) {
862
+ const order = [];
863
+ const byId = /* @__PURE__ */ new Map();
864
+ let skipped = 0;
865
+ for (const row of results.results.bindings) {
866
+ const iri = text(row.work);
867
+ const id = iri === null ? null : idFromIri(iri);
868
+ if (iri === null || id === null) {
869
+ skipped += 1;
870
+ continue;
871
+ }
872
+ let work = byId.get(id);
873
+ if (!work) {
874
+ work = {
875
+ id,
876
+ title: text(row.title),
877
+ date: text(row.date),
878
+ creators: [],
879
+ status: readStatus(text(row.status), iri),
880
+ sourceUrl: publicPageFor(iri)
881
+ };
882
+ byId.set(id, work);
883
+ order.push(id);
884
+ }
885
+ const creatorIri = text(row.creator);
886
+ const creatorId = creatorIri ? idFromIri(creatorIri) : null;
887
+ if (creatorId && !work.creators.some((c) => c.id === creatorId)) {
888
+ work.creators.push({ id: creatorId, name: text(row.creatorName) });
889
+ }
890
+ }
891
+ return page(
892
+ order.map((id) => byId.get(id)),
893
+ limit,
894
+ skipped
895
+ );
896
+ }
897
+ function toWorkDetail(results, id, iri, pageUrl) {
898
+ const index = byPredicate(results);
899
+ const types = allValues(index, P.type);
900
+ const truncated = results.results.bindings.length >= MOST_TRIPLES;
901
+ const creators = [];
902
+ for (const row of index.get(P.creator) ?? []) {
903
+ const creatorIri = text(row.o);
904
+ const creatorId = creatorIri ? idFromIri(creatorIri) : null;
905
+ if (!creatorId || creators.some((c) => c.id === creatorId)) continue;
906
+ creators.push({ id: creatorId, name: text(row.name) });
907
+ }
908
+ const depictions = [];
909
+ for (const address of allValues(index, P.depiction)) {
910
+ const link = toDigitisedLink(address, "depiction", id, null);
911
+ if (link) depictions.push(link);
912
+ }
913
+ const statusStatement = firstValue(index, P.statusOfIdentification);
914
+ const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
915
+ return {
916
+ id,
917
+ title: firstValue(index, P.title),
918
+ label: firstValue(index, P.label),
919
+ date: firstValue(index, P.date),
920
+ firstYear: integer(index.get(P.firstYear)?.[0]?.o),
921
+ creators,
922
+ languages: allTerms(index, P.language),
923
+ forms: allTerms(index, P.formOfWork),
924
+ subjects: allWords(index, P.subjectLabel),
925
+ deweyClasses: deweyClasses(index, P.subject),
926
+ status: readStatus(statusStatement, iri),
927
+ statusStatement,
928
+ // A cut record makes this the ceiling rather than the count, so it is
929
+ // withheld rather than published as a number the record does not support.
930
+ expressionCount: truncated ? null : allValues(index, P.expressionOfWork).length,
931
+ sameAs: alignments(index, id),
932
+ catalogueUrl,
933
+ depictions,
934
+ types,
935
+ truncated,
936
+ sourceUrl: pageUrl
937
+ };
938
+ }
939
+ function toEditions(results, limit) {
940
+ const order = [];
941
+ const byId = /* @__PURE__ */ new Map();
942
+ let skipped = 0;
943
+ for (const row of results.results.bindings) {
944
+ const iri = text(row.edition);
945
+ const id = iri === null ? null : idFromIri(iri);
946
+ if (iri === null || id === null) {
947
+ skipped += 1;
948
+ continue;
949
+ }
950
+ let edition = byId.get(id);
951
+ if (!edition) {
952
+ edition = {
953
+ id,
954
+ title: text(row.title),
955
+ date: text(row.date),
956
+ year: integer(row.year),
957
+ publisher: text(row.publisher),
958
+ place: text(row.place),
959
+ editionStatement: text(row.editionStatement),
960
+ extent: text(row.extent),
961
+ isbn: text(row.isbn),
962
+ note: text(row.note),
963
+ catalogueUrl: text(row.catalogue),
964
+ digitised: [],
965
+ sourceUrl: publicPageFor(iri)
966
+ };
967
+ byId.set(id, edition);
968
+ order.push(id);
969
+ }
970
+ for (const [column, role] of [
971
+ ["reproduction", "reproduction"],
972
+ ["ocr", "ocr"]
973
+ ]) {
974
+ const link = toDigitisedLink(text(row[column]), role, id, edition.title);
975
+ if (link && !edition.digitised.some((existing) => existing.url === link.url)) {
976
+ edition.digitised.push(link);
977
+ }
978
+ }
979
+ }
980
+ return page(
981
+ order.map((id) => byId.get(id)),
982
+ limit,
983
+ skipped
984
+ );
985
+ }
986
+ function toDigitisedLinks(results, limit) {
987
+ const links = [];
988
+ const seen = /* @__PURE__ */ new Set();
989
+ const rowCount = results.results.bindings.length;
990
+ for (const row of results.results.bindings) {
991
+ const fromIri = text(row.edition);
992
+ const fromId = fromIri ? idFromIri(fromIri) ?? fromIri : "";
993
+ const title = text(row.title);
994
+ for (const [column, role] of [
995
+ ["reproduction", "reproduction"],
996
+ ["ocr", "ocr"],
997
+ ["depiction", "depiction"]
998
+ ]) {
999
+ const link = toDigitisedLink(text(row[column]), role, fromId, title);
1000
+ if (!link || seen.has(link.url)) continue;
1001
+ seen.add(link.url);
1002
+ links.push(link);
1003
+ }
1004
+ }
1005
+ return { rows: links.slice(0, limit), hasMore: links.length > limit || rowCount > limit };
1006
+ }
1007
+ function toTypes(results) {
1008
+ const types = /* @__PURE__ */ new Set();
1009
+ for (const row of results.results.bindings) {
1010
+ const iri = text(row.type);
1011
+ if (iri !== null) types.add(iri);
1012
+ }
1013
+ return [...types];
1014
+ }
1015
+
1016
+ // src/bnf/rateLimiter.ts
1017
+ var RateLimiter = class {
1018
+ baseIntervalMs;
1019
+ maxIntervalMs;
1020
+ intervalMs;
1021
+ lastStartedAt = 0;
1022
+ queue = Promise.resolve();
1023
+ calmStreak = 0;
1024
+ constructor(options) {
1025
+ this.baseIntervalMs = options.intervalMs;
1026
+ this.maxIntervalMs = options.maxIntervalMs ?? options.intervalMs * 8;
1027
+ this.intervalMs = options.intervalMs;
1028
+ }
1029
+ /** The spacing in force, which callers report rather than guess. */
1030
+ get currentIntervalMs() {
1031
+ return this.intervalMs;
1032
+ }
1033
+ /**
1034
+ * Run `task` with nothing else in flight.
1035
+ *
1036
+ * This only serialises. Spacing is claimed per attempt through
1037
+ * `beforeRequest`, because a task that retries makes several requests and
1038
+ * each of them owes the service the same gap.
1039
+ */
1040
+ schedule(task) {
1041
+ const result = this.queue.then(task, task);
1042
+ this.queue = result.then(
1043
+ () => void 0,
1044
+ () => void 0
1045
+ );
1046
+ return result;
1047
+ }
1048
+ /** Wait until enough time has passed since the previous request began. */
1049
+ async beforeRequest() {
1050
+ const elapsed = Date.now() - this.lastStartedAt;
1051
+ const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
1052
+ if (wait > 0) await sleep(wait);
1053
+ this.lastStartedAt = Date.now();
1054
+ }
1055
+ /** Called when the service asks for room: double the gap, up to the ceiling. */
1056
+ pushBack() {
1057
+ this.calmStreak = 0;
1058
+ this.intervalMs = Math.min(this.maxIntervalMs, this.intervalMs * 2);
1059
+ }
1060
+ /**
1061
+ * Called on a clean answer. Recovery takes several in a row, so one lucky
1062
+ * response after a rough patch does not undo the caution that earned it.
1063
+ */
1064
+ succeeded() {
1065
+ if (this.intervalMs === this.baseIntervalMs) return;
1066
+ this.calmStreak += 1;
1067
+ if (this.calmStreak < 3) return;
1068
+ this.calmStreak = 0;
1069
+ this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
1070
+ }
1071
+ };
1072
+ function sleep(ms) {
1073
+ return new Promise((resolve) => setTimeout(resolve, ms));
1074
+ }
1075
+
1076
+ // src/bnf/client.ts
1077
+ var PERSON_CLASS = "http://xmlns.com/foaf/0.1/Person";
1078
+ var WORK_CLASSES = /* @__PURE__ */ new Set([
1079
+ "http://rdvocab.info/uri/schema/FRBRentitiesRDA/Work",
1080
+ "http://rdaregistry.info/Elements/c/#C10001"
1081
+ ]);
1082
+ function withGuarantees(config) {
1083
+ const defaults = loadConfig({});
1084
+ const bounded = (value, fallback, min, max) => {
1085
+ const parsed = Number(value);
1086
+ if (!Number.isFinite(parsed)) return fallback;
1087
+ return Math.min(max, Math.max(min, parsed));
1088
+ };
1089
+ const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
1090
+ const identifier = defaults.userAgent;
1091
+ return {
1092
+ ...config,
1093
+ // A caller may say who they are. Appending rather than replacing means the
1094
+ // BnF can always tell which software is calling, and reach someone.
1095
+ userAgent: claimed === "" || claimed.includes(REPO_URL) ? identifier : `${claimed} ${identifier}`,
1096
+ minIntervalMs: bounded(
1097
+ config.minIntervalMs,
1098
+ defaults.minIntervalMs,
1099
+ MIN_ALLOWED_INTERVAL_MS,
1100
+ MAX_ALLOWED_INTERVAL_MS
1101
+ ),
1102
+ timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1e3, 3e5),
1103
+ maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),
1104
+ cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 864e5),
1105
+ cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5e3)
1106
+ };
1107
+ }
1108
+ var BnfClient = class {
1109
+ config;
1110
+ logger;
1111
+ limiter;
1112
+ cache;
1113
+ /** Questions already on the wire, so an identical one waits instead of asking. */
1114
+ inFlight = /* @__PURE__ */ new Map();
1115
+ fetchImpl;
1116
+ constructor(options = {}) {
1117
+ const base = { ...loadConfig(), ...options.config };
1118
+ this.config = withGuarantees(base);
1119
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
1120
+ this.limiter = new RateLimiter({ intervalMs: this.config.minIntervalMs });
1121
+ this.cache = new Cache(this.config.cacheTtlMs, this.config.cacheMaxEntries);
1122
+ this.fetchImpl = options.fetchImpl;
1123
+ }
1124
+ /** The pacing in force, which widens when the endpoint pushes back. */
1125
+ get intervalMs() {
1126
+ return this.limiter.currentIntervalMs;
1127
+ }
1128
+ /** What the BnF sees this client call itself. */
1129
+ get userAgent() {
1130
+ return this.config.userAgent;
1131
+ }
1132
+ /** The deadline one query is given, which callers report rather than guess. */
1133
+ get timeoutMs() {
1134
+ return this.config.timeoutMs;
1135
+ }
1136
+ /** The one address this client sends anything to. */
1137
+ get endpoint() {
1138
+ return SPARQL_ENDPOINT;
1139
+ }
1140
+ /**
1141
+ * `cacheKey` is stated rather than taken from the query, because two reads of
1142
+ * one query can produce different values: a list cut to five rows is not the
1143
+ * same value as the same list cut to twenty, and serving one for the other
1144
+ * would answer a size the caller never asked for.
1145
+ */
1146
+ read(cacheKey, query, parse) {
1147
+ const hit = this.cache.get(cacheKey);
1148
+ if (hit !== void 0) {
1149
+ this.logger.debug(`cache hit ${cacheKey}`);
1150
+ return Promise.resolve({
1151
+ data: hit.value,
1152
+ cached: true,
1153
+ retrievedAt: new Date(hit.retrievedAt).toISOString()
1154
+ });
1155
+ }
1156
+ const flying = this.inFlight.get(cacheKey);
1157
+ if (flying !== void 0) {
1158
+ this.logger.debug(`joining a request already in flight for ${cacheKey}`);
1159
+ return flying;
1160
+ }
1161
+ const flight = this.fetchAndParse(cacheKey, query, parse);
1162
+ this.inFlight.set(cacheKey, flight);
1163
+ return flight.finally(() => this.inFlight.delete(cacheKey));
1164
+ }
1165
+ async fetchAndParse(cacheKey, query, parse) {
1166
+ const results = await this.limiter.schedule(
1167
+ () => runQuery({
1168
+ query,
1169
+ userAgent: this.config.userAgent,
1170
+ timeoutMs: this.config.timeoutMs,
1171
+ maxRetries: this.config.maxRetries,
1172
+ limiter: this.limiter,
1173
+ logger: this.logger,
1174
+ ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
1175
+ })
1176
+ );
1177
+ const retrievedAt = Date.now();
1178
+ const data = parse(results);
1179
+ this.cache.set(cacheKey, data, retrievedAt);
1180
+ return { data, cached: false, retrievedAt: new Date(retrievedAt).toISOString() };
1181
+ }
1182
+ /** Read an identifier a caller wrote, refusing anything this dataset cannot address. */
1183
+ identify(input) {
1184
+ return parseEntityId(input);
1185
+ }
1186
+ /** The words a search will actually look for, which a tool reports back. */
1187
+ words(input) {
1188
+ return toSearchWords(input);
1189
+ }
1190
+ searchAuthors(name, limit, offset) {
1191
+ const words = toSearchWords(name);
1192
+ const query = searchAuthorsQuery(words, limit, offset);
1193
+ return this.read(
1194
+ `authors:${words.join("+")}:${limit}:${offset}`,
1195
+ query,
1196
+ (results) => toAuthorSummaries(results, limit)
1197
+ );
1198
+ }
1199
+ async getAuthor(id) {
1200
+ const result = await this.read(
1201
+ `author:${id.id}`,
1202
+ authorQuery(id),
1203
+ (results) => toAuthorDetail(results, id.id, id.pageUrl)
1204
+ );
1205
+ if (!result.data.types.includes(PERSON_CLASS)) {
1206
+ await this.refuse(id, result.data.types, "a person", "get_work reads a work.");
1207
+ }
1208
+ return result;
1209
+ }
1210
+ /**
1211
+ * Say what an address is, when it is not the kind of thing that was asked for.
1212
+ *
1213
+ * A record the catalogue does not describe at all and a record describing
1214
+ * something else are two different answers, and only one of them means the
1215
+ * caller should go looking elsewhere.
1216
+ */
1217
+ async refuse(id, types, wanted, hint) {
1218
+ const known = types.length > 0 ? types : (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
1219
+ if (known.length === 0) {
1220
+ throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1221
+ hint: "Find the identifier with search_authors or search_works rather than writing one.",
1222
+ url: id.pageUrl
1223
+ });
1224
+ }
1225
+ throw notFound(
1226
+ `"${id.id}" is described by data.bnf.fr, and it is not ${wanted}: it is typed ${known.join(", ")}.`,
1227
+ { hint, url: id.pageUrl }
1228
+ );
1229
+ }
1230
+ searchWorks(title, limit, offset) {
1231
+ const words = toSearchWords(title);
1232
+ const query = searchWorksQuery(words, limit, offset);
1233
+ return this.read(
1234
+ `works:${words.join("+")}:${limit}:${offset}`,
1235
+ query,
1236
+ (results) => toWorkSummaries(results, limit)
1237
+ );
1238
+ }
1239
+ async getWork(id) {
1240
+ const result = await this.read(
1241
+ `work:${id.id}`,
1242
+ workQuery(id),
1243
+ (results) => toWorkDetail(results, id.id, id.iri, id.pageUrl)
1244
+ );
1245
+ if (!result.data.types.some((type) => WORK_CLASSES.has(type))) {
1246
+ await this.refuse(
1247
+ id,
1248
+ result.data.types,
1249
+ "a work",
1250
+ "get_author reads a person, and list_editions reads the editions of a work."
1251
+ );
1252
+ }
1253
+ return result;
1254
+ }
1255
+ listEditions(id, limit, offset) {
1256
+ const query = editionsQuery(id, limit, offset);
1257
+ return this.read(
1258
+ `editions:${id.id}:${limit}:${offset}`,
1259
+ query,
1260
+ (results) => toEditions(results, limit)
1261
+ );
1262
+ }
1263
+ /** Digitised documents attached to a work and to its editions. */
1264
+ digitisedForWork(id, limit) {
1265
+ return this.read(
1266
+ `digitised-work:${id.id}:${limit}`,
1267
+ digitisedForWorkQuery(id, limit),
1268
+ (results) => toDigitisedLinks(results, limit)
1269
+ );
1270
+ }
1271
+ /** Digitised documents attached to a person and to the editions of their works. */
1272
+ digitisedForPerson(id, limit) {
1273
+ return this.read(
1274
+ `digitised-person:${id.id}:${limit}`,
1275
+ digitisedForPersonQuery(id, limit),
1276
+ (results) => toDigitisedLinks(results, limit)
1277
+ );
1278
+ }
1279
+ /** What an address is typed as, which tells a person from a work. */
1280
+ types(id) {
1281
+ return this.read(`types:${id.id}`, kindQuery(id), toTypes);
1282
+ }
1283
+ };
1284
+
1285
+ // src/tools/findDigitised.ts
1286
+ import { z as z3 } from "zod";
1287
+
1288
+ // src/tools/arguments.ts
1289
+ import { z } from "zod";
1290
+ var INVALID_INPUT = "invalid_input";
1291
+ function strictInput(shape) {
1292
+ const declared = Object.keys(shape);
1293
+ return z.strictObject(shape, {
1294
+ error: (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : void 0
1295
+ });
1296
+ }
1297
+ function unknownArgumentMessage(keys, declared) {
1298
+ const named = keys.map((key) => {
1299
+ const near = nearestArgument(key, declared);
1300
+ return near ? `'${key}' (did you mean '${near}'?)` : `'${key}'`;
1301
+ }).join(", ");
1302
+ return `[${INVALID_INPUT}] Unknown ${keys.length > 1 ? "arguments" : "argument"} ${named}. This tool takes: ${declared.join(", ")}.`;
1303
+ }
1304
+ function nearestArgument(key, declared) {
1305
+ const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
1306
+ const flat = flatten(key);
1307
+ if (flat.length === 0) return void 0;
1308
+ const sameName = declared.find((name) => flatten(name) === flat);
1309
+ if (sameName) return sameName;
1310
+ const overlapping = declared.find((name) => {
1311
+ const other = flatten(name);
1312
+ const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
1313
+ return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
1314
+ });
1315
+ if (overlapping) return overlapping;
1316
+ let closest;
1317
+ let shortest = Number.POSITIVE_INFINITY;
1318
+ for (const name of declared) {
1319
+ const distance = editDistance(flat, flatten(name));
1320
+ if (distance < shortest) {
1321
+ shortest = distance;
1322
+ closest = name;
1323
+ }
1324
+ }
1325
+ return shortest <= Math.max(1, Math.floor(flat.length / 3)) ? closest : void 0;
1326
+ }
1327
+ function editDistance(left, right) {
1328
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
1329
+ for (let row = 1; row <= left.length; row += 1) {
1330
+ const current = [row];
1331
+ for (let column = 1; column <= right.length; column += 1) {
1332
+ const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
1333
+ current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
1334
+ }
1335
+ previous = current;
1336
+ }
1337
+ return previous[right.length];
1338
+ }
1339
+
1340
+ // src/tools/shared.ts
1341
+ import { z as z2 } from "zod";
1342
+ var MAX_TEXT_CHARS = 2400;
1343
+ var SOURCE_NAME = "data.bnf.fr (Biblioth\xE8que nationale de France)";
1344
+ function retrievalDay(retrievedAt) {
1345
+ const day = retrievedAt.slice(0, 10);
1346
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : retrievedAt;
1347
+ }
1348
+ function attribution(retrievedAt, sourceUrl) {
1349
+ const credit = `Source: ${SOURCE_NAME}, retrieved ${retrievalDay(retrievedAt)}`;
1350
+ return sourceUrl ? `${credit} \u2014 ${sourceUrl}` : credit;
1351
+ }
1352
+ var retrievedAtSchema = z2.string().describe(
1353
+ "When these metadata were read from data.bnf.fr, as an ISO 8601 instant. The BnF licence asks for this date to be stated wherever the metadata are shown, so repeat it alongside the source."
1354
+ );
1355
+ var digitisedLinkSchema = z2.object({
1356
+ ark: z2.string().describe("The Gallica ARK identifying the document."),
1357
+ url: z2.string().describe("Open this to see the document. This server does not read it."),
1358
+ role: z2.enum(["reproduction", "ocr", "depiction"]).describe(
1359
+ "'reproduction' is the edition itself digitised. 'ocr' is the text a machine read off it. 'depiction' is an image illustrating the record, which can be a portrait or a page mentioning the subject rather than the work."
1360
+ ),
1361
+ from_id: z2.string().describe("The record this link hangs off."),
1362
+ from_title: z2.string().nullable()
1363
+ });
1364
+ function indentMarkerLines(body) {
1365
+ return body.replace(/^(Note|Source|Hint)(\s*:)/gim, " $1$2");
1366
+ }
1367
+ function onOneLine(value) {
1368
+ return value.replace(/[\r\n\u2028\u2029]+/g, " ").trim();
1369
+ }
1370
+ function ok(structured, body, options) {
1371
+ const credit = attribution(options.retrievedAt, options.sourceUrl);
1372
+ const noteLines = (options.notes ?? []).map((note) => `Note: ${onOneLine(note)}`);
1373
+ while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) noteLines.pop();
1374
+ const trailer = [...noteLines, credit].join("\n");
1375
+ const cut = "\n\n[shortened; the full result is in the structured output]";
1376
+ const budget = MAX_TEXT_CHARS - `
1377
+
1378
+ ${trailer}`.length;
1379
+ const safe = indentMarkerLines(body);
1380
+ const text2 = safe.length <= budget ? `${safe}
1381
+
1382
+ ${trailer}` : `${truncate(safe, Math.max(0, budget - cut.length))}${cut}
1383
+
1384
+ ${trailer}`;
1385
+ return { content: [{ type: "text", text: text2 }], structuredContent: structured };
1386
+ }
1387
+ function toToolError(error) {
1388
+ const known = error instanceof BnfError ? error : new BnfError("network_error", error instanceof Error ? error.message : String(error));
1389
+ const lines = [`[${known.code}] ${onOneLine(known.message)}`];
1390
+ if (known.details.hint) lines.push(`Hint: ${onOneLine(known.details.hint)}`);
1391
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1392
+ }
1393
+ function truncate(text2, maxChars) {
1394
+ if (text2.length <= maxChars) return text2;
1395
+ return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
1396
+ }
1397
+ var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. These rows are in the order the index returned them, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page.";
1398
+ var GALLICA_CAVEAT = "These are links for a person to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it cannot say whether a document opens, what it contains, or on what terms it may be reused.";
1399
+ var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
1400
+
1401
+ // src/tools/findDigitised.ts
1402
+ var PERSON_TYPE = "http://xmlns.com/foaf/0.1/Person";
1403
+ var WORK_TYPES = [
1404
+ "http://rdvocab.info/uri/schema/FRBRentitiesRDA/Work",
1405
+ "http://rdaregistry.info/Elements/c/#C10001"
1406
+ ];
1407
+ var findDigitisedDescription = [
1408
+ "Gather the digitised documents the Biblioth\xE8que nationale de France catalogue attaches to one person or one work, and return them as links.",
1409
+ "Give the identifier of either; the tool reads what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with.",
1410
+ "Every result is a link for someone to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it reports nothing about what is at the other end: not whether the document opens, not what it contains, not on what terms it may be reused.",
1411
+ "It returns links and nothing else: no publisher, no date, no ISBN. Use list_editions when which edition a copy belongs to matters.",
1412
+ "A 'depiction' illustrates a record and can be a page that merely mentions the subject. A 'reproduction' is an edition digitised. An 'ocr' link names a machine-read text of a document, which this server does not fetch."
1413
+ ].join(" ");
1414
+ var findDigitisedInput = strictInput({
1415
+ id: z3.string().min(1).max(200).describe(
1416
+ "A person or work identifier, such as 'cb119219976' or 'cb11970626n'. The argument is named 'id' rather than 'work_id' or 'author_id' because it takes either."
1417
+ ),
1418
+ kind: z3.enum(["auto", "person", "work"]).default("auto").describe(
1419
+ "Which kind of record the identifier names. 'auto' asks the catalogue, which costs one extra query."
1420
+ ),
1421
+ limit: z3.number().int().min(1).max(200).default(40)
1422
+ });
1423
+ var findDigitisedOutput = z3.object({
1424
+ id: z3.string(),
1425
+ kind: z3.enum(["person", "work"]).describe("What the catalogue types this record as."),
1426
+ links: z3.array(digitisedLinkSchema),
1427
+ counts: z3.object({
1428
+ reproduction: z3.number().int(),
1429
+ ocr: z3.number().int(),
1430
+ depiction: z3.number().int()
1431
+ }).describe("Links returned on this page, by role. These count links, not documents."),
1432
+ has_more: z3.boolean(),
1433
+ retrieved_at: retrievedAtSchema,
1434
+ notes: z3.array(z3.string())
1435
+ });
1436
+ async function runFindDigitised(client, args) {
1437
+ try {
1438
+ const id = client.identify(args.id);
1439
+ let kind;
1440
+ const notes = [];
1441
+ if (args.kind === "auto") {
1442
+ if (id.kind === "temp-work") {
1443
+ kind = "work";
1444
+ } else {
1445
+ const types = await client.types(id);
1446
+ if (types.data.length === 0) {
1447
+ return toToolError(
1448
+ notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1449
+ hint: "Find the identifier with search_authors or search_works rather than writing one.",
1450
+ url: id.pageUrl
1451
+ })
1452
+ );
1453
+ }
1454
+ if (types.data.includes(PERSON_TYPE)) kind = "person";
1455
+ else if (types.data.some((type) => WORK_TYPES.includes(type))) kind = "work";
1456
+ else {
1457
+ return toToolError(
1458
+ notFound(
1459
+ `"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
1460
+ {
1461
+ hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
1462
+ url: id.pageUrl
1463
+ }
1464
+ )
1465
+ );
1466
+ }
1467
+ }
1468
+ } else {
1469
+ kind = args.kind;
1470
+ }
1471
+ const { data, cached, retrievedAt } = kind === "person" ? await client.digitisedForPerson(id, args.limit) : await client.digitisedForWork(id, args.limit);
1472
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1473
+ const links = data.rows.map((link) => ({
1474
+ ark: link.ark,
1475
+ url: link.url,
1476
+ role: link.role,
1477
+ from_id: link.fromId,
1478
+ from_title: link.fromTitle
1479
+ }));
1480
+ const counts = {
1481
+ reproduction: countRole(data.rows, "reproduction"),
1482
+ ocr: countRole(data.rows, "ocr"),
1483
+ depiction: countRole(data.rows, "depiction")
1484
+ };
1485
+ if (links.length > 0) notes.push(GALLICA_CAVEAT);
1486
+ if (counts.depiction > 0) {
1487
+ notes.push(
1488
+ `${counts.depiction === 1 ? "One of these is an image" : `${counts.depiction} of these are images`} the catalogue uses to illustrate the record. Such an image can be a portrait, a title page, or a newspaper page that mentions the subject in passing, and the catalogue does not say which.`
1489
+ );
1490
+ }
1491
+ if (counts.ocr > 0) {
1492
+ notes.push(
1493
+ `${counts.ocr === 1 ? "One of these points" : `${counts.ocr} of these point`} at a text a machine read off a scanned document. This server names that text and does not read it.`
1494
+ );
1495
+ }
1496
+ if (data.hasMore) {
1497
+ notes.push(
1498
+ args.limit >= 200 ? "The catalogue points at more documents than this tool will return in one answer, and it does not page: 200 is the ceiling. Read the editions with list_editions to reach the rest, one page at a time." : `More links exist than were returned. Raise 'limit' to see more of them, up to 200. This tool does not page, so a higher limit re-reads the links already shown rather than continuing after them.`
1499
+ );
1500
+ }
1501
+ if (links.length === 0) {
1502
+ notes.push(
1503
+ `The catalogue attaches no digitised document to "${id.id}". That is a statement about the catalogue: the BnF digitises a fraction of what it holds, and a document can be on Gallica without the record here pointing at it.`
1504
+ );
1505
+ }
1506
+ const body = links.length === 0 ? `The BnF catalogue attaches no digitised document to "${id.id}".` : `${links.length} link(s) for "${id.id}" (${kind}):
1507
+ ${links.map((link, index) => {
1508
+ const from = link.from_title ? ` \xB7 from: ${link.from_title}` : "";
1509
+ return `${index + 1}. [${link.role}] ${link.url}${from}`;
1510
+ }).join("\n")}`;
1511
+ return ok(
1512
+ {
1513
+ id: id.id,
1514
+ kind,
1515
+ links,
1516
+ counts,
1517
+ has_more: data.hasMore,
1518
+ retrieved_at: retrievedAt,
1519
+ notes
1520
+ },
1521
+ body,
1522
+ { retrievedAt, notes, sourceUrl: id.pageUrl }
1523
+ );
1524
+ } catch (error) {
1525
+ return toToolError(error);
1526
+ }
1527
+ }
1528
+ var countRole = (links, role) => links.filter((link) => link.role === role).length;
1529
+
1530
+ // src/tools/getAuthor.ts
1531
+ import { z as z4 } from "zod";
1532
+ var getAuthorDescription = [
1533
+ "Read one person's record in the Biblioth\xE8que nationale de France authority file, by the identifier search_authors returns.",
1534
+ "It carries the dates and places of birth and death, the occupation the record names, the language and country it associates with the person, the field of activity with its Dewey class, and the addresses for the same person in VIAF, IdRef, DBpedia, Wikidata and ISNI.",
1535
+ "It carries no biography. The field the BnF calls biographical information is a job title on most records and a single word on many, so quote it as what the catalogue states and do not build a life story out of it.",
1536
+ "It does not list the person's works. The catalogue links them, and no tool here walks that link: search_works reads titles, so a work is found by its title rather than by its author.",
1537
+ "'depictions' are images data.bnf.fr attaches to the person. They are links for a person to open; this server does not read them, and an image can be a page that mentions the person rather than a portrait of them."
1538
+ ].join(" ");
1539
+ var getAuthorInput = strictInput({
1540
+ author_id: z4.string().min(1).max(200).describe(
1541
+ "The identifier from search_authors, such as 'cb119219976'. A full data.bnf.fr address is accepted too."
1542
+ ),
1543
+ include_depictions: z4.boolean().default(false).describe(
1544
+ "Include the images data.bnf.fr attaches to the person. A well-known person can carry dozens, so they are left out by default. find_digitised gathers them with everything else."
1545
+ )
1546
+ });
1547
+ var getAuthorOutput = z4.object({
1548
+ author: z4.object({
1549
+ id: z4.string(),
1550
+ name: z4.string().nullable(),
1551
+ label: z4.string().nullable().describe("The authority heading, usually with the dates."),
1552
+ given_name: z4.string().nullable(),
1553
+ family_name: z4.string().nullable(),
1554
+ other_names: z4.array(z4.object({ label: z4.string(), language: z4.string().nullable() })).describe("Other headings the record carries, including transliterations and pen names."),
1555
+ birth_date: z4.string().nullable().describe("As published, usually YYYY-MM-DD."),
1556
+ death_date: z4.string().nullable(),
1557
+ birth_year: z4.number().int().nullable(),
1558
+ death_year: z4.number().int().nullable(),
1559
+ birth_place: z4.string().nullable(),
1560
+ death_place: z4.string().nullable(),
1561
+ biographical_information: z4.string().nullable().describe(
1562
+ "What the record states about the person. This is a job title on most records and a single word on many; it is not a biography."
1563
+ ),
1564
+ occupation: z4.string().nullable(),
1565
+ languages: z4.array(z4.string()).describe("ISO 639-2 codes, such as 'fre'."),
1566
+ countries: z4.array(z4.string()).describe("Country codes, such as 'fr'."),
1567
+ fields: z4.array(z4.string()).describe("Fields of activity in the words of the record."),
1568
+ dewey_classes: z4.array(z4.string()).describe("Dewey classes the fields point at, such as '800' for literature."),
1569
+ same_as: z4.record(z4.string(), z4.array(z4.string())).describe("Addresses for the same person in other files, grouped by file."),
1570
+ catalogue_url: z4.string().nullable().describe("The record in the BnF general catalogue."),
1571
+ record_created: z4.string().nullable().describe("When the authority record was created."),
1572
+ record_modified: z4.string().nullable().describe("When it was last changed."),
1573
+ source_url: z4.string()
1574
+ }),
1575
+ depictions: z4.array(digitisedLinkSchema).optional(),
1576
+ depiction_count: z4.number().int().describe(
1577
+ "Images on Gallica that the record points at, counted whether or not they were returned. An illustration the record holds elsewhere is not counted, because this server describes Gallica documents and knows nothing about the others."
1578
+ ),
1579
+ retrieved_at: retrievedAtSchema,
1580
+ notes: z4.array(z4.string())
1581
+ });
1582
+ async function runGetAuthor(client, args) {
1583
+ try {
1584
+ const id = client.identify(args.author_id);
1585
+ const { data, cached, retrievedAt } = await client.getAuthor(id);
1586
+ const notes = [];
1587
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1588
+ if (data.biographicalInformation !== null) {
1589
+ notes.push(
1590
+ `The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
1591
+ );
1592
+ }
1593
+ if (data.deathDate === null && data.deathYear === null) {
1594
+ notes.push(
1595
+ "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two."
1596
+ );
1597
+ }
1598
+ if (Object.keys(data.sameAs).length > 0) {
1599
+ notes.push(
1600
+ "The addresses under 'same_as' are the BnF's own alignments to other files. Following one is how to reach a biography, which this catalogue does not hold."
1601
+ );
1602
+ }
1603
+ if (data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
1604
+ const structured = {
1605
+ author: {
1606
+ id: data.id,
1607
+ name: data.name,
1608
+ label: data.label,
1609
+ given_name: data.givenName,
1610
+ family_name: data.familyName,
1611
+ other_names: data.otherNames,
1612
+ birth_date: data.birthDate,
1613
+ death_date: data.deathDate,
1614
+ birth_year: data.birthYear,
1615
+ death_year: data.deathYear,
1616
+ birth_place: data.birthPlace,
1617
+ death_place: data.deathPlace,
1618
+ biographical_information: data.biographicalInformation,
1619
+ occupation: data.occupation,
1620
+ languages: data.languages,
1621
+ countries: data.countries,
1622
+ fields: data.fields,
1623
+ dewey_classes: data.deweyClasses,
1624
+ same_as: data.sameAs,
1625
+ catalogue_url: data.catalogueUrl,
1626
+ record_created: data.recordCreated,
1627
+ record_modified: data.recordModified,
1628
+ source_url: data.sourceUrl
1629
+ },
1630
+ depiction_count: data.depictions.length,
1631
+ retrieved_at: retrievedAt,
1632
+ notes
1633
+ };
1634
+ if (args.include_depictions) {
1635
+ structured.depictions = data.depictions.map((link) => ({
1636
+ ark: link.ark,
1637
+ url: link.url,
1638
+ role: link.role,
1639
+ from_id: link.fromId,
1640
+ from_title: link.fromTitle
1641
+ }));
1642
+ }
1643
+ const born = [
1644
+ data.birthDate ?? (data.birthYear !== null ? String(data.birthYear) : null),
1645
+ data.birthPlace
1646
+ ].filter(Boolean).join(", ");
1647
+ const died = [
1648
+ data.deathDate ?? (data.deathYear !== null ? String(data.deathYear) : null),
1649
+ data.deathPlace
1650
+ ].filter(Boolean).join(", ");
1651
+ const lines = [
1652
+ data.label ?? data.name ?? data.id,
1653
+ born ? `Born: ${born}` : "",
1654
+ died ? `Died: ${died}` : "",
1655
+ data.occupation ? `Occupation as recorded: ${data.occupation}` : "",
1656
+ data.fields.length > 0 ? `Field of activity: ${data.fields.join(", ")}${data.deweyClasses.length > 0 ? ` (Dewey ${data.deweyClasses.join(", ")})` : ""}` : "",
1657
+ data.languages.length > 0 ? `Language: ${data.languages.join(", ")}` : "",
1658
+ data.countries.length > 0 ? `Country: ${data.countries.join(", ")}` : "",
1659
+ data.otherNames.length > 0 ? `Also recorded as: ${data.otherNames.map((other) => other.label).join("; ")}` : "",
1660
+ Object.keys(data.sameAs).length > 0 ? `Same person in: ${Object.entries(data.sameAs).map(([file, addresses]) => `${file} ${addresses[0]}`).join(" \xB7 ")}` : "",
1661
+ data.catalogueUrl ? `BnF catalogue: ${data.catalogueUrl}` : "",
1662
+ data.depictions.length > 0 ? `Images attached to this record: ${data.depictions.length}${args.include_depictions ? "" : " (ask with include_depictions, or use find_digitised)"}` : ""
1663
+ ].filter(Boolean);
1664
+ return ok(structured, lines.join("\n"), { retrievedAt, notes, sourceUrl: data.sourceUrl });
1665
+ } catch (error) {
1666
+ return toToolError(error);
1667
+ }
1668
+ }
1669
+
1670
+ // src/tools/getWork.ts
1671
+ import { z as z5 } from "zod";
1672
+ var getWorkDescription = [
1673
+ "Read one work's record in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works returns.",
1674
+ "It carries the title, everyone the record credits, the date the BnF gives the work, its language, its form and its subject with the Dewey class.",
1675
+ "'status' says whether the record is established or provisional, and 'status_statement' repeats what the catalogue itself states. A provisional identifier can change, so cite an established one where there is a choice.",
1676
+ "'expression_count' counts the expressions the record links, which is not a count of published editions: use list_editions for those."
1677
+ ].join(" ");
1678
+ var getWorkInput = strictInput({
1679
+ work_id: z5.string().min(1).max(200).describe(
1680
+ "The identifier from search_works, such as 'cb11970626n' or 'temp-work/22d7f68c1a4bdd081ad7ca791fd3b730'."
1681
+ ),
1682
+ include_depictions: z5.boolean().default(false).describe(
1683
+ "Include the images data.bnf.fr attaches to the work. A well-known work can carry dozens, so they are left out by default. find_digitised gathers them alongside the digitised editions."
1684
+ )
1685
+ });
1686
+ var getWorkOutput = z5.object({
1687
+ work: z5.object({
1688
+ id: z5.string(),
1689
+ title: z5.string().nullable(),
1690
+ label: z5.string().nullable(),
1691
+ date: z5.string().nullable().describe("The date the record gives the work, as published."),
1692
+ first_year: z5.number().int().nullable(),
1693
+ creators: z5.array(z5.object({ id: z5.string(), name: z5.string().nullable() })),
1694
+ languages: z5.array(z5.string()).describe("ISO 639-2 codes."),
1695
+ forms: z5.array(z5.string()).describe("Forms from the BnF work-form vocabulary, such as 'poesi' for poetry."),
1696
+ subjects: z5.array(z5.string()).describe("Subjects in the words of the record."),
1697
+ dewey_classes: z5.array(z5.string()),
1698
+ status: z5.enum(["established", "provisional"]),
1699
+ status_statement: z5.string().nullable().describe("What the record itself states, such as 'fully established' or 'provisional'."),
1700
+ expression_count: z5.number().int().nullable().describe(
1701
+ "Expressions the record links: translations, adaptations, recordings. This is not a count of published editions. Null when the record was longer than one query reads, since the number would then be the ceiling rather than the count."
1702
+ ),
1703
+ same_as: z5.record(z5.string(), z5.array(z5.string())),
1704
+ catalogue_url: z5.string().nullable(),
1705
+ source_url: z5.string()
1706
+ }),
1707
+ depictions: z5.array(digitisedLinkSchema).optional(),
1708
+ depiction_count: z5.number().int().describe(
1709
+ "Images on Gallica that the record points at, counted whether or not they were returned."
1710
+ ),
1711
+ retrieved_at: retrievedAtSchema,
1712
+ notes: z5.array(z5.string())
1713
+ });
1714
+ async function runGetWork(client, args) {
1715
+ try {
1716
+ const id = client.identify(args.work_id);
1717
+ const { data, cached, retrievedAt } = await client.getWork(id);
1718
+ const notes = [];
1719
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1720
+ if (data.status === "provisional") {
1721
+ notes.push(
1722
+ `This is a provisional record, addressed under 'temp-work' rather than by an ARK${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. ${PROVISIONAL_CAVEAT}`
1723
+ );
1724
+ } else {
1725
+ notes.push(
1726
+ `This is an established work record, addressed by the ARK '${data.id}'${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. That identifier is the one to cite.`
1727
+ );
1728
+ }
1729
+ if (data.truncated) {
1730
+ notes.push(
1731
+ "This record is longer than one query reads, so parts of it are missing from this answer. A field shown as empty here may be filled on the record itself: open source_url before concluding that the BnF states nothing."
1732
+ );
1733
+ }
1734
+ if (data.expressionCount !== null && data.expressionCount > 0) {
1735
+ notes.push(
1736
+ `The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
1737
+ );
1738
+ }
1739
+ if (data.creators.length === 0) {
1740
+ notes.push(
1741
+ "The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
1742
+ );
1743
+ }
1744
+ if (data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
1745
+ const structured = {
1746
+ work: {
1747
+ id: data.id,
1748
+ title: data.title,
1749
+ label: data.label,
1750
+ date: data.date,
1751
+ first_year: data.firstYear,
1752
+ creators: data.creators,
1753
+ languages: data.languages,
1754
+ forms: data.forms,
1755
+ subjects: data.subjects,
1756
+ dewey_classes: data.deweyClasses,
1757
+ status: data.status,
1758
+ status_statement: data.statusStatement,
1759
+ expression_count: data.expressionCount,
1760
+ same_as: data.sameAs,
1761
+ catalogue_url: data.catalogueUrl,
1762
+ source_url: data.sourceUrl
1763
+ },
1764
+ depiction_count: data.depictions.length,
1765
+ retrieved_at: retrievedAt,
1766
+ notes
1767
+ };
1768
+ if (args.include_depictions) {
1769
+ structured.depictions = data.depictions.map((link) => ({
1770
+ ark: link.ark,
1771
+ url: link.url,
1772
+ role: link.role,
1773
+ from_id: link.fromId,
1774
+ from_title: link.fromTitle
1775
+ }));
1776
+ }
1777
+ const lines = [
1778
+ [data.title ?? data.label ?? data.id, data.date ? `(${data.date})` : ""].filter(Boolean).join(" "),
1779
+ data.creators.length > 0 ? `By: ${data.creators.map((creator) => creator.name ?? creator.id).join(", ")}` : "",
1780
+ `Record: ${data.status === "provisional" ? "provisional" : "established"}`,
1781
+ data.languages.length > 0 ? `Language: ${data.languages.join(", ")}` : "",
1782
+ data.forms.length > 0 ? `Form: ${data.forms.join(", ")}` : "",
1783
+ data.subjects.length > 0 ? `Subject: ${data.subjects.join(", ")}${data.deweyClasses.length > 0 ? ` (Dewey ${data.deweyClasses.join(", ")})` : ""}` : "",
1784
+ data.catalogueUrl ? `BnF catalogue: ${data.catalogueUrl}` : ""
1785
+ ].filter(Boolean);
1786
+ return ok(structured, lines.join("\n"), { retrievedAt, notes, sourceUrl: data.sourceUrl });
1787
+ } catch (error) {
1788
+ return toToolError(error);
1789
+ }
1790
+ }
1791
+
1792
+ // src/tools/listEditions.ts
1793
+ import { z as z6 } from "zod";
1794
+ var listEditionsDescription = [
1795
+ "List the published editions of one work in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works or get_work returns.",
1796
+ "Each row carries the publisher, the place, the year, the edition statement, the extent in the words of the record, the ISBN when there is one, and the record in the BnF general catalogue.",
1797
+ "An edition that has been digitised carries a link under 'digitised'. That link says a copy exists at that address; this server never opens it, so it cannot say whether the document is readable, complete, or free to reuse.",
1798
+ "Rows are in the order the catalogue holds them, which is neither by date nor by importance."
1799
+ ].join(" ");
1800
+ var listEditionsInput = strictInput({
1801
+ work_id: z6.string().min(1).max(200).describe("The work identifier, such as 'cb11970626n'."),
1802
+ limit: z6.number().int().min(1).max(50).default(10),
1803
+ page: z6.number().int().min(1).max(100).default(1)
1804
+ });
1805
+ var editionRowSchema = z6.object({
1806
+ id: z6.string().describe("The edition's own identifier in the BnF catalogue."),
1807
+ title: z6.string().nullable().describe("The title as this edition carries it."),
1808
+ date: z6.string().nullable().describe("The date as published, which can be a phrase such as '[s.d.]' for an undated one."),
1809
+ year: z6.number().int().nullable().describe("The year, when the record states one as a number."),
1810
+ publisher: z6.string().nullable(),
1811
+ place: z6.string().nullable(),
1812
+ edition_statement: z6.string().nullable().describe("Such as a numbered or revised edition."),
1813
+ extent: z6.string().nullable().describe("The extent in the words of the record: pages, volumes, discs."),
1814
+ isbn: z6.string().nullable(),
1815
+ note: z6.string().nullable().describe("What the cataloguer wrote, as published."),
1816
+ catalogue_url: z6.string().nullable(),
1817
+ digitised: z6.array(digitisedLinkSchema).describe(
1818
+ "Digitised copies of this edition, as links. An empty list means the catalogue records none here, not that none exists."
1819
+ ),
1820
+ source_url: z6.string()
1821
+ });
1822
+ var listEditionsOutput = z6.object({
1823
+ work_id: z6.string(),
1824
+ editions: z6.array(editionRowSchema),
1825
+ page: z6.number().int(),
1826
+ has_more: z6.boolean(),
1827
+ digitised_count: z6.number().int().describe("Editions on this page carrying at least one link."),
1828
+ retrieved_at: retrievedAtSchema,
1829
+ notes: z6.array(z6.string())
1830
+ });
1831
+ async function runListEditions(client, args) {
1832
+ try {
1833
+ const id = client.identify(args.work_id);
1834
+ const offset = (args.page - 1) * args.limit;
1835
+ const { data, cached, retrievedAt } = await client.listEditions(id, args.limit, offset);
1836
+ const notes = [];
1837
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1838
+ const editions = data.rows.map((row) => ({
1839
+ id: row.id,
1840
+ title: row.title,
1841
+ date: row.date,
1842
+ year: row.year,
1843
+ publisher: row.publisher,
1844
+ place: row.place,
1845
+ edition_statement: row.editionStatement,
1846
+ extent: row.extent,
1847
+ isbn: row.isbn,
1848
+ note: row.note,
1849
+ catalogue_url: row.catalogueUrl,
1850
+ digitised: row.digitised.map((link) => ({
1851
+ ark: link.ark,
1852
+ url: link.url,
1853
+ role: link.role,
1854
+ from_id: link.fromId,
1855
+ from_title: link.fromTitle
1856
+ })),
1857
+ source_url: row.sourceUrl
1858
+ }));
1859
+ const digitisedCount = editions.filter((edition) => edition.digitised.length > 0).length;
1860
+ if (digitisedCount > 0) notes.push(GALLICA_CAVEAT);
1861
+ if (data.hasMore) {
1862
+ notes.push(`More editions exist beyond this page. Ask for page ${args.page + 1}.`);
1863
+ }
1864
+ if (editions.length === 0 && args.page > 1) {
1865
+ notes.push(
1866
+ `Page ${args.page} holds no row, which means the list ended earlier rather than that the work has no editions. Ask for page 1 to see it from the start.`
1867
+ );
1868
+ } else if (editions.length === 0) {
1869
+ notes.push(
1870
+ `The catalogue links no published edition to "${id.id}". A work record can exist with none: it happens on provisional records, and on works the BnF describes through an anthology rather than through an edition of their own. Check get_work to see what kind of record this is.`
1871
+ );
1872
+ }
1873
+ if (editions.length > 0) {
1874
+ notes.push(
1875
+ "Rows are in the order the catalogue holds them. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list."
1876
+ );
1877
+ }
1878
+ const body = editions.length === 0 ? args.page > 1 ? `Page ${args.page} of the editions of "${id.id}" holds no row.` : `The BnF catalogue links no edition to the work "${id.id}".` : `${editions.length} edition(s) of "${id.id}":
1879
+ ${editions.map((edition, index) => {
1880
+ const imprint = [edition.place, edition.publisher, edition.date].filter(Boolean).join(" : ");
1881
+ const extra = [
1882
+ edition.edition_statement,
1883
+ edition.extent,
1884
+ edition.isbn ? `ISBN ${edition.isbn}` : ""
1885
+ ].filter(Boolean).join(" \xB7 ");
1886
+ const links = edition.digitised.length > 0 ? `
1887
+ digitised: ${edition.digitised.map((link) => `${link.role} ${link.url}`).join(" ")}` : "";
1888
+ return `${index + 1}. ${edition.title ?? edition.id}${imprint ? ` \xB7 ${imprint}` : ""}${extra ? `
1889
+ ${extra}` : ""}
1890
+ ${edition.catalogue_url ?? edition.source_url}${links}`;
1891
+ }).join("\n")}`;
1892
+ return ok(
1893
+ {
1894
+ work_id: id.id,
1895
+ editions,
1896
+ page: args.page,
1897
+ has_more: data.hasMore,
1898
+ digitised_count: digitisedCount,
1899
+ retrieved_at: retrievedAt,
1900
+ notes
1901
+ },
1902
+ body,
1903
+ { retrievedAt, notes }
1904
+ );
1905
+ } catch (error) {
1906
+ return toToolError(error);
1907
+ }
1908
+ }
1909
+
1910
+ // src/tools/searchAuthors.ts
1911
+ import { z as z7 } from "zod";
1912
+ var searchAuthorsDescription = [
1913
+ "Find a person in the Biblioth\xE8que nationale de France authority file by name, and get the identifier the other tools take.",
1914
+ "This matches the name the BnF records, so it takes a surname, a full name or both names in either order. It does not read biographies, so it cannot find a person from what they wrote or what they did.",
1915
+ "Several rows can carry one name: the BnF keeps more than one authority record for some people, and many people share a name. Read 'birth_year', 'death_year' and 'role' to tell them apart, and show the caller the choice rather than picking one.",
1916
+ "Rows come back in the order the index returned them, which is not an order of relevance."
1917
+ ].join(" ");
1918
+ var searchAuthorsInput = strictInput({
1919
+ name: z7.string().min(1).max(200).describe("The person's name, such as 'Rimbaud' or 'Arthur Rimbaud'."),
1920
+ limit: z7.number().int().min(1).max(50).default(10),
1921
+ page: z7.number().int().min(1).max(100).default(1)
1922
+ });
1923
+ var authorRowSchema = z7.object({
1924
+ id: z7.string().describe("Pass this to get_author, or to find_digitised."),
1925
+ name: z7.string().nullable().describe("The name as data.bnf.fr writes it."),
1926
+ label: z7.string().nullable().describe("The authority heading, which usually carries the dates in brackets."),
1927
+ birth_year: z7.number().int().nullable(),
1928
+ death_year: z7.number().int().nullable().describe("Null when the record states none."),
1929
+ role: z7.string().nullable().describe(
1930
+ "What the record says the person did. This field holds a job title far more often than a sentence, and is frequently a single word."
1931
+ ),
1932
+ source_url: z7.string().describe("The record's page. Show this when citing it.")
1933
+ });
1934
+ var searchAuthorsOutput = z7.object({
1935
+ name: z7.string().describe("The name asked for."),
1936
+ words_searched: z7.array(z7.string()).describe("The words the index was actually asked for, after punctuation was set aside."),
1937
+ authors: z7.array(authorRowSchema),
1938
+ page: z7.number().int(),
1939
+ has_more: z7.boolean().describe("Whether the index held at least one further match beyond this page."),
1940
+ retrieved_at: retrievedAtSchema,
1941
+ notes: z7.array(z7.string())
1942
+ });
1943
+ function lifespan(birthYear, deathYear) {
1944
+ if (birthYear !== null && deathYear !== null) return ` (${birthYear}\u2013${deathYear})`;
1945
+ if (birthYear !== null) return ` (born ${birthYear})`;
1946
+ if (deathYear !== null) return ` (died ${deathYear})`;
1947
+ return "";
1948
+ }
1949
+ async function runSearchAuthors(client, args) {
1950
+ try {
1951
+ const words = client.words(args.name);
1952
+ if (words.length === 0) {
1953
+ return toToolError(
1954
+ invalidInput(
1955
+ `"${args.name}" holds no word to search for.`,
1956
+ "Write the name in letters, such as 'Rimbaud'."
1957
+ )
1958
+ );
1959
+ }
1960
+ const offset = (args.page - 1) * args.limit;
1961
+ if (offset + args.limit > TEXT_WINDOW) {
1962
+ return toToolError(
1963
+ invalidInput(
1964
+ `Page ${args.page} at ${args.limit} rows a page reaches past row ${TEXT_WINDOW}, which is as far into the index as one search reads.`,
1965
+ `Narrow the search instead. Rows past ${TEXT_WINDOW} are not a smaller part of the answer: they were never read, so an empty page there would say nothing about the catalogue.`
1966
+ )
1967
+ );
1968
+ }
1969
+ const { data, cached, retrievedAt } = await client.searchAuthors(args.name, args.limit, offset);
1970
+ const notes = [];
1971
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1972
+ const authors = data.rows.map((row) => ({
1973
+ id: row.id,
1974
+ name: row.name,
1975
+ label: row.label,
1976
+ birth_year: row.birthYear,
1977
+ death_year: row.deathYear,
1978
+ role: row.role,
1979
+ source_url: row.sourceUrl
1980
+ }));
1981
+ if (authors.length > 0) notes.push(NO_RANKING);
1982
+ const repeated = /* @__PURE__ */ new Map();
1983
+ for (const author of authors) {
1984
+ const key = (author.name ?? "").toLocaleLowerCase("fr");
1985
+ if (key !== "") repeated.set(key, (repeated.get(key) ?? 0) + 1);
1986
+ }
1987
+ const shared = [...repeated.entries()].filter(([, count]) => count > 1);
1988
+ if (shared.length > 0) {
1989
+ notes.push(
1990
+ `${shared.map(([name, count]) => `${count} records are named "${name}"`).join("; ")}. The BnF keeps more than one authority record for some people, and different people share names. Read the dates and the role before treating any of them as the person meant.`
1991
+ );
1992
+ }
1993
+ if (data.hasMore) {
1994
+ notes.push(
1995
+ `More matches exist beyond this page. Ask for page ${args.page + 1}, or narrow the name. This server reports no total, because counting every match means asking the endpoint to walk the whole index a second time.`
1996
+ );
1997
+ }
1998
+ if (authors.length === 0) {
1999
+ notes.push(
2000
+ `No record in the BnF authority file carries every one of these words in a name: ${words.map((word) => `"${word}"`).join(", ")}. A search here reads names, so a person known by a pen name is found under that name rather than their own.`
2001
+ );
2002
+ }
2003
+ if (words.length < args.name.trim().split(/\s+/).length) {
2004
+ notes.push(
2005
+ `The index was asked for ${words.map((word) => `"${word}"`).join(", ")}; anything in the name that was not a word was set aside.`
2006
+ );
2007
+ }
2008
+ const body = authors.length === 0 ? `No person in the BnF authority file matches "${args.name}".` : `${authors.length} record(s) for "${args.name}":
2009
+ ${authors.map((author, index) => {
2010
+ const life = lifespan(author.birth_year, author.death_year);
2011
+ const role = author.role ? ` \xB7 ${author.role}` : "";
2012
+ return `${index + 1}. ${author.name ?? author.label ?? author.id}${life}${role} \xB7 id: ${author.id}
2013
+ ${author.source_url}`;
2014
+ }).join("\n")}`;
2015
+ return ok(
2016
+ {
2017
+ name: args.name,
2018
+ words_searched: words,
2019
+ authors,
2020
+ page: args.page,
2021
+ has_more: data.hasMore,
2022
+ retrieved_at: retrievedAt,
2023
+ notes
2024
+ },
2025
+ body,
2026
+ { retrievedAt, notes }
2027
+ );
2028
+ } catch (error) {
2029
+ return toToolError(error);
2030
+ }
2031
+ }
2032
+
2033
+ // src/tools/searchWorks.ts
2034
+ import { z as z8 } from "zod";
2035
+ var searchWorksDescription = [
2036
+ "Find a work in the Biblioth\xE8que nationale de France catalogue by words in its title, and get the identifier get_work and list_editions take.",
2037
+ "A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are in index order and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
2038
+ "A study of a book carries the book's title, so a search for a famous title returns the criticism alongside the work. Read 'creators' to tell them apart.",
2039
+ "This reads titles and nothing else. It cannot find the works of a named person: searching a person's name returns works written about them, whose creators are their critics.",
2040
+ "'status' says whether the BnF has established the work as a record of its own or holds it provisionally under a title it has catalogued."
2041
+ ].join(" ");
2042
+ var searchWorksInput = strictInput({
2043
+ title: z8.string().min(1).max(200).describe("Words from the title, such as 'saison enfer'. Every word given has to appear."),
2044
+ limit: z8.number().int().min(1).max(50).default(10),
2045
+ page: z8.number().int().min(1).max(100).default(1)
2046
+ });
2047
+ var workRowSchema = z8.object({
2048
+ id: z8.string().describe("Pass this to get_work, list_editions or find_digitised."),
2049
+ title: z8.string().nullable(),
2050
+ date: z8.string().nullable().describe("The year the record gives the work, as published."),
2051
+ creators: z8.array(z8.object({ id: z8.string(), name: z8.string().nullable() })).describe("Everyone the record credits. Empty when the record credits nobody."),
2052
+ status: z8.enum(["established", "provisional"]).describe(
2053
+ "'established' is a work the BnF has settled as a record of its own. 'provisional' is a title it holds while a cataloguer settles it, whose identifier can change."
2054
+ ),
2055
+ source_url: z8.string()
2056
+ });
2057
+ var searchWorksOutput = z8.object({
2058
+ title: z8.string().describe("The words asked for."),
2059
+ words_searched: z8.array(z8.string()),
2060
+ works: z8.array(workRowSchema),
2061
+ page: z8.number().int(),
2062
+ has_more: z8.boolean(),
2063
+ retrieved_at: retrievedAtSchema,
2064
+ notes: z8.array(z8.string())
2065
+ });
2066
+ async function runSearchWorks(client, args) {
2067
+ try {
2068
+ const words = client.words(args.title);
2069
+ if (words.length === 0) {
2070
+ return toToolError(
2071
+ invalidInput(
2072
+ `"${args.title}" holds no word to search for.`,
2073
+ "Write the words of the title in letters, such as 'saison enfer'."
2074
+ )
2075
+ );
2076
+ }
2077
+ const offset = (args.page - 1) * args.limit;
2078
+ if (offset + args.limit > TEXT_WINDOW) {
2079
+ return toToolError(
2080
+ invalidInput(
2081
+ `Page ${args.page} at ${args.limit} rows a page reaches past row ${TEXT_WINDOW}, which is as far into the index as one search reads.`,
2082
+ `Narrow the search instead. Rows past ${TEXT_WINDOW} are not a smaller part of the answer: they were never read, so an empty page there would say nothing about the catalogue.`
2083
+ )
2084
+ );
2085
+ }
2086
+ const { data, cached, retrievedAt } = await client.searchWorks(args.title, args.limit, offset);
2087
+ const notes = [];
2088
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2089
+ const works = data.rows.map((row) => ({
2090
+ id: row.id,
2091
+ title: row.title,
2092
+ date: row.date,
2093
+ creators: row.creators,
2094
+ status: row.status,
2095
+ source_url: row.sourceUrl
2096
+ }));
2097
+ if (works.length > 0) notes.push(NO_RANKING);
2098
+ if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
2099
+ if (words.length > 2) {
2100
+ notes.push(
2101
+ `All ${words.length} words were required: ${words.map((word) => `"${word}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
2102
+ );
2103
+ }
2104
+ if (works.length > 0 && works.every((work) => work.status === "provisional")) {
2105
+ notes.push(
2106
+ "Every row on this page is a provisional record. The BnF creates one for a title it has catalogued without settling it as a work of its own, which is what a study of a famous book usually gets. An established record for the work itself can sit further down the index: ask for the next page, or search fewer words of the title."
2107
+ );
2108
+ }
2109
+ if (data.hasMore) {
2110
+ notes.push(
2111
+ `More matches exist beyond this page. Ask for page ${args.page + 1}, or add a word to the title. This server reports no total: a count would read as a measure of the search, and this search has none.`
2112
+ );
2113
+ }
2114
+ if (works.length === 0) {
2115
+ notes.push(
2116
+ `No work in the BnF catalogue has a title carrying every one of these words: ${words.map((word) => `"${word}"`).join(", ")}. Every word given has to appear, so dropping one widens the search.`
2117
+ );
2118
+ }
2119
+ const body = works.length === 0 ? `No work in the BnF catalogue matches "${args.title}".` : `${works.length} work(s) whose title carries ${words.map((word) => `"${word}"`).join(" and ")}:
2120
+ ${works.map((work, index) => {
2121
+ const by = work.creators.length > 0 ? ` \xB7 ${work.creators.map((creator) => creator.name ?? creator.id).join(", ")}` : "";
2122
+ const when = work.date ? ` (${work.date})` : "";
2123
+ const status = work.status === "provisional" ? " \xB7 provisional record" : "";
2124
+ return `${index + 1}. ${work.title ?? work.id}${when}${by}${status} \xB7 id: ${work.id}
2125
+ ${work.source_url}`;
2126
+ }).join("\n")}`;
2127
+ return ok(
2128
+ {
2129
+ title: args.title,
2130
+ words_searched: words,
2131
+ works,
2132
+ page: args.page,
2133
+ has_more: data.hasMore,
2134
+ retrieved_at: retrievedAt,
2135
+ notes
2136
+ },
2137
+ body,
2138
+ { retrievedAt, notes }
2139
+ );
2140
+ } catch (error) {
2141
+ return toToolError(error);
2142
+ }
2143
+ }
2144
+
2145
+ // src/server.ts
2146
+ var READ_ONLY = {
2147
+ readOnlyHint: true,
2148
+ destructiveHint: false,
2149
+ idempotentHint: true,
2150
+ openWorldHint: true
2151
+ };
2152
+ var INSTRUCTIONS = [
2153
+ "Tools for data.bnf.fr, the open catalogue of the Biblioth\xE8que nationale de France. No API key and no account are needed.",
2154
+ "The usual path is search_authors or search_works to get an identifier, then get_author, get_work or list_editions to read the record it names.",
2155
+ "The BnF publishes these metadata on one condition: name the source and state the date they were retrieved. Every answer carries 'retrieved_at' for that reason, and repeating both alongside what you show is what the licence asks for.",
2156
+ "Both searches read a full-text index that answers whether a title or a name matches and never scores how well, so rows come back in index order. Never present the first row as the best answer, and never describe a count as a ranking.",
2157
+ "One person can hold several authority records, and search_authors returns all of them: show the caller the choice rather than picking one.",
2158
+ "A work record is either established, with an ARK of its own, or provisional, addressed under 'temp-work' while a cataloguer settles it. A provisional identifier can change, so prefer an established one when citing.",
2159
+ "The catalogue describes what the BnF holds; it does not hold the texts. A record can point at a digitised copy on Gallica, and those links are returned as addresses for a person to open. This server never requests gallica.bnf.fr, because the BnF places its metadata and its digitised contents under two different regimes, so it can say a document exists at a link and nothing about what is there.",
2160
+ "This server paces itself, and a rate_limited error means the endpoint was asked to slow down, never that the thing you asked for is missing.",
2161
+ "Every result carries a source_url. Credit data.bnf.fr and link what you use."
2162
+ ].join(" ");
2163
+ function createServer(options = {}) {
2164
+ const config = options.config ?? loadConfig();
2165
+ const logger = options.logger ?? createLogger(config.logLevel);
2166
+ const client = new BnfClient({
2167
+ config,
2168
+ logger,
2169
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
2170
+ });
2171
+ const server = new McpServer(
2172
+ { name: "mcp-databnf", version: PKG_VERSION },
2173
+ { instructions: INSTRUCTIONS }
2174
+ );
2175
+ server.registerTool(
2176
+ "search_authors",
2177
+ {
2178
+ title: "Find a person in the BnF authority file",
2179
+ description: searchAuthorsDescription,
2180
+ inputSchema: searchAuthorsInput,
2181
+ outputSchema: searchAuthorsOutput,
2182
+ annotations: READ_ONLY
2183
+ },
2184
+ async (args) => runSearchAuthors(client, args)
2185
+ );
2186
+ server.registerTool(
2187
+ "get_author",
2188
+ {
2189
+ title: "Read a person's record",
2190
+ description: getAuthorDescription,
2191
+ inputSchema: getAuthorInput,
2192
+ outputSchema: getAuthorOutput,
2193
+ annotations: READ_ONLY
2194
+ },
2195
+ async (args) => runGetAuthor(client, args)
2196
+ );
2197
+ server.registerTool(
2198
+ "search_works",
2199
+ {
2200
+ title: "Find a work by its title",
2201
+ description: searchWorksDescription,
2202
+ inputSchema: searchWorksInput,
2203
+ outputSchema: searchWorksOutput,
2204
+ annotations: READ_ONLY
2205
+ },
2206
+ async (args) => runSearchWorks(client, args)
2207
+ );
2208
+ server.registerTool(
2209
+ "get_work",
2210
+ {
2211
+ title: "Read a work's record",
2212
+ description: getWorkDescription,
2213
+ inputSchema: getWorkInput,
2214
+ outputSchema: getWorkOutput,
2215
+ annotations: READ_ONLY
2216
+ },
2217
+ async (args) => runGetWork(client, args)
2218
+ );
2219
+ server.registerTool(
2220
+ "list_editions",
2221
+ {
2222
+ title: "List the editions of a work",
2223
+ description: listEditionsDescription,
2224
+ inputSchema: listEditionsInput,
2225
+ outputSchema: listEditionsOutput,
2226
+ annotations: READ_ONLY
2227
+ },
2228
+ async (args) => runListEditions(client, args)
2229
+ );
2230
+ server.registerTool(
2231
+ "find_digitised",
2232
+ {
2233
+ title: "Gather the digitised documents attached to a record",
2234
+ description: findDigitisedDescription,
2235
+ inputSchema: findDigitisedInput,
2236
+ outputSchema: findDigitisedOutput,
2237
+ annotations: READ_ONLY
2238
+ },
2239
+ async (args) => runFindDigitised(client, args)
2240
+ );
2241
+ logger.info(
2242
+ `ready: user-agent="${config.userAgent}", ${config.minIntervalMs}ms between requests, cache ${config.cacheTtlMs}ms`
2243
+ );
2244
+ return server;
2245
+ }
2246
+
2247
+ // src/index.ts
2248
+ async function main() {
2249
+ const server = createServer();
2250
+ const transport = new StdioServerTransport();
2251
+ await server.connect(transport);
2252
+ const shutdown = async () => {
2253
+ await server.close().catch(() => void 0);
2254
+ process.exit(0);
2255
+ };
2256
+ process.on("SIGINT", shutdown);
2257
+ process.on("SIGTERM", shutdown);
2258
+ }
2259
+ main().catch((error) => {
2260
+ const message = error instanceof Error ? error.message : String(error);
2261
+ process.stderr.write(`[mcp-databnf] fatal: ${message}
2262
+ `);
2263
+ process.exit(1);
2264
+ });
2265
+ //# sourceMappingURL=index.js.map