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 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 repeated keys. |
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 five tools fit together. All of them take the reference list as `text` and are read only.
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` and fix what it flags.
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 five tools.
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
 
@@ -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
- throw new Error("Could not tell the format. Pass format as bibtex, ris or csl_json.");
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
- export function toText(entries) {
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
+ }
@@ -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.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);
@@ -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 author, year, title 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.",
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 repeated keys. 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.",
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: `Made ${plural(keys.length, "cite key")}.\n${bullets(lines, "No entries were found.")}`,
176
- structured: { count: keys.length, keys, bibtex: toBibtex(renamed) },
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.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 (a simple author, year, title style). Input format is detected unless passed. Page ranges become 12--15 in BibTeX. DOIs and names are cleaned on the way.
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.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",