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 +14 -0
- package/README.md +2 -2
- package/build/src/analyze.js +47 -6
- package/build/src/bib.js +113 -1
- package/build/src/server.js +1 -1
- package/build/src/tools.js +44 -10
- package/llms.txt +5 -2
- package/package.json +1 -1
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 `
|
|
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
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
|
|
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,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
|
-
|
|
173
|
-
|
|
194
|
+
const protectedWords = [];
|
|
195
|
+
const firstOnly = /^[a-z]/.test(raw);
|
|
174
196
|
let depth = 0;
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
209
|
-
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 },
|
|
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.
|
|
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
|
|
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.
|
|
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",
|