bibtex-reference-manager-mcp 0.1.2 → 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,19 @@
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
+
3
17
  ## 0.1.2
4
18
 
5
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.
package/README.md CHANGED
@@ -139,9 +139,9 @@ 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. For plain text, pass `style` as `apa` (the default), `mla` or `ieee` to get a formatted reference list. |
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
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. |
146
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`. |
147
147
 
@@ -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,25 +187,43 @@ 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]*)([)"'\].,;:!?]*)$/;
170
192
  function capitalizeTitle(title) {
171
193
  const raw = title.trim();
172
- if (/^[a-z]/.test(raw))
173
- return raw[0].toUpperCase() + raw.slice(1);
194
+ const protectedWords = [];
195
+ const firstOnly = /^[a-z]/.test(raw);
174
196
  let depth = 0;
175
- return raw
197
+ let first = true;
198
+ const out = raw
176
199
  .split(/(\s+)/)
177
200
  .map((word) => {
178
201
  const inBraces = depth > 0 || word.startsWith("{");
179
202
  for (const c of word)
180
203
  depth += c === "{" ? 1 : c === "}" ? -1 : 0;
181
- if (inBraces || SMALL_WORDS.has(word.toLowerCase()))
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()))
182
218
  return word;
183
219
  return word.replace(/^[a-z]/, (c) => c.toUpperCase());
184
220
  })
185
221
  .join("");
222
+ return { title: out, protectedWords };
186
223
  }
187
224
  export function fixEntries(entries) {
188
225
  const fixes = [];
226
+ const protectedTitles = [];
189
227
  const fixed = entries.map((e) => {
190
228
  const f = { ...e.fields };
191
229
  const set = (field, to) => {
@@ -197,7 +235,10 @@ export function fixEntries(entries) {
197
235
  set("pages", f.pages.trim().replace(/\s*[-–—]\s*/, "--"));
198
236
  }
199
237
  if (f.title && lintEntries([{ ...e, fields: f }]).some((i) => i.rule === "uncapitalized-title")) {
200
- set("title", capitalizeTitle(f.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 });
201
242
  }
202
243
  if (f.doi) {
203
244
  const doi = cleanDoi(f.doi);
@@ -206,5 +247,5 @@ export function fixEntries(entries) {
206
247
  }
207
248
  return { ...e, fields: f };
208
249
  });
209
- return { entries: fixed, fixes };
250
+ return { entries: fixed, fixes, protectedTitles };
210
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 = {
@@ -587,11 +596,92 @@ function ieeeText(e, index) {
587
596
  body += ` ${f.url}`;
588
597
  return `[${index + 1}] ${body}`;
589
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
+ }
590
676
  export function toText(entries, style = "apa") {
591
677
  return entries
592
678
  .map((e, i) => {
593
679
  if (style === "mla")
594
680
  return mlaText(e);
681
+ if (style === "chicago")
682
+ return chicagoText(e);
683
+ if (style === "harvard")
684
+ return harvardText(e);
595
685
  if (style === "ieee")
596
686
  return ieeeText(e, i);
597
687
  const f = e.fields;
@@ -683,3 +773,25 @@ export function uniqueKey(base, used) {
683
773
  return base + suffix;
684
774
  }
685
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.2";
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);
@@ -48,15 +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 reference list style, APA by default, or MLA or IEEE 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.",
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
56
  style: z
57
- .enum(["apa", "mla", "ieee"])
57
+ .enum(["apa", "mla", "ieee", "chicago", "harvard"])
58
58
  .optional()
59
- .describe("The citation style for the text output: apa, mla or ieee. Defaults to apa. Ignored for the other formats."),
59
+ .describe("The citation style for the text output: apa, mla, ieee, chicago or harvard. Defaults to apa. Ignored for the other formats."),
60
60
  },
61
61
  outputSchema: {
62
62
  from: z.string().describe("The format that was read."),
@@ -88,6 +88,10 @@ const dedupeEntries = {
88
88
  .max(1)
89
89
  .optional()
90
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."),
91
95
  },
92
96
  outputSchema: {
93
97
  total: z.number().describe("How many entries were read."),
@@ -99,10 +103,33 @@ const dedupeEntries = {
99
103
  remove: z.array(z.string()),
100
104
  })),
101
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."),
102
114
  },
103
115
  run: async (a) => {
104
116
  const { entries } = parseAny(a.text, a.format ?? "auto");
105
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;
106
133
  const drop = new Set();
107
134
  for (const g of groups) {
108
135
  for (const i of g.indexes)
@@ -118,6 +145,7 @@ const dedupeEntries = {
118
145
  unique: kept.length,
119
146
  groups: groups.map(({ keys, reasons, keep, remove }) => ({ keys, reasons, keep, remove })),
120
147
  deduplicated_bibtex: toBibtex(kept),
148
+ ...(merged ? { merged } : {}),
121
149
  },
122
150
  };
123
151
  },
@@ -125,7 +153,7 @@ const dedupeEntries = {
125
153
  const lintEntriesTool = {
126
154
  name: "lint_entries",
127
155
  title: "Lint entries",
128
- 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.",
129
157
  inputSchema: { text, format },
130
158
  outputSchema: {
131
159
  count: z.number().describe("How many entries were checked."),
@@ -133,7 +161,7 @@ const lintEntriesTool = {
133
161
  warnings: z.number(),
134
162
  issues: z.array(z.object({
135
163
  key: z.string(),
136
- 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"]),
137
165
  severity: z.enum(["error", "warning"]),
138
166
  field: z.string().optional(),
139
167
  message: z.string(),
@@ -154,7 +182,7 @@ const lintEntriesTool = {
154
182
  const fixEntriesTool = {
155
183
  name: "fix_entries",
156
184
  title: "Fix entries",
157
- 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 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.",
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.",
158
186
  inputSchema: { text, format },
159
187
  outputSchema: {
160
188
  count: z.number().describe("How many entries were read."),
@@ -166,17 +194,20 @@ const fixEntriesTool = {
166
194
  to: z.string(),
167
195
  })),
168
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."),
169
200
  },
170
201
  run: async (a) => {
171
202
  const { entries } = parseAny(a.text, a.format ?? "auto");
172
203
  if (!entries.length)
173
204
  throw new Error("No entries were found in the text, so there is nothing to fix.");
174
- const { entries: cleaned, fixes } = fixEntries(entries);
205
+ const { entries: cleaned, fixes, protectedTitles } = fixEntries(entries);
175
206
  const bibtex = toBibtex(cleaned);
176
207
  const lines = fixes.map((f) => `${f.key}: ${f.field} "${f.from}" becomes "${f.to}"`);
177
208
  return {
178
209
  text: `Checked ${plural(entries.length, "entry")} and made ${plural(fixes.length, "change")}.\n${bullets(lines, "Nothing needed fixing.")}\n\n${bibtex}`,
179
- structured: { count: entries.length, fixed: fixes.length, fixes, bibtex },
210
+ structured: { count: entries.length, fixed: fixes.length, fixes, bibtex, protected_titles: protectedTitles },
180
211
  };
181
212
  },
182
213
  };
@@ -188,6 +219,7 @@ const generateCiteKeys = {
188
219
  text,
189
220
  format,
190
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."),
191
223
  },
192
224
  outputSchema: {
193
225
  count: z.number(),
@@ -204,9 +236,11 @@ const generateCiteKeys = {
204
236
  });
205
237
  const renamed = entries.map((e, i) => ({ ...e, key: keys[i].new_key }));
206
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.")}`;
207
241
  return {
208
- text: `Made ${plural(keys.length, "cite key")}.\n${bullets(lines, "No entries were found.")}`,
209
- 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 },
210
244
  };
211
245
  },
212
246
  };
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.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.
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. For plain text, pass style as apa (the default), mla or ieee 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.
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)
@@ -151,6 +153,7 @@ Inputs:
151
153
  - text (required): the reference list
152
154
  - format (optional): auto, bibtex, ris, or csl_json
153
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)
154
157
 
155
158
  Behavior:
156
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.2",
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",