bibtex-reference-manager-mcp 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,604 @@
1
+ const MONTHS = {
2
+ jan: "January", feb: "February", mar: "March", apr: "April", may: "May", jun: "June",
3
+ jul: "July", aug: "August", sep: "September", oct: "October", nov: "November", dec: "December",
4
+ };
5
+ const STOP_WORDS = new Set([
6
+ "a", "an", "the", "on", "of", "in", "and", "for", "to", "with", "at", "by", "from", "is", "are", "as", "or", "into", "towards", "toward",
7
+ ]);
8
+ const FIELD_ORDER = [
9
+ "author", "editor", "title", "journal", "booktitle", "year", "month", "volume", "number", "pages",
10
+ "publisher", "address", "school", "institution", "edition", "series", "issn", "isbn", "doi", "url", "abstract", "keywords", "note",
11
+ ];
12
+ /* ---------- small helpers ---------- */
13
+ export function squash(s) {
14
+ return s.replace(/\s+/g, " ").trim();
15
+ }
16
+ /** Remove BibTeX braces and a few common escapes so the text reads normally. */
17
+ export function plain(s) {
18
+ return squash(s
19
+ .replace(/\\([&%$#_])/g, "$1")
20
+ .replace(/---/g, "—")
21
+ .replace(/[{}]/g, ""));
22
+ }
23
+ export function cleanDoi(raw) {
24
+ return squash(raw)
25
+ .replace(/^(https?:\/\/)?(dx\.)?doi\.org\//i, "")
26
+ .replace(/^doi:\s*/i, "")
27
+ .trim();
28
+ }
29
+ export function ascii(s) {
30
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
31
+ }
32
+ /* ---------- names ---------- */
33
+ function splitTopLevel(s, sep) {
34
+ // Split on a separator but never inside braces.
35
+ const parts = [];
36
+ let depth = 0;
37
+ let cur = "";
38
+ for (let i = 0; i < s.length; i++) {
39
+ const ch = s[i];
40
+ if (ch === "{")
41
+ depth++;
42
+ else if (ch === "}")
43
+ depth = Math.max(0, depth - 1);
44
+ if (depth === 0) {
45
+ const m = s.slice(i).match(sep);
46
+ if (m && m.index === 0) {
47
+ parts.push(cur);
48
+ cur = "";
49
+ i += m[0].length - 1;
50
+ continue;
51
+ }
52
+ }
53
+ cur += ch;
54
+ }
55
+ parts.push(cur);
56
+ return parts.map(squash).filter(Boolean);
57
+ }
58
+ export function parseNames(raw) {
59
+ if (!raw)
60
+ return [];
61
+ return splitTopLevel(raw, /^\s+and\s+/i).map((n) => {
62
+ if (/^\{[^{}]*\}$/.test(n))
63
+ return { family: n.slice(1, -1), given: "", literal: true };
64
+ const comma = splitTopLevel(n, /^,/);
65
+ if (comma.length >= 2)
66
+ return { family: comma[0], given: comma.slice(1).join(" ") };
67
+ const words = n.split(" ");
68
+ if (words.length === 1)
69
+ return { family: words[0], given: "" };
70
+ // Lower case particles like "van" or "de" belong to the family name.
71
+ let i = words.length - 1;
72
+ while (i > 1 && /^[a-z]/.test(words[i - 1]))
73
+ i--;
74
+ return { family: words.slice(i).join(" "), given: words.slice(0, i).join(" ") };
75
+ });
76
+ }
77
+ export function formatNames(names) {
78
+ return names.map((n) => (n.literal ? `{${n.family}}` : n.given ? `${n.family}, ${n.given}` : n.family)).join(" and ");
79
+ }
80
+ /* ---------- format detection ---------- */
81
+ export function detectFormat(text) {
82
+ const t = text.trim();
83
+ if (/^[\[{]/.test(t))
84
+ return "csl_json";
85
+ if (/^TY {2}- /m.test(t))
86
+ return "ris";
87
+ if (/@\s*\w+\s*[{(]/.test(t))
88
+ return "bibtex";
89
+ throw new Error("Could not tell the format. Pass format as bibtex, ris or csl_json.");
90
+ }
91
+ /* ---------- entry normalizing ---------- */
92
+ const TYPE_ALIASES = {
93
+ conference: "inproceedings",
94
+ electronic: "online",
95
+ www: "online",
96
+ report: "techreport",
97
+ thesis: "phdthesis",
98
+ };
99
+ function normalizeEntry(e) {
100
+ const fields = {};
101
+ for (const [k, v] of Object.entries(e.fields)) {
102
+ const val = squash(v);
103
+ if (val)
104
+ fields[k.toLowerCase()] = val;
105
+ }
106
+ if (fields.doi)
107
+ fields.doi = cleanDoi(fields.doi);
108
+ if (!fields.year && fields.date) {
109
+ const y = fields.date.match(/\b(\d{4})\b/);
110
+ if (y)
111
+ fields.year = y[1];
112
+ }
113
+ if (!fields.journal && fields.journaltitle)
114
+ fields.journal = fields.journaltitle;
115
+ delete fields.journaltitle;
116
+ if (fields.month && MONTHS[fields.month.toLowerCase()])
117
+ fields.month = MONTHS[fields.month.toLowerCase()];
118
+ const type = e.type.toLowerCase();
119
+ return { type: TYPE_ALIASES[type] ?? type, key: e.key.trim(), fields };
120
+ }
121
+ /* ---------- BibTeX ---------- */
122
+ export function parseBibtex(text) {
123
+ const entries = [];
124
+ const macros = {};
125
+ let i = 0;
126
+ const n = text.length;
127
+ // The entry being read, so an error can name it and the line it went wrong on.
128
+ let where = { type: "", key: "", field: "" };
129
+ const lineAt = (pos) => text.slice(0, pos).split("\n").length;
130
+ const fail = (what, pos) => {
131
+ const field = where.field ? ` in field ${where.field}` : "";
132
+ const entry = where.key ? `entry ${where.key}` : `@${where.type} entry`;
133
+ throw new Error(`${what}${field} of ${entry}, line ${lineAt(pos)}.`);
134
+ };
135
+ const skipWs = () => {
136
+ while (i < n && /\s/.test(text[i]))
137
+ i++;
138
+ };
139
+ const readBraced = () => {
140
+ let depth = 0;
141
+ const start = i + 1;
142
+ for (; i < n; i++) {
143
+ // A new entry starting at the front of a line means this value was never closed.
144
+ if (text[i] === "\n" && /^\n[ \t]*@[A-Za-z]+\s*[{(]/.test(text.slice(i, i + 80)))
145
+ break;
146
+ if (text[i] === "{")
147
+ depth++;
148
+ else if (text[i] === "}" && --depth === 0) {
149
+ const out = text.slice(start, i);
150
+ i++;
151
+ return out;
152
+ }
153
+ }
154
+ return fail("Unclosed brace", start - 1);
155
+ };
156
+ const readQuoted = () => {
157
+ let depth = 0;
158
+ const start = i + 1;
159
+ for (i = i + 1; i < n; i++) {
160
+ if (text[i] === "{")
161
+ depth++;
162
+ else if (text[i] === "}")
163
+ depth--;
164
+ else if (text[i] === '"' && depth <= 0 && text[i - 1] !== "\\") {
165
+ const out = text.slice(start, i);
166
+ i++;
167
+ return out;
168
+ }
169
+ }
170
+ return fail("Unclosed quote", start - 1);
171
+ };
172
+ const readValue = () => {
173
+ let out = "";
174
+ for (;;) {
175
+ skipWs();
176
+ if (text[i] === "{")
177
+ out += readBraced();
178
+ else if (text[i] === '"')
179
+ out += readQuoted();
180
+ else {
181
+ const m = text.slice(i).match(/^[^\s,#={}()"]+/);
182
+ if (!m)
183
+ break;
184
+ i += m[0].length;
185
+ const lower = m[0].toLowerCase();
186
+ out += macros[lower] ?? (MONTHS[lower] ? lower : m[0]);
187
+ }
188
+ skipWs();
189
+ if (text[i] === "#")
190
+ i++;
191
+ else
192
+ break;
193
+ }
194
+ return out;
195
+ };
196
+ while (i < n) {
197
+ const at = text.indexOf("@", i);
198
+ if (at < 0)
199
+ break;
200
+ i = at + 1;
201
+ const head = text.slice(i).match(/^\s*([A-Za-z]+)\s*([{(])/);
202
+ if (!head)
203
+ continue;
204
+ const type = head[1].toLowerCase();
205
+ const entryLine = lineAt(at);
206
+ where = { type, key: "", field: "" };
207
+ i += head[0].length;
208
+ const close = head[2] === "{" ? "}" : ")";
209
+ if (type === "comment" || type === "preamble") {
210
+ i--;
211
+ if (head[2] === "{")
212
+ readBraced();
213
+ continue;
214
+ }
215
+ if (type === "string") {
216
+ skipWs();
217
+ const name = text.slice(i).match(/^[^\s=]+/);
218
+ if (name) {
219
+ i += name[0].length;
220
+ skipWs();
221
+ if (text[i] === "=") {
222
+ i++;
223
+ macros[name[0].toLowerCase()] = readValue();
224
+ }
225
+ }
226
+ skipWs();
227
+ if (text[i] === close)
228
+ i++;
229
+ continue;
230
+ }
231
+ skipWs();
232
+ const keyM = text.slice(i).match(/^[^,\s{}]*/);
233
+ const key = keyM ? keyM[0] : "";
234
+ i += key.length;
235
+ where.key = key;
236
+ const fields = {};
237
+ for (;;) {
238
+ skipWs();
239
+ if (i >= n) {
240
+ throw new Error(`Entry ${key || `@${type}`} starting on line ${entryLine} is never closed. Add a closing "${close}" at the end of it.`);
241
+ }
242
+ if (text[i] === ",") {
243
+ i++;
244
+ continue;
245
+ }
246
+ if (text[i] === close) {
247
+ i++;
248
+ break;
249
+ }
250
+ const nameM = text.slice(i).match(/^[A-Za-z][\w:.\-]*/);
251
+ if (!nameM) {
252
+ i++;
253
+ continue;
254
+ }
255
+ i += nameM[0].length;
256
+ skipWs();
257
+ if (text[i] !== "=")
258
+ continue;
259
+ i++;
260
+ where.field = nameM[0].toLowerCase();
261
+ fields[where.field] = readValue();
262
+ where.field = "";
263
+ }
264
+ entries.push(normalizeEntry({ type, key, fields }));
265
+ }
266
+ return entries;
267
+ }
268
+ function bibtexValue(v) {
269
+ return `{${v}}`;
270
+ }
271
+ function normalizePages(p) {
272
+ const m = p.match(/^(\w+)\s*[-–—]+\s*(\w+)$/);
273
+ return m ? `${m[1]}--${m[2]}` : p;
274
+ }
275
+ export function toBibtex(entries) {
276
+ return entries
277
+ .map((e) => {
278
+ const names = Object.keys(e.fields).sort((a, b) => {
279
+ const ia = FIELD_ORDER.indexOf(a);
280
+ const ib = FIELD_ORDER.indexOf(b);
281
+ return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
282
+ });
283
+ const lines = names.map((k) => ` ${k} = ${bibtexValue(k === "pages" ? normalizePages(e.fields[k]) : e.fields[k])},`);
284
+ return `@${e.type}{${e.key},\n${lines.join("\n")}\n}`;
285
+ })
286
+ .join("\n\n");
287
+ }
288
+ /* ---------- RIS ---------- */
289
+ const RIS_TYPES = {
290
+ JOUR: "article", BOOK: "book", CHAP: "incollection", CONF: "inproceedings", CPAPER: "inproceedings",
291
+ THES: "phdthesis", RPRT: "techreport", ELEC: "online", ICOMM: "online", UNPB: "unpublished", GEN: "misc", MGZN: "article",
292
+ };
293
+ const RIS_OUT = {
294
+ article: "JOUR", book: "BOOK", incollection: "CHAP", inbook: "CHAP", inproceedings: "CPAPER", phdthesis: "THES",
295
+ mastersthesis: "THES", techreport: "RPRT", online: "ELEC", unpublished: "UNPB", misc: "GEN",
296
+ };
297
+ export function parseRis(text) {
298
+ const entries = [];
299
+ let tags = [];
300
+ let startLine = 1;
301
+ let lineNo = 0;
302
+ const flush = () => {
303
+ if (!tags.length)
304
+ return;
305
+ const get = (...t) => tags.find(([k]) => t.includes(k))?.[1];
306
+ const all = (...t) => tags.filter(([k]) => t.includes(k)).map(([, v]) => v);
307
+ if (!get("TY")) {
308
+ throw new Error(`The RIS record starting on line ${startLine} has no TY line. Every record must begin with "TY - " and a type like JOUR.`);
309
+ }
310
+ const ty = get("TY") ?? "GEN";
311
+ const type = RIS_TYPES[ty] ?? "misc";
312
+ const fields = {};
313
+ const authors = all("AU", "A1").map((a) => formatNames(parseNames(a)));
314
+ if (authors.length)
315
+ fields.author = authors.join(" and ");
316
+ const editors = all("ED", "A2");
317
+ if (editors.length)
318
+ fields.editor = editors.join(" and ");
319
+ fields.title = get("TI", "T1") ?? "";
320
+ const container = get("T2", "JO", "JF", "JA", "BT") ?? "";
321
+ if (container)
322
+ fields[type === "article" ? "journal" : type === "book" ? "series" : "booktitle"] = container;
323
+ const py = get("PY", "Y1", "DA") ?? "";
324
+ fields.year = py.match(/\d{4}/)?.[0] ?? "";
325
+ fields.volume = get("VL") ?? "";
326
+ fields.number = get("IS") ?? "";
327
+ const sp = get("SP");
328
+ const ep = get("EP");
329
+ fields.pages = sp && ep ? `${sp}--${ep}` : sp ?? "";
330
+ fields.publisher = get("PB") ?? "";
331
+ fields.address = get("CY") ?? "";
332
+ fields.doi = get("DO") ?? "";
333
+ fields.url = get("UR", "L1") ?? "";
334
+ fields.abstract = get("AB", "N2") ?? "";
335
+ fields.keywords = all("KW").join(", ");
336
+ const sn = get("SN") ?? "";
337
+ if (sn)
338
+ fields[type === "book" ? "isbn" : "issn"] = sn;
339
+ entries.push(normalizeEntry({ type, key: get("ID") ?? "", fields }));
340
+ tags = [];
341
+ };
342
+ for (const line of text.split(/\r?\n/)) {
343
+ lineNo++;
344
+ const m = line.match(/^([A-Z][A-Z0-9])\s{2}-\s?(.*)$/);
345
+ if (m && !tags.length)
346
+ startLine = lineNo;
347
+ if (!m) {
348
+ // A line that does not start with a tag continues the last value.
349
+ if (tags.length && line.trim())
350
+ tags[tags.length - 1][1] += " " + line.trim();
351
+ continue;
352
+ }
353
+ if (m[1] === "ER")
354
+ flush();
355
+ else if (m[1] === "TY" && tags.length) {
356
+ flush();
357
+ startLine = lineNo;
358
+ tags.push([m[1], m[2].trim()]);
359
+ }
360
+ else
361
+ tags.push([m[1], m[2].trim()]);
362
+ }
363
+ flush();
364
+ return entries;
365
+ }
366
+ export function toRis(entries) {
367
+ return entries
368
+ .map((e) => {
369
+ const f = e.fields;
370
+ const out = [`TY - ${RIS_OUT[e.type] ?? "GEN"}`];
371
+ if (e.key)
372
+ out.push(`ID - ${e.key}`);
373
+ for (const n of parseNames(f.author))
374
+ out.push(`AU - ${plain(n.literal ? n.family : n.given ? `${n.family}, ${n.given}` : n.family)}`);
375
+ for (const n of parseNames(f.editor))
376
+ out.push(`ED - ${plain(n.given ? `${n.family}, ${n.given}` : n.family)}`);
377
+ if (f.title)
378
+ out.push(`TI - ${plain(f.title)}`);
379
+ const container = f.journal ?? f.booktitle;
380
+ if (container)
381
+ out.push(`T2 - ${plain(container)}`);
382
+ if (f.year)
383
+ out.push(`PY - ${f.year}`);
384
+ if (f.volume)
385
+ out.push(`VL - ${f.volume}`);
386
+ if (f.number)
387
+ out.push(`IS - ${f.number}`);
388
+ if (f.pages) {
389
+ const m = normalizePages(f.pages).match(/^(\w+)--(\w+)$/);
390
+ if (m)
391
+ out.push(`SP - ${m[1]}`, `EP - ${m[2]}`);
392
+ else
393
+ out.push(`SP - ${f.pages}`);
394
+ }
395
+ if (f.publisher)
396
+ out.push(`PB - ${plain(f.publisher)}`);
397
+ if (f.address)
398
+ out.push(`CY - ${plain(f.address)}`);
399
+ if (f.doi)
400
+ out.push(`DO - ${f.doi}`);
401
+ if (f.url)
402
+ out.push(`UR - ${f.url}`);
403
+ if (f.abstract)
404
+ out.push(`AB - ${plain(f.abstract)}`);
405
+ if (f.keywords)
406
+ for (const k of f.keywords.split(/[,;]/).map(squash).filter(Boolean))
407
+ out.push(`KW - ${k}`);
408
+ const sn = f.issn ?? f.isbn;
409
+ if (sn)
410
+ out.push(`SN - ${sn}`);
411
+ out.push("ER - ");
412
+ return out.join("\n");
413
+ })
414
+ .join("\n\n");
415
+ }
416
+ /* ---------- CSL JSON ---------- */
417
+ const CSL_TYPES = {
418
+ "article-journal": "article", "article-magazine": "article", "article-newspaper": "article", article: "article",
419
+ book: "book", chapter: "incollection", "paper-conference": "inproceedings", thesis: "phdthesis",
420
+ report: "techreport", webpage: "online", "post-weblog": "online", manuscript: "unpublished", dataset: "misc",
421
+ };
422
+ const CSL_OUT = {
423
+ article: "article-journal", book: "book", incollection: "chapter", inbook: "chapter", inproceedings: "paper-conference",
424
+ phdthesis: "thesis", mastersthesis: "thesis", techreport: "report", online: "webpage", unpublished: "manuscript", misc: "document",
425
+ };
426
+ export function parseCslJson(text) {
427
+ let data;
428
+ try {
429
+ data = JSON.parse(text);
430
+ }
431
+ catch (e) {
432
+ throw new Error(`The CSL JSON could not be read: ${e.message}`);
433
+ }
434
+ const items = Array.isArray(data) ? data : data && typeof data === "object" && "items" in data && Array.isArray(data.items) ? data.items : [data];
435
+ return items
436
+ .filter((it) => it && typeof it === "object")
437
+ .map((it) => {
438
+ const type = CSL_TYPES[String(it.type ?? "")] ?? "misc";
439
+ const names = (list) => Array.isArray(list)
440
+ ? formatNames(list.map((a) => a.literal ? { family: String(a.literal), given: "", literal: true } : { family: String(a.family ?? ""), given: String(a.given ?? "") }))
441
+ : "";
442
+ const dp = it.issued?.["date-parts"]?.[0] ?? [];
443
+ const container = it["container-title"] ? String(it["container-title"]) : "";
444
+ const fields = {
445
+ author: names(it.author),
446
+ editor: names(it.editor),
447
+ title: it.title ? String(it.title) : "",
448
+ year: dp[0] ? String(dp[0]) : it.issued?.literal?.match?.(/\d{4}/)?.[0] ?? "",
449
+ volume: it.volume != null ? String(it.volume) : "",
450
+ number: it.issue != null ? String(it.issue) : "",
451
+ pages: it.page ? String(it.page) : "",
452
+ publisher: it.publisher ? String(it.publisher) : "",
453
+ address: it["publisher-place"] ? String(it["publisher-place"]) : "",
454
+ doi: it.DOI ? String(it.DOI) : "",
455
+ url: it.URL ? String(it.URL) : "",
456
+ abstract: it.abstract ? String(it.abstract) : "",
457
+ issn: it.ISSN ? String(it.ISSN) : "",
458
+ isbn: it.ISBN ? String(it.ISBN) : "",
459
+ };
460
+ if (container)
461
+ fields[type === "article" ? "journal" : "booktitle"] = container;
462
+ return normalizeEntry({ type, key: it.id != null ? String(it.id) : "", fields });
463
+ });
464
+ }
465
+ export function toCslJson(entries) {
466
+ const items = entries.map((e) => {
467
+ const f = e.fields;
468
+ const names = (raw) => parseNames(raw).map((n) => (n.literal ? { literal: plain(n.family) } : { family: plain(n.family), given: plain(n.given) }));
469
+ const item = { id: e.key, type: CSL_OUT[e.type] ?? "document" };
470
+ if (f.author)
471
+ item.author = names(f.author);
472
+ if (f.editor)
473
+ item.editor = names(f.editor);
474
+ if (f.title)
475
+ item.title = plain(f.title);
476
+ const container = f.journal ?? f.booktitle;
477
+ if (container)
478
+ item["container-title"] = plain(container);
479
+ if (f.year && /^\d{4}$/.test(f.year))
480
+ item.issued = { "date-parts": [[Number(f.year)]] };
481
+ if (f.volume)
482
+ item.volume = f.volume;
483
+ if (f.number)
484
+ item.issue = f.number;
485
+ if (f.pages)
486
+ item.page = normalizePages(f.pages).replace("--", "-");
487
+ if (f.publisher)
488
+ item.publisher = plain(f.publisher);
489
+ if (f.address)
490
+ item["publisher-place"] = plain(f.address);
491
+ if (f.doi)
492
+ item.DOI = f.doi;
493
+ if (f.url)
494
+ item.URL = f.url;
495
+ if (f.abstract)
496
+ item.abstract = plain(f.abstract);
497
+ if (f.issn)
498
+ item.ISSN = f.issn;
499
+ if (f.isbn)
500
+ item.ISBN = f.isbn;
501
+ return item;
502
+ });
503
+ return JSON.stringify(items, null, 2);
504
+ }
505
+ /* ---------- plain text ---------- */
506
+ function initials(given) {
507
+ return given
508
+ .split(/[\s]+/)
509
+ .filter(Boolean)
510
+ .map((w) => (w.includes("-") ? w.split("-").map((p) => `${p[0].toUpperCase()}.`).join("-") : `${w[0].toUpperCase()}.`))
511
+ .join(" ");
512
+ }
513
+ export function toText(entries) {
514
+ return entries
515
+ .map((e) => {
516
+ const f = e.fields;
517
+ const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${initials(plain(n.given))}`));
518
+ let who = "";
519
+ if (names.length === 1)
520
+ who = names[0];
521
+ else if (names.length > 1)
522
+ who = `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`;
523
+ const parts = [];
524
+ if (who)
525
+ parts.push(who.endsWith(".") ? who : `${who}.`);
526
+ parts.push(`(${f.year ?? "n.d."}).`);
527
+ if (f.title)
528
+ parts.push(/[.?!]$/.test(plain(f.title)) ? plain(f.title) : `${plain(f.title)}.`);
529
+ const container = f.journal ?? f.booktitle;
530
+ if (container) {
531
+ let c = plain(container);
532
+ if (f.volume)
533
+ c += `, ${f.volume}`;
534
+ if (f.number)
535
+ c += `(${f.number})`;
536
+ if (f.pages)
537
+ c += `, ${normalizePages(f.pages).replace("--", "-")}`;
538
+ parts.push(`${c}.`);
539
+ }
540
+ else if (f.publisher)
541
+ parts.push(`${plain(f.publisher)}.`);
542
+ if (f.doi)
543
+ parts.push(`https://doi.org/${f.doi}`);
544
+ else if (f.url)
545
+ parts.push(f.url);
546
+ return parts.join(" ");
547
+ })
548
+ .join("\n\n");
549
+ }
550
+ /* ---------- parse and write by name ---------- */
551
+ export function parseAny(text, format = "auto") {
552
+ if (!text.trim())
553
+ throw new Error("The text is empty. Paste a BibTeX, RIS or CSL JSON reference list.");
554
+ const fmt = format === "auto" ? detectFormat(text) : format;
555
+ const entries = fmt === "bibtex" ? parseBibtex(text) : fmt === "ris" ? parseRis(text) : parseCslJson(text);
556
+ // Give entries without a key one, so every entry can be named later.
557
+ const used = new Set(entries.map((e) => e.key).filter(Boolean));
558
+ for (const e of entries) {
559
+ if (!e.key) {
560
+ e.key = uniqueKey(makeKey(e, { titleWord: true }), used);
561
+ used.add(e.key);
562
+ }
563
+ }
564
+ return { format: fmt, entries };
565
+ }
566
+ export function serialize(entries, format) {
567
+ switch (format) {
568
+ case "bibtex":
569
+ return toBibtex(entries);
570
+ case "ris":
571
+ return toRis(entries);
572
+ case "csl_json":
573
+ return toCslJson(entries);
574
+ default:
575
+ return toText(entries);
576
+ }
577
+ }
578
+ /* ---------- cite keys ---------- */
579
+ export function makeKey(e, opts) {
580
+ const first = parseNames(e.fields.author)[0] ?? parseNames(e.fields.editor)[0];
581
+ let family = first ? plain(first.family).split(" ").pop() ?? "" : "";
582
+ family = ascii(family).toLowerCase().replace(/[^a-z0-9]/g, "");
583
+ if (!family)
584
+ family = "anon";
585
+ const year = /^\d{4}$/.test(e.fields.year ?? "") ? e.fields.year : "nd";
586
+ let word = "";
587
+ if (opts.titleWord && e.fields.title) {
588
+ const words = ascii(plain(e.fields.title))
589
+ .toLowerCase()
590
+ .split(/[^a-z0-9]+/)
591
+ .filter(Boolean);
592
+ word = words.find((w) => !STOP_WORDS.has(w)) ?? words[0] ?? "";
593
+ }
594
+ return `${family}${year}${word}`;
595
+ }
596
+ export function uniqueKey(base, used) {
597
+ if (!used.has(base))
598
+ return base;
599
+ for (let n = 0;; n++) {
600
+ const suffix = n < 26 ? String.fromCharCode(97 + n) : `${n}`;
601
+ if (!used.has(base + suffix))
602
+ return base + suffix;
603
+ }
604
+ }
@@ -0,0 +1,25 @@
1
+ export async function runTool(tool, args, api) {
2
+ try {
3
+ const out = await tool.run(args, api);
4
+ return { content: [{ type: "text", text: out.text }], structuredContent: out.structured };
5
+ }
6
+ catch (err) {
7
+ // Pass the message through as it was written, so the caller can act on it.
8
+ const msg = err instanceof Error ? err.message : String(err);
9
+ return { content: [{ type: "text", text: msg }], isError: true };
10
+ }
11
+ }
12
+ export function registerTools(server, tools, api) {
13
+ for (const tool of tools) {
14
+ server.registerTool(tool.name, {
15
+ title: tool.title,
16
+ description: tool.description,
17
+ inputSchema: tool.inputSchema,
18
+ outputSchema: tool.outputSchema,
19
+ annotations: { readOnlyHint: true, openWorldHint: false },
20
+ }, async (args) => runTool(tool, args, api));
21
+ }
22
+ }
23
+ export function bullets(lines, empty = "Nothing to show.") {
24
+ return lines.length ? lines.map((l) => `- ${l}`).join("\n") : empty;
25
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer, VERSION } from "./server.js";
4
+ function usage() {
5
+ console.log(`bibtex-reference-manager-mcp ${VERSION}
6
+
7
+ Clean, convert, dedupe and format BibTeX, RIS and CSL JSON reference lists for AI apps (MCP). Works offline.
8
+
9
+ Add it to your AI app's MCP config with command "npx" and args ["-y", "bibtex-reference-manager-mcp"].
10
+
11
+ --help, --version`);
12
+ }
13
+ async function main() {
14
+ const cmd = process.argv[2];
15
+ if (cmd === "--help" || cmd === "-h")
16
+ return usage();
17
+ if (cmd === "--version" || cmd === "-v")
18
+ return console.log(VERSION);
19
+ await createServer().connect(new StdioServerTransport());
20
+ console.error(`bibtex-reference-manager-mcp ${VERSION} running`);
21
+ }
22
+ main().catch((e) => {
23
+ console.error(e);
24
+ process.exit(1);
25
+ });
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerTools } from "./core/tools.js";
3
+ import { tools } from "./tools.js";
4
+ export const VERSION = "0.1.1";
5
+ export function createServer() {
6
+ const server = new McpServer({ name: "bibtex-reference-manager-mcp", version: VERSION });
7
+ registerTools(server, tools, null);
8
+ return server;
9
+ }