bibtex-reference-manager-mcp 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +6 -5
- package/build/src/analyze.js +82 -0
- package/build/src/bib.js +198 -5
- package/build/src/server.js +1 -1
- package/build/src/tools.js +75 -8
- package/llms.txt +19 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- `lint_entries` now flags a raw `&`, `%`, `_` or `#` in titles, journal names and similar text fields, with the new rule `unescaped-character`, since these often stop a .bib file from compiling. Existing rules are unchanged.
|
|
6
|
+
|
|
7
|
+
- `generate_cite_keys` takes an optional `show_bibtex`. When true, the text reply also lists the full entries with the new keys, so you can paste them into your .bib file. The existing output is unchanged.
|
|
8
|
+
|
|
9
|
+
- `convert_bibliography` now accepts `chicago` and `harvard` for `style`, next to `apa`, `mla` and `ieee`. Chicago is the bibliography form with the author first. The existing styles are unchanged.
|
|
10
|
+
|
|
11
|
+
- `fix_entries` now wraps acronyms like DNA, NASA and iOS in braces, as in `{DNA}`, when it fixes the capitalization of a title, so BibTeX styles keep their case. The new `protected_titles` list shows which words were wrapped. The existing output is unchanged.
|
|
12
|
+
|
|
13
|
+
- `dedupe_entries` takes an optional `merge_duplicates`. When true, the result has a `merged` list that shows the fields only the removed copies had (like doi or pages) next to the kept entry. The existing output is unchanged.
|
|
14
|
+
|
|
15
|
+
- When the format cannot be detected, the error now lists the accepted formats with a small BibTeX, RIS and CSL JSON example, and says so when the text is only a list of DOIs or URLs.
|
|
16
|
+
|
|
17
|
+
## 0.1.2
|
|
18
|
+
|
|
19
|
+
- Added `fix_entries`, which applies the safe fixes from `lint_entries` (double hyphen page ranges, capitalized titles, bare DOIs) and returns the cleaned BibTeX with a list of each change.
|
|
20
|
+
|
|
21
|
+
- `convert_bibliography` takes an optional `style` (`apa`, `mla` or `ieee`) for the plain text output, so a formatted reference list takes one call. Without it the output is the same as before.
|
|
22
|
+
|
|
3
23
|
## 0.1.1
|
|
4
24
|
|
|
5
25
|
- The README now has a Common workflows section showing how the five tools work together in a typical thesis or cleaning workflow.
|
package/README.md
CHANGED
|
@@ -139,9 +139,10 @@ The format is detected for you. If a file is not recognized, say which format it
|
|
|
139
139
|
| Tool | What it does |
|
|
140
140
|
| --- | --- |
|
|
141
141
|
| `parse_bibliography` | Reads BibTeX, RIS or CSL JSON text and returns normalized entries: lower case field names, tidy spacing, bare DOIs, the year filled in from a date, and authors split into "Family, Given". |
|
|
142
|
-
| `convert_bibliography` | Converts between BibTeX, RIS, CSL JSON and plain text. The input format is detected unless you pass `from`. Page ranges become `12--15` in BibTeX. |
|
|
142
|
+
| `convert_bibliography` | Converts between BibTeX, RIS, CSL JSON and plain text. The input format is detected unless you pass `from`. Page ranges become `12--15` in BibTeX. For plain text, pass `style` as `apa` (the default), `mla`, `ieee`, `chicago` or `harvard` to get a formatted reference list. |
|
|
143
143
|
| `dedupe_entries` | Finds duplicates by DOI, by the same cite key and by similar titles (the years must not clash). Says which entry to keep and returns a BibTeX list without the extra copies. |
|
|
144
|
-
| `lint_entries` | Flags missing required fields, bad page ranges, uncapitalized titles, invalid DOIs, odd years and
|
|
144
|
+
| `lint_entries` | Flags missing required fields, bad page ranges, uncapitalized titles, invalid DOIs, odd years, repeated keys and unescaped `&`, `%`, `_` or `#` in titles and journal names. |
|
|
145
|
+
| `fix_entries` | Applies the safe fixes from `lint_entries` and returns the cleaned BibTeX: double hyphen page ranges, capitalized titles and bare DOIs. It lists each change and does not guess missing fields. |
|
|
145
146
|
| `generate_cite_keys` | Makes keys from the first author, the year and the first useful title word, like `smith2024deep`. Repeats get a letter, like `smith2024deepa`. |
|
|
146
147
|
|
|
147
148
|
Every tool takes the reference list as `text`, and every tool returns both a readable answer and structured fields. All of them are read only. They never change a file, because you paste the text in and get the result back.
|
|
@@ -154,7 +155,7 @@ Every tool takes the reference list as `text`, and every tool returns both a rea
|
|
|
154
155
|
|
|
155
156
|
## Common workflows
|
|
156
157
|
|
|
157
|
-
These show how the
|
|
158
|
+
These show how the six tools fit together. All of them take the reference list as `text` and are read only.
|
|
158
159
|
|
|
159
160
|
### Clean a Zotero or Mendeley export
|
|
160
161
|
|
|
@@ -173,7 +174,7 @@ These show how the five tools fit together. All of them take the reference list
|
|
|
173
174
|
### Check a finished .bib before you hand it in
|
|
174
175
|
|
|
175
176
|
1. Run `parse_bibliography` to normalize.
|
|
176
|
-
2. Run `lint_entries`
|
|
177
|
+
2. Run `lint_entries`, then `fix_entries` to apply the safe fixes it can make.
|
|
177
178
|
3. Run `dedupe_entries` to make sure nothing is listed twice.
|
|
178
179
|
|
|
179
180
|
## Privacy
|
|
@@ -200,7 +201,7 @@ npm run build
|
|
|
200
201
|
npm test
|
|
201
202
|
```
|
|
202
203
|
|
|
203
|
-
The code is in `src`. `src/bib.ts` reads and writes the formats, `src/analyze.ts` finds duplicates and lint problems, and `src/tools.ts` defines the
|
|
204
|
+
The code is in `src`. `src/bib.ts` reads and writes the formats, `src/analyze.ts` finds duplicates and lint problems, and `src/tools.ts` defines the six tools.
|
|
204
205
|
|
|
205
206
|
## More free student MCP servers
|
|
206
207
|
|
package/build/src/analyze.js
CHANGED
|
@@ -124,6 +124,17 @@ export function pageProblem(pages) {
|
|
|
124
124
|
return "the range has more than two page numbers";
|
|
125
125
|
return `"${p}" is not a page number or a range like 12--15`;
|
|
126
126
|
}
|
|
127
|
+
const TEXT_FIELDS = ["title", "booktitle", "journal", "publisher", "series", "school", "institution", "address"];
|
|
128
|
+
// Characters LaTeX stops on when they are not written as \&, \%, \_ or \#. Math between $ signs is skipped.
|
|
129
|
+
function unescapedChars(value) {
|
|
130
|
+
const text = value.replace(/\\\\/g, "").replace(/\$[^$]*\$/g, "");
|
|
131
|
+
const found = [];
|
|
132
|
+
for (const c of ["&", "%", "_", "#"]) {
|
|
133
|
+
if (new RegExp(`(?<!\\\\)\\${c}`).test(text))
|
|
134
|
+
found.push(c);
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
127
138
|
export function lintEntries(entries) {
|
|
128
139
|
const issues = [];
|
|
129
140
|
const seen = new Map();
|
|
@@ -159,6 +170,15 @@ export function lintEntries(entries) {
|
|
|
159
170
|
add("uncapitalized-title", "warning", `Every word of the title is lower case: "${plain(f.title)}".`, "title");
|
|
160
171
|
}
|
|
161
172
|
}
|
|
173
|
+
for (const name of TEXT_FIELDS) {
|
|
174
|
+
const value = f[name];
|
|
175
|
+
if (!value)
|
|
176
|
+
continue;
|
|
177
|
+
const found = unescapedChars(value);
|
|
178
|
+
if (found.length) {
|
|
179
|
+
add("unescaped-character", "error", `The ${name} has ${found.map((c) => `an unescaped ${c}`).join(" and ")}. Write \\${found.join(" and \\")} so LaTeX can compile it.`, name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
162
182
|
if (f.doi && !/^10\.\d{4,9}\/\S+$/.test(cleanDoi(f.doi))) {
|
|
163
183
|
add("invalid-doi", "error", `"${f.doi}" is not a valid DOI. A DOI looks like 10.1000/xyz123.`, "doi");
|
|
164
184
|
}
|
|
@@ -167,3 +187,65 @@ export function lintEntries(entries) {
|
|
|
167
187
|
}
|
|
168
188
|
return issues;
|
|
169
189
|
}
|
|
190
|
+
// Words like DNA, NASA, iOS or mRNA: BibTeX styles would lower case them without braces.
|
|
191
|
+
const ACRONYM = /^([("'[]*)([A-Z]{2,}s?|[a-z]+[A-Z][A-Za-z]*)([)"'\].,;:!?]*)$/;
|
|
192
|
+
function capitalizeTitle(title) {
|
|
193
|
+
const raw = title.trim();
|
|
194
|
+
const protectedWords = [];
|
|
195
|
+
const firstOnly = /^[a-z]/.test(raw);
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let first = true;
|
|
198
|
+
const out = raw
|
|
199
|
+
.split(/(\s+)/)
|
|
200
|
+
.map((word) => {
|
|
201
|
+
const inBraces = depth > 0 || word.startsWith("{");
|
|
202
|
+
for (const c of word)
|
|
203
|
+
depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
|
204
|
+
const isWord = word.trim() !== "";
|
|
205
|
+
const wasFirst = isWord && first;
|
|
206
|
+
if (isWord)
|
|
207
|
+
first = false;
|
|
208
|
+
if (inBraces)
|
|
209
|
+
return word;
|
|
210
|
+
const m = ACRONYM.exec(word);
|
|
211
|
+
if (m) {
|
|
212
|
+
protectedWords.push(m[2]);
|
|
213
|
+
return `${m[1]}{${m[2]}}${m[3]}`;
|
|
214
|
+
}
|
|
215
|
+
if (firstOnly)
|
|
216
|
+
return wasFirst ? word.replace(/^[a-z]/, (c) => c.toUpperCase()) : word;
|
|
217
|
+
if (SMALL_WORDS.has(word.toLowerCase()))
|
|
218
|
+
return word;
|
|
219
|
+
return word.replace(/^[a-z]/, (c) => c.toUpperCase());
|
|
220
|
+
})
|
|
221
|
+
.join("");
|
|
222
|
+
return { title: out, protectedWords };
|
|
223
|
+
}
|
|
224
|
+
export function fixEntries(entries) {
|
|
225
|
+
const fixes = [];
|
|
226
|
+
const protectedTitles = [];
|
|
227
|
+
const fixed = entries.map((e) => {
|
|
228
|
+
const f = { ...e.fields };
|
|
229
|
+
const set = (field, to) => {
|
|
230
|
+
if (to !== f[field])
|
|
231
|
+
fixes.push({ key: e.key, field, from: f[field], to });
|
|
232
|
+
f[field] = to;
|
|
233
|
+
};
|
|
234
|
+
if (f.pages && pageProblem(f.pages)?.startsWith("use a double hyphen")) {
|
|
235
|
+
set("pages", f.pages.trim().replace(/\s*[-–—]\s*/, "--"));
|
|
236
|
+
}
|
|
237
|
+
if (f.title && lintEntries([{ ...e, fields: f }]).some((i) => i.rule === "uncapitalized-title")) {
|
|
238
|
+
const cap = capitalizeTitle(f.title);
|
|
239
|
+
set("title", cap.title);
|
|
240
|
+
if (cap.protectedWords.length)
|
|
241
|
+
protectedTitles.push({ key: e.key, words: cap.protectedWords });
|
|
242
|
+
}
|
|
243
|
+
if (f.doi) {
|
|
244
|
+
const doi = cleanDoi(f.doi);
|
|
245
|
+
if (/^10\.\d{4,9}\/\S+$/.test(doi))
|
|
246
|
+
set("doi", doi);
|
|
247
|
+
}
|
|
248
|
+
return { ...e, fields: f };
|
|
249
|
+
});
|
|
250
|
+
return { entries: fixed, fixes, protectedTitles };
|
|
251
|
+
}
|
package/build/src/bib.js
CHANGED
|
@@ -86,7 +86,16 @@ export function detectFormat(text) {
|
|
|
86
86
|
return "ris";
|
|
87
87
|
if (/@\s*\w+\s*[{(]/.test(t))
|
|
88
88
|
return "bibtex";
|
|
89
|
-
|
|
89
|
+
const looksLikeIds = t.split(/\s+/).every((w) => /^(https?:\/\/\S+|(doi:)?10\.\d{4,9}\/\S+)$/i.test(w));
|
|
90
|
+
const hint = looksLikeIds
|
|
91
|
+
? "This looks like a list of DOIs or URLs. This server does not look them up online, so paste the reference entries instead.\n"
|
|
92
|
+
: "This looks like plain text. Paste the reference entries in one of the formats below, or pass format.\n";
|
|
93
|
+
throw new Error("Could not tell the format. " +
|
|
94
|
+
hint +
|
|
95
|
+
"Accepted formats are bibtex, ris and csl_json. Examples:\n" +
|
|
96
|
+
"bibtex:\n@article{smith2020, author = {Smith, Jane}, title = {A title}, journal = {A Journal}, year = {2020}}\n" +
|
|
97
|
+
"ris:\nTY - JOUR\nAU - Smith, Jane\nTI - A title\nJO - A Journal\nPY - 2020\nER - \n" +
|
|
98
|
+
'csl_json:\n[{"id":"smith2020","type":"article-journal","title":"A title","author":[{"family":"Smith","given":"Jane"}],"container-title":"A Journal","issued":{"date-parts":[[2020]]}}]');
|
|
90
99
|
}
|
|
91
100
|
/* ---------- entry normalizing ---------- */
|
|
92
101
|
const TYPE_ALIASES = {
|
|
@@ -510,9 +519,171 @@ function initials(given) {
|
|
|
510
519
|
.map((w) => (w.includes("-") ? w.split("-").map((p) => `${p[0].toUpperCase()}.`).join("-") : `${w[0].toUpperCase()}.`))
|
|
511
520
|
.join(" ");
|
|
512
521
|
}
|
|
513
|
-
|
|
522
|
+
function endWith(s) {
|
|
523
|
+
return /[.?!]$/.test(s) ? s : `${s}.`;
|
|
524
|
+
}
|
|
525
|
+
function mlaText(e) {
|
|
526
|
+
const f = e.fields;
|
|
527
|
+
const people = parseNames(f.author).map((n) => ({
|
|
528
|
+
first: n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${plain(n.given)}`,
|
|
529
|
+
last: n.literal || !n.given ? plain(n.family) : `${plain(n.given)} ${plain(n.family)}`,
|
|
530
|
+
}));
|
|
531
|
+
let who = "";
|
|
532
|
+
if (people.length === 1)
|
|
533
|
+
who = people[0].first;
|
|
534
|
+
else if (people.length === 2)
|
|
535
|
+
who = `${people[0].first}, and ${people[1].last}`;
|
|
536
|
+
else if (people.length > 2)
|
|
537
|
+
who = `${people[0].first}, et al`;
|
|
538
|
+
const parts = [];
|
|
539
|
+
if (who)
|
|
540
|
+
parts.push(endWith(who));
|
|
541
|
+
const container = f.journal ?? f.booktitle;
|
|
542
|
+
if (f.title)
|
|
543
|
+
parts.push(container ? `"${endWith(plain(f.title))}"` : endWith(plain(f.title)));
|
|
544
|
+
const bits = [];
|
|
545
|
+
if (container)
|
|
546
|
+
bits.push(plain(container));
|
|
547
|
+
else if (f.publisher)
|
|
548
|
+
bits.push(plain(f.publisher));
|
|
549
|
+
if (container && f.volume)
|
|
550
|
+
bits.push(`vol. ${f.volume}`);
|
|
551
|
+
if (container && f.number)
|
|
552
|
+
bits.push(`no. ${f.number}`);
|
|
553
|
+
if (f.year)
|
|
554
|
+
bits.push(f.year);
|
|
555
|
+
if (container && f.pages)
|
|
556
|
+
bits.push(`pp. ${normalizePages(f.pages).replace("--", "-")}`);
|
|
557
|
+
if (bits.length)
|
|
558
|
+
parts.push(`${bits.join(", ")}.`);
|
|
559
|
+
if (f.doi)
|
|
560
|
+
parts.push(`https://doi.org/${f.doi}.`);
|
|
561
|
+
else if (f.url)
|
|
562
|
+
parts.push(`${f.url}.`);
|
|
563
|
+
return parts.join(" ");
|
|
564
|
+
}
|
|
565
|
+
function ieeeText(e, index) {
|
|
566
|
+
const f = e.fields;
|
|
567
|
+
const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${initials(plain(n.given))} ${plain(n.family)}`));
|
|
568
|
+
let who = "";
|
|
569
|
+
if (names.length === 1)
|
|
570
|
+
who = names[0];
|
|
571
|
+
else if (names.length === 2)
|
|
572
|
+
who = `${names[0]} and ${names[1]}`;
|
|
573
|
+
else if (names.length > 2)
|
|
574
|
+
who = `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
|
|
575
|
+
const container = f.journal ?? f.booktitle;
|
|
576
|
+
const title = f.title ? plain(f.title).replace(/[.,]$/, "") : "";
|
|
577
|
+
const bits = [];
|
|
578
|
+
if (container)
|
|
579
|
+
bits.push(plain(container));
|
|
580
|
+
else if (f.publisher)
|
|
581
|
+
bits.push(plain(f.publisher));
|
|
582
|
+
if (container && f.volume)
|
|
583
|
+
bits.push(`vol. ${f.volume}`);
|
|
584
|
+
if (container && f.number)
|
|
585
|
+
bits.push(`no. ${f.number}`);
|
|
586
|
+
if (container && f.pages)
|
|
587
|
+
bits.push(`pp. ${normalizePages(f.pages).replace("--", "-")}`);
|
|
588
|
+
if (f.year)
|
|
589
|
+
bits.push(f.year);
|
|
590
|
+
const head = [who, container && title ? `"${title},"` : title].filter(Boolean).join(", ");
|
|
591
|
+
let body = container && title ? `${head} ${bits.join(", ")}` : [head, bits.join(", ")].filter(Boolean).join(", ");
|
|
592
|
+
body = endWith(body.trim());
|
|
593
|
+
if (f.doi)
|
|
594
|
+
body += ` doi: ${f.doi}.`;
|
|
595
|
+
else if (f.url)
|
|
596
|
+
body += ` ${f.url}`;
|
|
597
|
+
return `[${index + 1}] ${body}`;
|
|
598
|
+
}
|
|
599
|
+
function chicagoText(e) {
|
|
600
|
+
const f = e.fields;
|
|
601
|
+
const people = parseNames(f.author).map((n) => ({
|
|
602
|
+
first: n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${plain(n.given)}`,
|
|
603
|
+
last: n.literal || !n.given ? plain(n.family) : `${plain(n.given)} ${plain(n.family)}`,
|
|
604
|
+
}));
|
|
605
|
+
let who = "";
|
|
606
|
+
if (people.length === 1)
|
|
607
|
+
who = people[0].first;
|
|
608
|
+
else if (people.length > 1) {
|
|
609
|
+
const rest = people.slice(1).map((p) => p.last);
|
|
610
|
+
who = `${people[0].first}, ${rest.length > 1 ? `${rest.slice(0, -1).join(", ")}, and ${rest[rest.length - 1]}` : `and ${rest[0]}`}`;
|
|
611
|
+
}
|
|
612
|
+
const parts = [];
|
|
613
|
+
if (who)
|
|
614
|
+
parts.push(endWith(who));
|
|
615
|
+
const container = f.journal ?? f.booktitle;
|
|
616
|
+
if (f.title)
|
|
617
|
+
parts.push(container ? `"${endWith(plain(f.title))}"` : endWith(plain(f.title)));
|
|
618
|
+
if (container) {
|
|
619
|
+
let c = plain(container);
|
|
620
|
+
if (f.volume)
|
|
621
|
+
c += ` ${f.volume}`;
|
|
622
|
+
if (f.number)
|
|
623
|
+
c += `, no. ${f.number}`;
|
|
624
|
+
if (f.year)
|
|
625
|
+
c += ` (${f.year})`;
|
|
626
|
+
if (f.pages)
|
|
627
|
+
c += `: ${normalizePages(f.pages).replace("--", "-")}`;
|
|
628
|
+
parts.push(`${c}.`);
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
const pub = [f.address ? plain(f.address) : "", f.publisher ? plain(f.publisher) : ""].filter(Boolean).join(": ");
|
|
632
|
+
const tail = [pub, f.year].filter(Boolean).join(", ");
|
|
633
|
+
if (tail)
|
|
634
|
+
parts.push(`${tail}.`);
|
|
635
|
+
}
|
|
636
|
+
if (f.doi)
|
|
637
|
+
parts.push(`https://doi.org/${f.doi}.`);
|
|
638
|
+
else if (f.url)
|
|
639
|
+
parts.push(`${f.url}.`);
|
|
640
|
+
return parts.join(" ");
|
|
641
|
+
}
|
|
642
|
+
function harvardText(e) {
|
|
643
|
+
const f = e.fields;
|
|
644
|
+
const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${initials(plain(n.given))}`));
|
|
645
|
+
let who = "";
|
|
646
|
+
if (names.length === 1)
|
|
647
|
+
who = names[0];
|
|
648
|
+
else if (names.length > 1)
|
|
649
|
+
who = `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
650
|
+
const container = f.journal ?? f.booktitle;
|
|
651
|
+
const title = f.title ? plain(f.title).replace(/[.,]$/, "") : "";
|
|
652
|
+
let out = `${who ? `${who} ` : ""}(${f.year ?? "n.d."})`;
|
|
653
|
+
if (title)
|
|
654
|
+
out += container ? ` '${title}',` : ` ${title}.`;
|
|
655
|
+
if (container) {
|
|
656
|
+
let c = plain(container);
|
|
657
|
+
if (f.volume)
|
|
658
|
+
c += `, ${f.volume}`;
|
|
659
|
+
if (f.number)
|
|
660
|
+
c += `(${f.number})`;
|
|
661
|
+
if (f.pages)
|
|
662
|
+
c += `, pp. ${normalizePages(f.pages).replace("--", "-")}`;
|
|
663
|
+
out += ` ${c}.`;
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
const pub = [f.address ? plain(f.address) : "", f.publisher ? plain(f.publisher) : ""].filter(Boolean).join(": ");
|
|
667
|
+
if (pub)
|
|
668
|
+
out += ` ${pub}.`;
|
|
669
|
+
}
|
|
670
|
+
if (f.doi)
|
|
671
|
+
out += ` doi:${f.doi}.`;
|
|
672
|
+
else if (f.url)
|
|
673
|
+
out += ` Available at: ${f.url}`;
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
export function toText(entries, style = "apa") {
|
|
514
677
|
return entries
|
|
515
|
-
.map((e) => {
|
|
678
|
+
.map((e, i) => {
|
|
679
|
+
if (style === "mla")
|
|
680
|
+
return mlaText(e);
|
|
681
|
+
if (style === "chicago")
|
|
682
|
+
return chicagoText(e);
|
|
683
|
+
if (style === "harvard")
|
|
684
|
+
return harvardText(e);
|
|
685
|
+
if (style === "ieee")
|
|
686
|
+
return ieeeText(e, i);
|
|
516
687
|
const f = e.fields;
|
|
517
688
|
const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${initials(plain(n.given))}`));
|
|
518
689
|
let who = "";
|
|
@@ -563,7 +734,7 @@ export function parseAny(text, format = "auto") {
|
|
|
563
734
|
}
|
|
564
735
|
return { format: fmt, entries };
|
|
565
736
|
}
|
|
566
|
-
export function serialize(entries, format) {
|
|
737
|
+
export function serialize(entries, format, style = "apa") {
|
|
567
738
|
switch (format) {
|
|
568
739
|
case "bibtex":
|
|
569
740
|
return toBibtex(entries);
|
|
@@ -572,7 +743,7 @@ export function serialize(entries, format) {
|
|
|
572
743
|
case "csl_json":
|
|
573
744
|
return toCslJson(entries);
|
|
574
745
|
default:
|
|
575
|
-
return toText(entries);
|
|
746
|
+
return toText(entries, style);
|
|
576
747
|
}
|
|
577
748
|
}
|
|
578
749
|
/* ---------- cite keys ---------- */
|
|
@@ -602,3 +773,25 @@ export function uniqueKey(base, used) {
|
|
|
602
773
|
return base + suffix;
|
|
603
774
|
}
|
|
604
775
|
}
|
|
776
|
+
/* ---------- LaTeX citations ---------- */
|
|
777
|
+
const CITE_COMMAND = /\\[a-zA-Z]*cite[a-zA-Z]*\*?\s*(?:\[[^\]]*\]\s*)*\{([^}]*)\}/g;
|
|
778
|
+
/**
|
|
779
|
+
* Pulls cite keys out of \cite, \citep, \citet, \parencite, \autocite and
|
|
780
|
+
* similar commands, including lists like \cite{a,b} and optional arguments.
|
|
781
|
+
* Keys come back once each, in order of first use. Commented text is skipped.
|
|
782
|
+
*/
|
|
783
|
+
export function extractCiteKeys(tex) {
|
|
784
|
+
const text = tex.replace(/(^|[^\\])%.*$/gm, "$1");
|
|
785
|
+
const seen = new Set();
|
|
786
|
+
const keys = [];
|
|
787
|
+
for (const m of text.matchAll(CITE_COMMAND)) {
|
|
788
|
+
for (const raw of m[1].split(",")) {
|
|
789
|
+
const key = raw.trim();
|
|
790
|
+
if (key && !seen.has(key)) {
|
|
791
|
+
seen.add(key);
|
|
792
|
+
keys.push(key);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return keys;
|
|
797
|
+
}
|
package/build/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { registerTools } from "./core/tools.js";
|
|
3
3
|
import { tools } from "./tools.js";
|
|
4
|
-
export const VERSION = "0.1.
|
|
4
|
+
export const VERSION = "0.1.3";
|
|
5
5
|
export function createServer() {
|
|
6
6
|
const server = new McpServer({ name: "bibtex-reference-manager-mcp", version: VERSION });
|
|
7
7
|
registerTools(server, tools, null);
|
package/build/src/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { bullets } from "./core/tools.js";
|
|
3
|
-
import { findDuplicates, lintEntries } from "./analyze.js";
|
|
3
|
+
import { findDuplicates, fixEntries, lintEntries } from "./analyze.js";
|
|
4
4
|
import { makeKey, parseAny, parseNames, plain, serialize, toBibtex, uniqueKey } from "./bib.js";
|
|
5
5
|
const text = z.string().min(1).describe("The reference list as text: BibTeX, RIS or CSL JSON.");
|
|
6
6
|
const format = z
|
|
@@ -48,11 +48,15 @@ const parseBibliography = {
|
|
|
48
48
|
const convertBibliography = {
|
|
49
49
|
name: "convert_bibliography",
|
|
50
50
|
title: "Convert a bibliography",
|
|
51
|
-
description: "Convert a reference list between BibTeX, RIS, CSL JSON and plain text (a simple
|
|
51
|
+
description: "Convert a reference list between BibTeX, RIS, CSL JSON and plain text (a simple reference list style, APA by default, or MLA, IEEE, Chicago or Harvard when you pass style). The input format is detected unless you pass it. Page ranges, DOIs and names are cleaned on the way. Example: text is a BibTeX entry and to is \"ris\", and output is the same reference as RIS. Set from only to override detection.",
|
|
52
52
|
inputSchema: {
|
|
53
53
|
text,
|
|
54
54
|
to: z.enum(["bibtex", "ris", "csl_json", "text"]).describe("The format to write."),
|
|
55
55
|
from: format,
|
|
56
|
+
style: z
|
|
57
|
+
.enum(["apa", "mla", "ieee", "chicago", "harvard"])
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("The citation style for the text output: apa, mla, ieee, chicago or harvard. Defaults to apa. Ignored for the other formats."),
|
|
56
60
|
},
|
|
57
61
|
outputSchema: {
|
|
58
62
|
from: z.string().describe("The format that was read."),
|
|
@@ -64,7 +68,7 @@ const convertBibliography = {
|
|
|
64
68
|
const { format: fmt, entries } = parseAny(a.text, a.from ?? "auto");
|
|
65
69
|
if (!entries.length)
|
|
66
70
|
throw new Error("No entries were found in the text, so there is nothing to convert.");
|
|
67
|
-
const output = serialize(entries, a.to);
|
|
71
|
+
const output = serialize(entries, a.to, a.style ?? "apa");
|
|
68
72
|
return {
|
|
69
73
|
text: `Converted ${plural(entries.length, "entry")} from ${fmt} to ${a.to}.\n\n${output}`,
|
|
70
74
|
structured: { from: fmt, to: a.to, count: entries.length, output },
|
|
@@ -84,6 +88,10 @@ const dedupeEntries = {
|
|
|
84
88
|
.max(1)
|
|
85
89
|
.optional()
|
|
86
90
|
.describe("How alike two titles must be to count as the same, from 0.5 to 1. Defaults to 0.9."),
|
|
91
|
+
merge_duplicates: z
|
|
92
|
+
.boolean()
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Set to true to also return a merged list that shows, for each group, the fields only the removed copies had, like doi or pages. The kept entry's own fields are never changed. Defaults to false."),
|
|
87
95
|
},
|
|
88
96
|
outputSchema: {
|
|
89
97
|
total: z.number().describe("How many entries were read."),
|
|
@@ -95,10 +103,33 @@ const dedupeEntries = {
|
|
|
95
103
|
remove: z.array(z.string()),
|
|
96
104
|
})),
|
|
97
105
|
deduplicated_bibtex: z.string().describe("The list as BibTeX without the removed copies."),
|
|
106
|
+
merged: z
|
|
107
|
+
.array(z.object({
|
|
108
|
+
key: z.string().describe("The cite key of the kept entry."),
|
|
109
|
+
added_fields: z.record(z.string(), z.string()).describe("Fields that only the removed copies had, with the value taken from the first copy that has it."),
|
|
110
|
+
fields: z.record(z.string(), z.string()).describe("The kept entry's fields plus the added ones."),
|
|
111
|
+
}))
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Only present when merge_duplicates is true: one item per group showing the merged fields."),
|
|
98
114
|
},
|
|
99
115
|
run: async (a) => {
|
|
100
116
|
const { entries } = parseAny(a.text, a.format ?? "auto");
|
|
101
117
|
const groups = findDuplicates(entries, a.title_threshold ?? 0.9);
|
|
118
|
+
const merged = a.merge_duplicates
|
|
119
|
+
? groups.map((g) => {
|
|
120
|
+
const fields = { ...entries[g.keepIndex].fields };
|
|
121
|
+
const added = {};
|
|
122
|
+
for (const i of g.indexes) {
|
|
123
|
+
if (i === g.keepIndex)
|
|
124
|
+
continue;
|
|
125
|
+
for (const [name, value] of Object.entries(entries[i].fields)) {
|
|
126
|
+
if (value && !fields[name])
|
|
127
|
+
fields[name] = added[name] = value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { key: g.keep, added_fields: added, fields };
|
|
131
|
+
})
|
|
132
|
+
: undefined;
|
|
102
133
|
const drop = new Set();
|
|
103
134
|
for (const g of groups) {
|
|
104
135
|
for (const i of g.indexes)
|
|
@@ -114,6 +145,7 @@ const dedupeEntries = {
|
|
|
114
145
|
unique: kept.length,
|
|
115
146
|
groups: groups.map(({ keys, reasons, keep, remove }) => ({ keys, reasons, keep, remove })),
|
|
116
147
|
deduplicated_bibtex: toBibtex(kept),
|
|
148
|
+
...(merged ? { merged } : {}),
|
|
117
149
|
},
|
|
118
150
|
};
|
|
119
151
|
},
|
|
@@ -121,7 +153,7 @@ const dedupeEntries = {
|
|
|
121
153
|
const lintEntriesTool = {
|
|
122
154
|
name: "lint_entries",
|
|
123
155
|
title: "Lint entries",
|
|
124
|
-
description: "Check a reference list for problems: missing required fields for the entry type, bad page ranges, titles that are not capitalized, invalid DOIs, odd years and
|
|
156
|
+
description: "Check a reference list for problems: missing required fields for the entry type, bad page ranges, titles that are not capitalized, invalid DOIs, odd years, repeated keys and unescaped &, %, _ or # in titles, journal names and other text fields (rule unescaped-character). Each issue names the entry key, the field and a plain message. Example: an @article with no journal field gives an issue with the rule missing-field and the field journal. Pass the whole list as text so repeated keys can be found.",
|
|
125
157
|
inputSchema: { text, format },
|
|
126
158
|
outputSchema: {
|
|
127
159
|
count: z.number().describe("How many entries were checked."),
|
|
@@ -129,7 +161,7 @@ const lintEntriesTool = {
|
|
|
129
161
|
warnings: z.number(),
|
|
130
162
|
issues: z.array(z.object({
|
|
131
163
|
key: z.string(),
|
|
132
|
-
rule: z.enum(["missing-field", "bad-pages", "uncapitalized-title", "invalid-doi", "bad-year", "duplicate-key"]),
|
|
164
|
+
rule: z.enum(["missing-field", "bad-pages", "uncapitalized-title", "invalid-doi", "bad-year", "duplicate-key", "unescaped-character"]),
|
|
133
165
|
severity: z.enum(["error", "warning"]),
|
|
134
166
|
field: z.string().optional(),
|
|
135
167
|
message: z.string(),
|
|
@@ -147,6 +179,38 @@ const lintEntriesTool = {
|
|
|
147
179
|
};
|
|
148
180
|
},
|
|
149
181
|
};
|
|
182
|
+
const fixEntriesTool = {
|
|
183
|
+
name: "fix_entries",
|
|
184
|
+
title: "Fix entries",
|
|
185
|
+
description: "Apply the safe fixes that lint_entries points to and return the cleaned list as BibTeX: page ranges with one hyphen get a double hyphen, titles that start with a lower case letter or are all lower case get capitals (words inside braces are left alone, and acronyms like DNA, NASA or iOS in those titles are wrapped as {DNA} so BibTeX keeps their case; they are listed in protected_titles), and DOIs written as a link or with a doi: prefix become bare DOIs. Missing fields, backwards page ranges and DOIs that are not valid are not guessed, so run lint_entries on the result. Example: pages \"12-15\" becomes \"12--15\". Your files are not changed.",
|
|
186
|
+
inputSchema: { text, format },
|
|
187
|
+
outputSchema: {
|
|
188
|
+
count: z.number().describe("How many entries were read."),
|
|
189
|
+
fixed: z.number().describe("How many changes were made."),
|
|
190
|
+
fixes: z.array(z.object({
|
|
191
|
+
key: z.string(),
|
|
192
|
+
field: z.enum(["pages", "title", "doi"]),
|
|
193
|
+
from: z.string(),
|
|
194
|
+
to: z.string(),
|
|
195
|
+
})),
|
|
196
|
+
bibtex: z.string().describe("The cleaned list as BibTeX."),
|
|
197
|
+
protected_titles: z
|
|
198
|
+
.array(z.object({ key: z.string(), words: z.array(z.string()) }))
|
|
199
|
+
.describe("Acronyms wrapped in braces, like DNA, in titles that got a capitalization fix."),
|
|
200
|
+
},
|
|
201
|
+
run: async (a) => {
|
|
202
|
+
const { entries } = parseAny(a.text, a.format ?? "auto");
|
|
203
|
+
if (!entries.length)
|
|
204
|
+
throw new Error("No entries were found in the text, so there is nothing to fix.");
|
|
205
|
+
const { entries: cleaned, fixes, protectedTitles } = fixEntries(entries);
|
|
206
|
+
const bibtex = toBibtex(cleaned);
|
|
207
|
+
const lines = fixes.map((f) => `${f.key}: ${f.field} "${f.from}" becomes "${f.to}"`);
|
|
208
|
+
return {
|
|
209
|
+
text: `Checked ${plural(entries.length, "entry")} and made ${plural(fixes.length, "change")}.\n${bullets(lines, "Nothing needed fixing.")}\n\n${bibtex}`,
|
|
210
|
+
structured: { count: entries.length, fixed: fixes.length, fixes, bibtex, protected_titles: protectedTitles },
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
};
|
|
150
214
|
const generateCiteKeys = {
|
|
151
215
|
name: "generate_cite_keys",
|
|
152
216
|
title: "Generate cite keys",
|
|
@@ -155,6 +219,7 @@ const generateCiteKeys = {
|
|
|
155
219
|
text,
|
|
156
220
|
format,
|
|
157
221
|
include_title_word: z.boolean().optional().describe("Add the first meaningful title word, so you get smith2024deep and not smith2024. Defaults to true."),
|
|
222
|
+
show_bibtex: z.boolean().optional().describe("Also print the full entries with the new keys in the text reply, ready to paste into a .bib file. Defaults to false."),
|
|
158
223
|
},
|
|
159
224
|
outputSchema: {
|
|
160
225
|
count: z.number(),
|
|
@@ -171,10 +236,12 @@ const generateCiteKeys = {
|
|
|
171
236
|
});
|
|
172
237
|
const renamed = entries.map((e, i) => ({ ...e, key: keys[i].new_key }));
|
|
173
238
|
const lines = keys.map((k) => (k.old_key === k.new_key ? `${k.new_key} (unchanged)` : `${k.old_key} becomes ${k.new_key}`));
|
|
239
|
+
const bibtex = toBibtex(renamed);
|
|
240
|
+
const head = `Made ${plural(keys.length, "cite key")}.\n${bullets(lines, "No entries were found.")}`;
|
|
174
241
|
return {
|
|
175
|
-
text:
|
|
176
|
-
structured: { count: keys.length, keys, bibtex
|
|
242
|
+
text: a.show_bibtex && keys.length ? `${head}\n\n${bibtex}` : head,
|
|
243
|
+
structured: { count: keys.length, keys, bibtex },
|
|
177
244
|
};
|
|
178
245
|
},
|
|
179
246
|
};
|
|
180
|
-
export const tools = [parseBibliography, convertBibliography, dedupeEntries, lintEntriesTool, generateCiteKeys];
|
|
247
|
+
export const tools = [parseBibliography, convertBibliography, dedupeEntries, lintEntriesTool, fixEntriesTool, generateCiteKeys];
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BibTeX Reference Manager MCP
|
|
2
2
|
|
|
3
|
-
> A free, open source MCP server that cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists. It runs offline on the user's computer with no account and no API key. npm package: bibtex-reference-manager-mcp. Version 0.1.
|
|
3
|
+
> A free, open source MCP server that cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists. It runs offline on the user's computer with no account and no API key. npm package: bibtex-reference-manager-mcp. Version 0.1.3.
|
|
4
4
|
|
|
5
5
|
Use this server when a user pastes or attaches a reference list and wants it read, converted, deduplicated, checked or renamed. Pass the list as the `text` argument. The tools do the work in code, so do not rewrite entries by hand when a tool can do it.
|
|
6
6
|
|
|
@@ -66,7 +66,7 @@ Example result:
|
|
|
66
66
|
Notes: leave format out unless auto picks the wrong one. If the text cannot be read, the tool returns an error asking for the format to be passed.
|
|
67
67
|
|
|
68
68
|
### convert_bibliography
|
|
69
|
-
Convert between BibTeX, RIS, CSL JSON and plain text (
|
|
69
|
+
Convert between BibTeX, RIS, CSL JSON and plain text. For plain text, pass style as apa (the default), mla, ieee, chicago or harvard to get a formatted reference list. Input format is detected unless passed. Page ranges become 12--15 in BibTeX. DOIs and names are cleaned on the way.
|
|
70
70
|
|
|
71
71
|
Inputs:
|
|
72
72
|
- text (required): the reference list
|
|
@@ -94,6 +94,7 @@ Inputs:
|
|
|
94
94
|
- text (required): the reference list
|
|
95
95
|
- format (optional): auto, bibtex, ris, or csl_json
|
|
96
96
|
- title_threshold (optional): 0.5 to 1, default 0.9
|
|
97
|
+
- merge_duplicates (optional): true adds a merged list. For each group it shows the fields only the removed copies had (like doi or pages) and the kept entry's fields with those added. Existing fields are never changed.
|
|
97
98
|
|
|
98
99
|
How matching works:
|
|
99
100
|
- DOI match: normalizes DOI (strips https://doi.org/) and matches case insensitively
|
|
@@ -118,6 +119,7 @@ Rules:
|
|
|
118
119
|
- invalid-doi: DOI does not look like 10.xxxx/something (DOIs are not looked up online)
|
|
119
120
|
- bad-year: year is not a normal year value
|
|
120
121
|
- duplicate-key: the same cite key appears more than once
|
|
122
|
+
- unescaped-character: a raw &, %, _ or # in title, booktitle, journal, publisher, series, school, institution or address (text between $ signs is skipped). Write \&, \%, \_ or \# instead.
|
|
121
123
|
|
|
122
124
|
Example issues:
|
|
123
125
|
- bad1: missing-field on journal (error)
|
|
@@ -130,6 +132,20 @@ Example issues:
|
|
|
130
132
|
|
|
131
133
|
Pass the whole list so repeated keys can be found. A clean list returns no problems found.
|
|
132
134
|
|
|
135
|
+
### fix_entries
|
|
136
|
+
Apply the safe fixes that lint_entries points to and return the cleaned list as BibTeX, plus a list of each change (key, field, from, to).
|
|
137
|
+
|
|
138
|
+
Inputs:
|
|
139
|
+
- text (required): the reference list
|
|
140
|
+
- format (optional): auto, bibtex, ris, or csl_json
|
|
141
|
+
|
|
142
|
+
Fixes:
|
|
143
|
+
- pages: a single hyphen range like 12-15 becomes 12--15
|
|
144
|
+
- title: a title that starts with a lower case letter gets a capital; a title with every word in lower case gets capitals on the main words. Text inside braces is left alone
|
|
145
|
+
- doi: a link or doi: prefix is removed, leaving the bare DOI
|
|
146
|
+
|
|
147
|
+
Not fixed: missing fields, backwards page ranges, bad years, repeated keys and DOIs that are not valid. Run lint_entries on the result to see what is left. The list is returned as text, no file is changed.
|
|
148
|
+
|
|
133
149
|
### generate_cite_keys
|
|
134
150
|
Create consistent cite keys from the first author's family name, the year, and the first meaningful word of the title. Repeated keys get a letter added.
|
|
135
151
|
|
|
@@ -137,6 +153,7 @@ Inputs:
|
|
|
137
153
|
- text (required): the reference list
|
|
138
154
|
- format (optional): auto, bibtex, ris, or csl_json
|
|
139
155
|
- include_title_word (optional): add the first meaningful title word (default true)
|
|
156
|
+
- show_bibtex (optional): also print the full entries with the new keys in the text reply (default false)
|
|
140
157
|
|
|
141
158
|
Behavior:
|
|
142
159
|
- Uses first author's family name, normalized (accents removed)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bibtex-reference-manager-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "BibTeX reference manager MCP server for students writing LaTeX theses and papers. Ask your AI app to fix a broken .bib file, clean a Zotero export or Mendeley export, convert EndNote RIS to BibTeX, remove duplicates, check DOIs and make consistent cite keys for your thesis bibliography. Also reads CSL JSON. Works offline with no account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/src/index.js",
|