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

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 ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release of BibTeX Reference Manager MCP.
6
+ - Added `parse_bibliography`, which reads BibTeX, RIS or CSL JSON and returns normalized entries.
7
+ - Added `convert_bibliography`, which converts between BibTeX, RIS, CSL JSON and plain text.
8
+ - Added `dedupe_entries`, which finds duplicates by DOI, cite key and title similarity.
9
+ - Added `lint_entries`, which flags missing fields, bad page ranges, uncapitalized titles and invalid DOIs.
10
+ - Added `generate_cite_keys`, which makes keys like smith2024deep.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AnonymousOnyx22
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # BibTeX Reference Manager MCP
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/bibtex-reference-manager-mcp.svg)](https://www.npmjs.com/package/bibtex-reference-manager-mcp)
4
+ [![downloads](https://img.shields.io/npm/dm/bibtex-reference-manager-mcp.svg)](https://www.npmjs.com/package/bibtex-reference-manager-mcp)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ BibTeX Reference Manager MCP is a free, open source [MCP](https://modelcontextprotocol.io) server, published on npm as `bibtex-reference-manager-mcp`. It cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists, so you stop fixing broken `.bib` files by hand. Paste a list into your AI app and ask it to read the entries, convert them to another format, find the duplicates, flag the problems or give every entry a tidy cite key.
8
+
9
+ Use it with Claude Desktop, Claude Code, Cursor, VS Code Copilot and any other app that supports MCP. It runs on your computer, needs no internet and no login, and your references never leave your machine.
10
+
11
+ Free forever. No account and no subscription.
12
+
13
+ ## Install
14
+
15
+ Run it straight from npm with `npx`. You need Node.js 20 or newer, see [Node version](#node-version) for the details. There is nothing else to install, no account, no API key and no config file for the server itself.
16
+
17
+ ```bash
18
+ npx -y bibtex-reference-manager-mcp
19
+ ```
20
+
21
+ Add it to your AI app's MCP config so the app can start it for you:
22
+
23
+ - [Claude Desktop](#claude-desktop)
24
+ - [Claude Code](#claude-code)
25
+ - [Cursor](#cursor)
26
+ - [VS Code Copilot](#vs-code-copilot)
27
+
28
+ All of them start the same server the same way. The only difference is where the config goes.
29
+
30
+ ### Claude Desktop
31
+
32
+ Add this to `claude_desktop_config.json`:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "bibtex": {
38
+ "command": "npx",
39
+ "args": ["-y", "bibtex-reference-manager-mcp"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ Restart Claude Desktop after you save the file.
46
+
47
+ ### Claude Code
48
+
49
+ Run this in a terminal:
50
+
51
+ ```bash
52
+ claude mcp add bibtex -- npx -y bibtex-reference-manager-mcp
53
+ ```
54
+
55
+ ### Cursor
56
+
57
+ Cursor reads `.cursor/mcp.json` from the project folder, or `~/.cursor/mcp.json` in your home folder if you want the server in every project.
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "bibtex": {
63
+ "type": "stdio",
64
+ "command": "npx",
65
+ "args": ["-y", "bibtex-reference-manager-mcp"]
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### VS Code Copilot
72
+
73
+ Add this to `.vscode/mcp.json` in your workspace:
74
+
75
+ ```json
76
+ {
77
+ "servers": {
78
+ "bibtex": {
79
+ "type": "stdio",
80
+ "command": "npx",
81
+ "args": ["-y", "bibtex-reference-manager-mcp"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Try these prompts
88
+
89
+ Paste your reference list into the chat first, or attach the file, then ask:
90
+
91
+ 1. "Here is my references.bib. Lint it and tell me what is broken, entry by entry."
92
+ 2. "Find the duplicates in this list and give me back a clean BibTeX file without them."
93
+ 3. "Convert these RIS entries from my library export to BibTeX, then rename the keys so they look like smith2024deep."
94
+
95
+ ## Tools
96
+
97
+ | Tool | What it does |
98
+ | --- | --- |
99
+ | `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". |
100
+ | `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. |
101
+ | `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. |
102
+ | `lint_entries` | Flags missing required fields, bad page ranges, uncapitalized titles, invalid DOIs, odd years and repeated keys. |
103
+ | `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`. |
104
+
105
+ 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.
106
+
107
+ ### Formats
108
+
109
+ - BibTeX: `@string` macros, quoted and braced values, `#` joins, and `@comment` blocks are handled.
110
+ - RIS: the usual tags such as `TY`, `AU`, `TI`, `T2`, `PY`, `SP`, `EP`, `DO` and `KW`.
111
+ - CSL JSON: a list of items, one item, or an object with an `items` list.
112
+
113
+ ## Privacy
114
+
115
+ Everything runs on your computer. The server reads only the text your AI app passes to it. It makes no network requests, does not look up DOIs online, needs no API key and keeps nothing between calls. DOI checks only look at the shape of the DOI, they do not ask a website whether it exists.
116
+
117
+ ## FAQ
118
+
119
+ **Does it check that a DOI really exists?** No. It runs offline, so it only checks that the DOI has the right shape, like `10.1000/xyz123`.
120
+
121
+ **How does it decide two titles are the same?** It lowercases the titles, drops punctuation and accents, and compares pairs of letters. The default match is 0.9 out of 1. Pass `title_threshold` to loosen or tighten that. Two entries with different years are not matched by title.
122
+
123
+ **Will it change my protected capitals like `{DNA}`?** No. Braces are kept in BibTeX output and only removed when you convert to RIS, CSL JSON or plain text.
124
+
125
+ ## Node version
126
+
127
+ You need Node.js 20 or newer to run the server. To work on it, use Node.js 22.12 or newer.
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ npm install
133
+ npm run build
134
+ npm test
135
+ ```
136
+
137
+ 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.
138
+
139
+ ## License
140
+
141
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,169 @@
1
+ import { ascii, cleanDoi, plain } from "./bib.js";
2
+ /* ---------- duplicates ---------- */
3
+ export function normalizeTitle(t) {
4
+ return ascii(plain(t))
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, " ")
7
+ .trim();
8
+ }
9
+ function bigrams(s) {
10
+ const m = new Map();
11
+ const t = s.replace(/ /g, "");
12
+ for (let i = 0; i < t.length - 1; i++) {
13
+ const g = t.slice(i, i + 2);
14
+ m.set(g, (m.get(g) ?? 0) + 1);
15
+ }
16
+ return m;
17
+ }
18
+ /** Dice coefficient on character pairs, from 0 to 1. */
19
+ export function titleSimilarity(a, b) {
20
+ const x = normalizeTitle(a);
21
+ const y = normalizeTitle(b);
22
+ if (!x || !y)
23
+ return 0;
24
+ if (x === y)
25
+ return 1;
26
+ const bx = bigrams(x);
27
+ const by = bigrams(y);
28
+ let overlap = 0;
29
+ let total = 0;
30
+ for (const v of bx.values())
31
+ total += v;
32
+ for (const v of by.values())
33
+ total += v;
34
+ for (const [g, c] of bx)
35
+ overlap += Math.min(c, by.get(g) ?? 0);
36
+ return total ? (2 * overlap) / total : 0;
37
+ }
38
+ export function findDuplicates(entries, threshold) {
39
+ const n = entries.length;
40
+ const parent = entries.map((_, i) => i);
41
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
42
+ const reasons = new Map();
43
+ const link = (a, b, why) => {
44
+ const ra = find(a);
45
+ const rb = find(b);
46
+ if (ra !== rb)
47
+ parent[rb] = ra;
48
+ // Record the reason against both members, the root is settled afterwards.
49
+ for (const i of [a, b]) {
50
+ if (!reasons.has(i))
51
+ reasons.set(i, new Set());
52
+ reasons.get(i).add(why);
53
+ }
54
+ };
55
+ for (let i = 0; i < n; i++) {
56
+ for (let j = i + 1; j < n; j++) {
57
+ const a = entries[i];
58
+ const b = entries[j];
59
+ const da = a.fields.doi ? cleanDoi(a.fields.doi).toLowerCase() : "";
60
+ const db = b.fields.doi ? cleanDoi(b.fields.doi).toLowerCase() : "";
61
+ if (da && da === db)
62
+ link(i, j, "doi");
63
+ if (a.key && a.key.toLowerCase() === b.key.toLowerCase())
64
+ link(i, j, "key");
65
+ const ya = a.fields.year;
66
+ const yb = b.fields.year;
67
+ const yearsClash = ya && yb && ya !== yb;
68
+ if (!yearsClash && a.fields.title && b.fields.title && titleSimilarity(a.fields.title, b.fields.title) >= threshold)
69
+ link(i, j, "title");
70
+ }
71
+ }
72
+ const groups = new Map();
73
+ for (let i = 0; i < n; i++) {
74
+ const r = find(i);
75
+ groups.set(r, [...(groups.get(r) ?? []), i]);
76
+ }
77
+ const out = [];
78
+ for (const members of groups.values()) {
79
+ if (members.length < 2)
80
+ continue;
81
+ const why = new Set();
82
+ for (const i of members)
83
+ for (const r of reasons.get(i) ?? [])
84
+ why.add(r);
85
+ // Keep the entry with the most fields, the earliest one on a tie.
86
+ const best = members.reduce((p, c) => (Object.keys(entries[c].fields).length > Object.keys(entries[p].fields).length ? c : p), members[0]);
87
+ out.push({
88
+ indexes: members,
89
+ keys: members.map((i) => entries[i].key),
90
+ reasons: ["doi", "key", "title"].filter((r) => why.has(r)),
91
+ keepIndex: best,
92
+ keep: entries[best].key,
93
+ remove: members.filter((i) => i !== best).map((i) => entries[i].key),
94
+ });
95
+ }
96
+ return out.sort((a, b) => a.indexes[0] - b.indexes[0]);
97
+ }
98
+ const REQUIRED = {
99
+ article: [["author"], ["title"], ["journal"], ["year"]],
100
+ book: [["author", "editor"], ["title"], ["publisher"], ["year"]],
101
+ inproceedings: [["author"], ["title"], ["booktitle"], ["year"]],
102
+ incollection: [["author"], ["title"], ["booktitle"], ["publisher"], ["year"]],
103
+ phdthesis: [["author"], ["title"], ["school"], ["year"]],
104
+ mastersthesis: [["author"], ["title"], ["school"], ["year"]],
105
+ techreport: [["author"], ["title"], ["institution"], ["year"]],
106
+ unpublished: [["author"], ["title"], ["note"]],
107
+ misc: [["title"]],
108
+ online: [["title"], ["url"]],
109
+ };
110
+ const SMALL_WORDS = new Set(["a", "an", "the", "and", "or", "of", "in", "on", "for", "to", "with", "at", "by", "from", "as", "but", "nor", "vs"]);
111
+ export function pageProblem(pages) {
112
+ const p = pages.trim();
113
+ if (/^[A-Za-z]?\d+$/.test(p))
114
+ return null;
115
+ if (/^\d+--\d+$/.test(p)) {
116
+ const [a, b] = p.split("--").map(Number);
117
+ return b < a ? `the range ${p} ends before it starts` : null;
118
+ }
119
+ if (/^\d+\s*[-–—]\s*\d+$/.test(p)) {
120
+ const [a, b] = p.split(/[-–—]/).map(Number);
121
+ return b < a ? `the range ${p} ends before it starts` : "use a double hyphen between the pages, like 12--15";
122
+ }
123
+ if (/^\d+\s*[-–—]+\s*\d+\s*[-–—]+\s*\d+$/.test(p))
124
+ return "the range has more than two page numbers";
125
+ return `"${p}" is not a page number or a range like 12--15`;
126
+ }
127
+ export function lintEntries(entries) {
128
+ const issues = [];
129
+ const seen = new Map();
130
+ for (const e of entries) {
131
+ const k = e.key.toLowerCase();
132
+ seen.set(k, (seen.get(k) ?? 0) + 1);
133
+ }
134
+ const reportedKeys = new Set();
135
+ for (const e of entries) {
136
+ const f = e.fields;
137
+ const add = (rule, severity, message, field) => issues.push({ key: e.key, rule, severity, message, ...(field ? { field } : {}) });
138
+ if ((seen.get(e.key.toLowerCase()) ?? 0) > 1 && !reportedKeys.has(e.key.toLowerCase())) {
139
+ reportedKeys.add(e.key.toLowerCase());
140
+ add("duplicate-key", "error", `The key ${e.key} is used ${seen.get(e.key.toLowerCase())} times.`);
141
+ }
142
+ for (const group of REQUIRED[e.type] ?? [["title"]]) {
143
+ if (!group.some((g) => f[g])) {
144
+ add("missing-field", e.type === "misc" || e.type === "online" ? "warning" : "error", `A ${e.type} needs ${group.join(" or ")}, and it is missing.`, group[0]);
145
+ }
146
+ }
147
+ if (f.pages) {
148
+ const problem = pageProblem(f.pages);
149
+ if (problem)
150
+ add("bad-pages", "warning", `Pages: ${problem}.`, "pages");
151
+ }
152
+ if (f.title) {
153
+ const words = plain(f.title).split(" ").filter(Boolean);
154
+ const raw = f.title.trim();
155
+ if (/^[a-z]/.test(raw)) {
156
+ add("uncapitalized-title", "warning", `The title starts with a lower case letter: "${plain(f.title)}".`, "title");
157
+ }
158
+ else if (words.length > 3 && words.filter((w) => !SMALL_WORDS.has(w.toLowerCase())).every((w) => w === w.toLowerCase())) {
159
+ add("uncapitalized-title", "warning", `Every word of the title is lower case: "${plain(f.title)}".`, "title");
160
+ }
161
+ }
162
+ if (f.doi && !/^10\.\d{4,9}\/\S+$/.test(cleanDoi(f.doi))) {
163
+ add("invalid-doi", "error", `"${f.doi}" is not a valid DOI. A DOI looks like 10.1000/xyz123.`, "doi");
164
+ }
165
+ if (f.year && !/^\d{4}$/.test(f.year))
166
+ add("bad-year", "warning", `The year "${f.year}" is not a four digit year.`, "year");
167
+ }
168
+ return issues;
169
+ }
@@ -0,0 +1,578 @@
1
+ const MONTHS = {
2
+ jan: "January", feb: "February", mar: "March", apr: "April", may: "May", jun: "June",
3
+ jul: "July", aug: "August", sep: "September", oct: "October", nov: "November", dec: "December",
4
+ };
5
+ const STOP_WORDS = new Set([
6
+ "a", "an", "the", "on", "of", "in", "and", "for", "to", "with", "at", "by", "from", "is", "are", "as", "or", "into", "towards", "toward",
7
+ ]);
8
+ const FIELD_ORDER = [
9
+ "author", "editor", "title", "journal", "booktitle", "year", "month", "volume", "number", "pages",
10
+ "publisher", "address", "school", "institution", "edition", "series", "issn", "isbn", "doi", "url", "abstract", "keywords", "note",
11
+ ];
12
+ /* ---------- small helpers ---------- */
13
+ export function squash(s) {
14
+ return s.replace(/\s+/g, " ").trim();
15
+ }
16
+ /** Remove BibTeX braces and a few common escapes so the text reads normally. */
17
+ export function plain(s) {
18
+ return squash(s
19
+ .replace(/\\([&%$#_])/g, "$1")
20
+ .replace(/---/g, "—")
21
+ .replace(/[{}]/g, ""));
22
+ }
23
+ export function cleanDoi(raw) {
24
+ return squash(raw)
25
+ .replace(/^(https?:\/\/)?(dx\.)?doi\.org\//i, "")
26
+ .replace(/^doi:\s*/i, "")
27
+ .trim();
28
+ }
29
+ export function ascii(s) {
30
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
31
+ }
32
+ /* ---------- names ---------- */
33
+ function splitTopLevel(s, sep) {
34
+ // Split on a separator but never inside braces.
35
+ const parts = [];
36
+ let depth = 0;
37
+ let cur = "";
38
+ for (let i = 0; i < s.length; i++) {
39
+ const ch = s[i];
40
+ if (ch === "{")
41
+ depth++;
42
+ else if (ch === "}")
43
+ depth = Math.max(0, depth - 1);
44
+ if (depth === 0) {
45
+ const m = s.slice(i).match(sep);
46
+ if (m && m.index === 0) {
47
+ parts.push(cur);
48
+ cur = "";
49
+ i += m[0].length - 1;
50
+ continue;
51
+ }
52
+ }
53
+ cur += ch;
54
+ }
55
+ parts.push(cur);
56
+ return parts.map(squash).filter(Boolean);
57
+ }
58
+ export function parseNames(raw) {
59
+ if (!raw)
60
+ return [];
61
+ return splitTopLevel(raw, /^\s+and\s+/i).map((n) => {
62
+ if (/^\{[^{}]*\}$/.test(n))
63
+ return { family: n.slice(1, -1), given: "", literal: true };
64
+ const comma = splitTopLevel(n, /^,/);
65
+ if (comma.length >= 2)
66
+ return { family: comma[0], given: comma.slice(1).join(" ") };
67
+ const words = n.split(" ");
68
+ if (words.length === 1)
69
+ return { family: words[0], given: "" };
70
+ // Lower case particles like "van" or "de" belong to the family name.
71
+ let i = words.length - 1;
72
+ while (i > 1 && /^[a-z]/.test(words[i - 1]))
73
+ i--;
74
+ return { family: words.slice(i).join(" "), given: words.slice(0, i).join(" ") };
75
+ });
76
+ }
77
+ export function formatNames(names) {
78
+ return names.map((n) => (n.literal ? `{${n.family}}` : n.given ? `${n.family}, ${n.given}` : n.family)).join(" and ");
79
+ }
80
+ /* ---------- format detection ---------- */
81
+ export function detectFormat(text) {
82
+ const t = text.trim();
83
+ if (/^[\[{]/.test(t))
84
+ return "csl_json";
85
+ if (/^TY {2}- /m.test(t))
86
+ return "ris";
87
+ if (/@\s*\w+\s*[{(]/.test(t))
88
+ return "bibtex";
89
+ throw new Error("Could not tell the format. Pass format as bibtex, ris or csl_json.");
90
+ }
91
+ /* ---------- entry normalizing ---------- */
92
+ const TYPE_ALIASES = {
93
+ conference: "inproceedings",
94
+ electronic: "online",
95
+ www: "online",
96
+ report: "techreport",
97
+ thesis: "phdthesis",
98
+ };
99
+ function normalizeEntry(e) {
100
+ const fields = {};
101
+ for (const [k, v] of Object.entries(e.fields)) {
102
+ const val = squash(v);
103
+ if (val)
104
+ fields[k.toLowerCase()] = val;
105
+ }
106
+ if (fields.doi)
107
+ fields.doi = cleanDoi(fields.doi);
108
+ if (!fields.year && fields.date) {
109
+ const y = fields.date.match(/\b(\d{4})\b/);
110
+ if (y)
111
+ fields.year = y[1];
112
+ }
113
+ if (!fields.journal && fields.journaltitle)
114
+ fields.journal = fields.journaltitle;
115
+ delete fields.journaltitle;
116
+ if (fields.month && MONTHS[fields.month.toLowerCase()])
117
+ fields.month = MONTHS[fields.month.toLowerCase()];
118
+ const type = e.type.toLowerCase();
119
+ return { type: TYPE_ALIASES[type] ?? type, key: e.key.trim(), fields };
120
+ }
121
+ /* ---------- BibTeX ---------- */
122
+ export function parseBibtex(text) {
123
+ const entries = [];
124
+ const macros = {};
125
+ let i = 0;
126
+ const n = text.length;
127
+ const skipWs = () => {
128
+ while (i < n && /\s/.test(text[i]))
129
+ i++;
130
+ };
131
+ const readBraced = () => {
132
+ let depth = 0;
133
+ const start = i + 1;
134
+ for (; i < n; i++) {
135
+ if (text[i] === "{")
136
+ depth++;
137
+ else if (text[i] === "}" && --depth === 0) {
138
+ const out = text.slice(start, i);
139
+ i++;
140
+ return out;
141
+ }
142
+ }
143
+ return text.slice(start);
144
+ };
145
+ const readQuoted = () => {
146
+ let depth = 0;
147
+ const start = i + 1;
148
+ for (i = i + 1; i < n; i++) {
149
+ if (text[i] === "{")
150
+ depth++;
151
+ else if (text[i] === "}")
152
+ depth--;
153
+ else if (text[i] === '"' && depth <= 0 && text[i - 1] !== "\\") {
154
+ const out = text.slice(start, i);
155
+ i++;
156
+ return out;
157
+ }
158
+ }
159
+ return text.slice(start);
160
+ };
161
+ const readValue = () => {
162
+ let out = "";
163
+ for (;;) {
164
+ skipWs();
165
+ if (text[i] === "{")
166
+ out += readBraced();
167
+ else if (text[i] === '"')
168
+ out += readQuoted();
169
+ else {
170
+ const m = text.slice(i).match(/^[^\s,#={}()"]+/);
171
+ if (!m)
172
+ break;
173
+ i += m[0].length;
174
+ const lower = m[0].toLowerCase();
175
+ out += macros[lower] ?? (MONTHS[lower] ? lower : m[0]);
176
+ }
177
+ skipWs();
178
+ if (text[i] === "#")
179
+ i++;
180
+ else
181
+ break;
182
+ }
183
+ return out;
184
+ };
185
+ while (i < n) {
186
+ const at = text.indexOf("@", i);
187
+ if (at < 0)
188
+ break;
189
+ i = at + 1;
190
+ const head = text.slice(i).match(/^\s*([A-Za-z]+)\s*([{(])/);
191
+ if (!head)
192
+ continue;
193
+ const type = head[1].toLowerCase();
194
+ i += head[0].length;
195
+ const close = head[2] === "{" ? "}" : ")";
196
+ if (type === "comment" || type === "preamble") {
197
+ i--;
198
+ if (head[2] === "{")
199
+ readBraced();
200
+ continue;
201
+ }
202
+ if (type === "string") {
203
+ skipWs();
204
+ const name = text.slice(i).match(/^[^\s=]+/);
205
+ if (name) {
206
+ i += name[0].length;
207
+ skipWs();
208
+ if (text[i] === "=") {
209
+ i++;
210
+ macros[name[0].toLowerCase()] = readValue();
211
+ }
212
+ }
213
+ skipWs();
214
+ if (text[i] === close)
215
+ i++;
216
+ continue;
217
+ }
218
+ skipWs();
219
+ const keyM = text.slice(i).match(/^[^,\s{}]*/);
220
+ const key = keyM ? keyM[0] : "";
221
+ i += key.length;
222
+ const fields = {};
223
+ for (;;) {
224
+ skipWs();
225
+ if (i >= n)
226
+ break;
227
+ if (text[i] === ",") {
228
+ i++;
229
+ continue;
230
+ }
231
+ if (text[i] === close) {
232
+ i++;
233
+ break;
234
+ }
235
+ const nameM = text.slice(i).match(/^[A-Za-z][\w:.\-]*/);
236
+ if (!nameM) {
237
+ i++;
238
+ continue;
239
+ }
240
+ i += nameM[0].length;
241
+ skipWs();
242
+ if (text[i] !== "=")
243
+ continue;
244
+ i++;
245
+ fields[nameM[0].toLowerCase()] = readValue();
246
+ }
247
+ entries.push(normalizeEntry({ type, key, fields }));
248
+ }
249
+ return entries;
250
+ }
251
+ function bibtexValue(v) {
252
+ return `{${v}}`;
253
+ }
254
+ function normalizePages(p) {
255
+ const m = p.match(/^(\w+)\s*[-–—]+\s*(\w+)$/);
256
+ return m ? `${m[1]}--${m[2]}` : p;
257
+ }
258
+ export function toBibtex(entries) {
259
+ return entries
260
+ .map((e) => {
261
+ const names = Object.keys(e.fields).sort((a, b) => {
262
+ const ia = FIELD_ORDER.indexOf(a);
263
+ const ib = FIELD_ORDER.indexOf(b);
264
+ return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
265
+ });
266
+ const lines = names.map((k) => ` ${k} = ${bibtexValue(k === "pages" ? normalizePages(e.fields[k]) : e.fields[k])},`);
267
+ return `@${e.type}{${e.key},\n${lines.join("\n")}\n}`;
268
+ })
269
+ .join("\n\n");
270
+ }
271
+ /* ---------- RIS ---------- */
272
+ const RIS_TYPES = {
273
+ JOUR: "article", BOOK: "book", CHAP: "incollection", CONF: "inproceedings", CPAPER: "inproceedings",
274
+ THES: "phdthesis", RPRT: "techreport", ELEC: "online", ICOMM: "online", UNPB: "unpublished", GEN: "misc", MGZN: "article",
275
+ };
276
+ const RIS_OUT = {
277
+ article: "JOUR", book: "BOOK", incollection: "CHAP", inbook: "CHAP", inproceedings: "CPAPER", phdthesis: "THES",
278
+ mastersthesis: "THES", techreport: "RPRT", online: "ELEC", unpublished: "UNPB", misc: "GEN",
279
+ };
280
+ export function parseRis(text) {
281
+ const entries = [];
282
+ let tags = [];
283
+ const flush = () => {
284
+ if (!tags.length)
285
+ return;
286
+ const get = (...t) => tags.find(([k]) => t.includes(k))?.[1];
287
+ const all = (...t) => tags.filter(([k]) => t.includes(k)).map(([, v]) => v);
288
+ const ty = get("TY") ?? "GEN";
289
+ const type = RIS_TYPES[ty] ?? "misc";
290
+ const fields = {};
291
+ const authors = all("AU", "A1").map((a) => formatNames(parseNames(a)));
292
+ if (authors.length)
293
+ fields.author = authors.join(" and ");
294
+ const editors = all("ED", "A2");
295
+ if (editors.length)
296
+ fields.editor = editors.join(" and ");
297
+ fields.title = get("TI", "T1") ?? "";
298
+ const container = get("T2", "JO", "JF", "JA", "BT") ?? "";
299
+ if (container)
300
+ fields[type === "article" ? "journal" : type === "book" ? "series" : "booktitle"] = container;
301
+ const py = get("PY", "Y1", "DA") ?? "";
302
+ fields.year = py.match(/\d{4}/)?.[0] ?? "";
303
+ fields.volume = get("VL") ?? "";
304
+ fields.number = get("IS") ?? "";
305
+ const sp = get("SP");
306
+ const ep = get("EP");
307
+ fields.pages = sp && ep ? `${sp}--${ep}` : sp ?? "";
308
+ fields.publisher = get("PB") ?? "";
309
+ fields.address = get("CY") ?? "";
310
+ fields.doi = get("DO") ?? "";
311
+ fields.url = get("UR", "L1") ?? "";
312
+ fields.abstract = get("AB", "N2") ?? "";
313
+ fields.keywords = all("KW").join(", ");
314
+ const sn = get("SN") ?? "";
315
+ if (sn)
316
+ fields[type === "book" ? "isbn" : "issn"] = sn;
317
+ entries.push(normalizeEntry({ type, key: get("ID") ?? "", fields }));
318
+ tags = [];
319
+ };
320
+ for (const line of text.split(/\r?\n/)) {
321
+ const m = line.match(/^([A-Z][A-Z0-9])\s{2}-\s?(.*)$/);
322
+ if (!m) {
323
+ // A line that does not start with a tag continues the last value.
324
+ if (tags.length && line.trim())
325
+ tags[tags.length - 1][1] += " " + line.trim();
326
+ continue;
327
+ }
328
+ if (m[1] === "ER")
329
+ flush();
330
+ else if (m[1] === "TY" && tags.length) {
331
+ flush();
332
+ tags.push([m[1], m[2].trim()]);
333
+ }
334
+ else
335
+ tags.push([m[1], m[2].trim()]);
336
+ }
337
+ flush();
338
+ return entries;
339
+ }
340
+ export function toRis(entries) {
341
+ return entries
342
+ .map((e) => {
343
+ const f = e.fields;
344
+ const out = [`TY - ${RIS_OUT[e.type] ?? "GEN"}`];
345
+ if (e.key)
346
+ out.push(`ID - ${e.key}`);
347
+ for (const n of parseNames(f.author))
348
+ out.push(`AU - ${plain(n.literal ? n.family : n.given ? `${n.family}, ${n.given}` : n.family)}`);
349
+ for (const n of parseNames(f.editor))
350
+ out.push(`ED - ${plain(n.given ? `${n.family}, ${n.given}` : n.family)}`);
351
+ if (f.title)
352
+ out.push(`TI - ${plain(f.title)}`);
353
+ const container = f.journal ?? f.booktitle;
354
+ if (container)
355
+ out.push(`T2 - ${plain(container)}`);
356
+ if (f.year)
357
+ out.push(`PY - ${f.year}`);
358
+ if (f.volume)
359
+ out.push(`VL - ${f.volume}`);
360
+ if (f.number)
361
+ out.push(`IS - ${f.number}`);
362
+ if (f.pages) {
363
+ const m = normalizePages(f.pages).match(/^(\w+)--(\w+)$/);
364
+ if (m)
365
+ out.push(`SP - ${m[1]}`, `EP - ${m[2]}`);
366
+ else
367
+ out.push(`SP - ${f.pages}`);
368
+ }
369
+ if (f.publisher)
370
+ out.push(`PB - ${plain(f.publisher)}`);
371
+ if (f.address)
372
+ out.push(`CY - ${plain(f.address)}`);
373
+ if (f.doi)
374
+ out.push(`DO - ${f.doi}`);
375
+ if (f.url)
376
+ out.push(`UR - ${f.url}`);
377
+ if (f.abstract)
378
+ out.push(`AB - ${plain(f.abstract)}`);
379
+ if (f.keywords)
380
+ for (const k of f.keywords.split(/[,;]/).map(squash).filter(Boolean))
381
+ out.push(`KW - ${k}`);
382
+ const sn = f.issn ?? f.isbn;
383
+ if (sn)
384
+ out.push(`SN - ${sn}`);
385
+ out.push("ER - ");
386
+ return out.join("\n");
387
+ })
388
+ .join("\n\n");
389
+ }
390
+ /* ---------- CSL JSON ---------- */
391
+ const CSL_TYPES = {
392
+ "article-journal": "article", "article-magazine": "article", "article-newspaper": "article", article: "article",
393
+ book: "book", chapter: "incollection", "paper-conference": "inproceedings", thesis: "phdthesis",
394
+ report: "techreport", webpage: "online", "post-weblog": "online", manuscript: "unpublished", dataset: "misc",
395
+ };
396
+ const CSL_OUT = {
397
+ article: "article-journal", book: "book", incollection: "chapter", inbook: "chapter", inproceedings: "paper-conference",
398
+ phdthesis: "thesis", mastersthesis: "thesis", techreport: "report", online: "webpage", unpublished: "manuscript", misc: "document",
399
+ };
400
+ export function parseCslJson(text) {
401
+ let data;
402
+ try {
403
+ data = JSON.parse(text);
404
+ }
405
+ catch (e) {
406
+ throw new Error(`The CSL JSON could not be read: ${e.message}`);
407
+ }
408
+ const items = Array.isArray(data) ? data : data && typeof data === "object" && "items" in data && Array.isArray(data.items) ? data.items : [data];
409
+ return items
410
+ .filter((it) => it && typeof it === "object")
411
+ .map((it) => {
412
+ const type = CSL_TYPES[String(it.type ?? "")] ?? "misc";
413
+ const names = (list) => Array.isArray(list)
414
+ ? formatNames(list.map((a) => a.literal ? { family: String(a.literal), given: "", literal: true } : { family: String(a.family ?? ""), given: String(a.given ?? "") }))
415
+ : "";
416
+ const dp = it.issued?.["date-parts"]?.[0] ?? [];
417
+ const container = it["container-title"] ? String(it["container-title"]) : "";
418
+ const fields = {
419
+ author: names(it.author),
420
+ editor: names(it.editor),
421
+ title: it.title ? String(it.title) : "",
422
+ year: dp[0] ? String(dp[0]) : it.issued?.literal?.match?.(/\d{4}/)?.[0] ?? "",
423
+ volume: it.volume != null ? String(it.volume) : "",
424
+ number: it.issue != null ? String(it.issue) : "",
425
+ pages: it.page ? String(it.page) : "",
426
+ publisher: it.publisher ? String(it.publisher) : "",
427
+ address: it["publisher-place"] ? String(it["publisher-place"]) : "",
428
+ doi: it.DOI ? String(it.DOI) : "",
429
+ url: it.URL ? String(it.URL) : "",
430
+ abstract: it.abstract ? String(it.abstract) : "",
431
+ issn: it.ISSN ? String(it.ISSN) : "",
432
+ isbn: it.ISBN ? String(it.ISBN) : "",
433
+ };
434
+ if (container)
435
+ fields[type === "article" ? "journal" : "booktitle"] = container;
436
+ return normalizeEntry({ type, key: it.id != null ? String(it.id) : "", fields });
437
+ });
438
+ }
439
+ export function toCslJson(entries) {
440
+ const items = entries.map((e) => {
441
+ const f = e.fields;
442
+ const names = (raw) => parseNames(raw).map((n) => (n.literal ? { literal: plain(n.family) } : { family: plain(n.family), given: plain(n.given) }));
443
+ const item = { id: e.key, type: CSL_OUT[e.type] ?? "document" };
444
+ if (f.author)
445
+ item.author = names(f.author);
446
+ if (f.editor)
447
+ item.editor = names(f.editor);
448
+ if (f.title)
449
+ item.title = plain(f.title);
450
+ const container = f.journal ?? f.booktitle;
451
+ if (container)
452
+ item["container-title"] = plain(container);
453
+ if (f.year && /^\d{4}$/.test(f.year))
454
+ item.issued = { "date-parts": [[Number(f.year)]] };
455
+ if (f.volume)
456
+ item.volume = f.volume;
457
+ if (f.number)
458
+ item.issue = f.number;
459
+ if (f.pages)
460
+ item.page = normalizePages(f.pages).replace("--", "-");
461
+ if (f.publisher)
462
+ item.publisher = plain(f.publisher);
463
+ if (f.address)
464
+ item["publisher-place"] = plain(f.address);
465
+ if (f.doi)
466
+ item.DOI = f.doi;
467
+ if (f.url)
468
+ item.URL = f.url;
469
+ if (f.abstract)
470
+ item.abstract = plain(f.abstract);
471
+ if (f.issn)
472
+ item.ISSN = f.issn;
473
+ if (f.isbn)
474
+ item.ISBN = f.isbn;
475
+ return item;
476
+ });
477
+ return JSON.stringify(items, null, 2);
478
+ }
479
+ /* ---------- plain text ---------- */
480
+ function initials(given) {
481
+ return given
482
+ .split(/[\s]+/)
483
+ .filter(Boolean)
484
+ .map((w) => (w.includes("-") ? w.split("-").map((p) => `${p[0].toUpperCase()}.`).join("-") : `${w[0].toUpperCase()}.`))
485
+ .join(" ");
486
+ }
487
+ export function toText(entries) {
488
+ return entries
489
+ .map((e) => {
490
+ const f = e.fields;
491
+ const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${initials(plain(n.given))}`));
492
+ let who = "";
493
+ if (names.length === 1)
494
+ who = names[0];
495
+ else if (names.length > 1)
496
+ who = `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`;
497
+ const parts = [];
498
+ if (who)
499
+ parts.push(who.endsWith(".") ? who : `${who}.`);
500
+ parts.push(`(${f.year ?? "n.d."}).`);
501
+ if (f.title)
502
+ parts.push(/[.?!]$/.test(plain(f.title)) ? plain(f.title) : `${plain(f.title)}.`);
503
+ const container = f.journal ?? f.booktitle;
504
+ if (container) {
505
+ let c = plain(container);
506
+ if (f.volume)
507
+ c += `, ${f.volume}`;
508
+ if (f.number)
509
+ c += `(${f.number})`;
510
+ if (f.pages)
511
+ c += `, ${normalizePages(f.pages).replace("--", "-")}`;
512
+ parts.push(`${c}.`);
513
+ }
514
+ else if (f.publisher)
515
+ parts.push(`${plain(f.publisher)}.`);
516
+ if (f.doi)
517
+ parts.push(`https://doi.org/${f.doi}`);
518
+ else if (f.url)
519
+ parts.push(f.url);
520
+ return parts.join(" ");
521
+ })
522
+ .join("\n\n");
523
+ }
524
+ /* ---------- parse and write by name ---------- */
525
+ export function parseAny(text, format = "auto") {
526
+ if (!text.trim())
527
+ throw new Error("The text is empty. Paste a BibTeX, RIS or CSL JSON reference list.");
528
+ const fmt = format === "auto" ? detectFormat(text) : format;
529
+ const entries = fmt === "bibtex" ? parseBibtex(text) : fmt === "ris" ? parseRis(text) : parseCslJson(text);
530
+ // Give entries without a key one, so every entry can be named later.
531
+ const used = new Set(entries.map((e) => e.key).filter(Boolean));
532
+ for (const e of entries) {
533
+ if (!e.key) {
534
+ e.key = uniqueKey(makeKey(e, { titleWord: true }), used);
535
+ used.add(e.key);
536
+ }
537
+ }
538
+ return { format: fmt, entries };
539
+ }
540
+ export function serialize(entries, format) {
541
+ switch (format) {
542
+ case "bibtex":
543
+ return toBibtex(entries);
544
+ case "ris":
545
+ return toRis(entries);
546
+ case "csl_json":
547
+ return toCslJson(entries);
548
+ default:
549
+ return toText(entries);
550
+ }
551
+ }
552
+ /* ---------- cite keys ---------- */
553
+ export function makeKey(e, opts) {
554
+ const first = parseNames(e.fields.author)[0] ?? parseNames(e.fields.editor)[0];
555
+ let family = first ? plain(first.family).split(" ").pop() ?? "" : "";
556
+ family = ascii(family).toLowerCase().replace(/[^a-z0-9]/g, "");
557
+ if (!family)
558
+ family = "anon";
559
+ const year = /^\d{4}$/.test(e.fields.year ?? "") ? e.fields.year : "nd";
560
+ let word = "";
561
+ if (opts.titleWord && e.fields.title) {
562
+ const words = ascii(plain(e.fields.title))
563
+ .toLowerCase()
564
+ .split(/[^a-z0-9]+/)
565
+ .filter(Boolean);
566
+ word = words.find((w) => !STOP_WORDS.has(w)) ?? words[0] ?? "";
567
+ }
568
+ return `${family}${year}${word}`;
569
+ }
570
+ export function uniqueKey(base, used) {
571
+ if (!used.has(base))
572
+ return base;
573
+ for (let n = 0;; n++) {
574
+ const suffix = n < 26 ? String.fromCharCode(97 + n) : `${n}`;
575
+ if (!used.has(base + suffix))
576
+ return base + suffix;
577
+ }
578
+ }
@@ -0,0 +1,25 @@
1
+ export async function runTool(tool, args, api) {
2
+ try {
3
+ const out = await tool.run(args, api);
4
+ return { content: [{ type: "text", text: out.text }], structuredContent: out.structured };
5
+ }
6
+ catch (err) {
7
+ // Pass the message through as it was written, so the caller can act on it.
8
+ const msg = err instanceof Error ? err.message : String(err);
9
+ return { content: [{ type: "text", text: msg }], isError: true };
10
+ }
11
+ }
12
+ export function registerTools(server, tools, api) {
13
+ for (const tool of tools) {
14
+ server.registerTool(tool.name, {
15
+ title: tool.title,
16
+ description: tool.description,
17
+ inputSchema: tool.inputSchema,
18
+ outputSchema: tool.outputSchema,
19
+ annotations: { readOnlyHint: true, openWorldHint: false },
20
+ }, async (args) => runTool(tool, args, api));
21
+ }
22
+ }
23
+ export function bullets(lines, empty = "Nothing to show.") {
24
+ return lines.length ? lines.map((l) => `- ${l}`).join("\n") : empty;
25
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer, VERSION } from "./server.js";
4
+ function usage() {
5
+ console.log(`bibtex-reference-manager-mcp ${VERSION}
6
+
7
+ Clean, convert, dedupe and format BibTeX, RIS and CSL JSON reference lists for AI apps (MCP). Works offline.
8
+
9
+ Add it to your AI app's MCP config with command "npx" and args ["-y", "bibtex-reference-manager-mcp"].
10
+
11
+ --help, --version`);
12
+ }
13
+ async function main() {
14
+ const cmd = process.argv[2];
15
+ if (cmd === "--help" || cmd === "-h")
16
+ return usage();
17
+ if (cmd === "--version" || cmd === "-v")
18
+ return console.log(VERSION);
19
+ await createServer().connect(new StdioServerTransport());
20
+ console.error(`bibtex-reference-manager-mcp ${VERSION} running`);
21
+ }
22
+ main().catch((e) => {
23
+ console.error(e);
24
+ process.exit(1);
25
+ });
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerTools } from "./core/tools.js";
3
+ import { tools } from "./tools.js";
4
+ export const VERSION = "0.1.0";
5
+ export function createServer() {
6
+ const server = new McpServer({ name: "bibtex-reference-manager-mcp", version: VERSION });
7
+ registerTools(server, tools, null);
8
+ return server;
9
+ }
@@ -0,0 +1,180 @@
1
+ import { z } from "zod";
2
+ import { bullets } from "./core/tools.js";
3
+ import { findDuplicates, lintEntries } from "./analyze.js";
4
+ import { makeKey, parseAny, parseNames, plain, serialize, toBibtex, uniqueKey } from "./bib.js";
5
+ const text = z.string().min(1).describe("The reference list as text: BibTeX, RIS or CSL JSON.");
6
+ const format = z
7
+ .enum(["auto", "bibtex", "ris", "csl_json"])
8
+ .optional()
9
+ .describe("The format of the text. Defaults to auto, which looks at the text to decide.");
10
+ const entryShape = z.object({
11
+ key: z.string(),
12
+ type: z.string().describe("The BibTeX entry type, like article or book."),
13
+ authors: z.array(z.string()).describe("Authors as \"Family, Given\"."),
14
+ fields: z.record(z.string(), z.string()).describe("Every field of the entry, with lower case names."),
15
+ });
16
+ function describeEntry(e) {
17
+ return {
18
+ key: e.key,
19
+ type: e.type,
20
+ authors: parseNames(e.fields.author).map((n) => (n.literal ? plain(n.family) : n.given ? `${n.family}, ${n.given}` : n.family)),
21
+ fields: e.fields,
22
+ };
23
+ }
24
+ function plural(n, word) {
25
+ if (n === 1)
26
+ return `${n} ${word}`;
27
+ return `${n} ${word.endsWith("y") ? `${word.slice(0, -1)}ies` : `${word}s`}`;
28
+ }
29
+ const parseBibliography = {
30
+ name: "parse_bibliography",
31
+ title: "Parse a bibliography",
32
+ description: "Read BibTeX, RIS or CSL JSON text and return the entries in one normalized shape: lower case field names, tidy whitespace, bare DOIs, the year filled in from a date, and authors split into \"Family, Given\". Nothing is sent anywhere.",
33
+ inputSchema: { text, format },
34
+ outputSchema: {
35
+ format: z.string().describe("The format that was read: bibtex, ris or csl_json."),
36
+ count: z.number().describe("How many entries were found."),
37
+ entries: z.array(entryShape),
38
+ },
39
+ run: async (a) => {
40
+ const { format: fmt, entries } = parseAny(a.text, a.format ?? "auto");
41
+ const lines = entries.map((e) => `${e.key} (${e.type}): ${plain(e.fields.title ?? "no title")}${e.fields.year ? `, ${e.fields.year}` : ""}`);
42
+ return {
43
+ text: `Read ${plural(entries.length, "entry")} as ${fmt}.\n${bullets(lines, "No entries were found.")}`,
44
+ structured: { format: fmt, count: entries.length, entries: entries.map(describeEntry) },
45
+ };
46
+ },
47
+ };
48
+ const convertBibliography = {
49
+ name: "convert_bibliography",
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.",
52
+ inputSchema: {
53
+ text,
54
+ to: z.enum(["bibtex", "ris", "csl_json", "text"]).describe("The format to write."),
55
+ from: format,
56
+ },
57
+ outputSchema: {
58
+ from: z.string().describe("The format that was read."),
59
+ to: z.string().describe("The format that was written."),
60
+ count: z.number().describe("How many entries were converted."),
61
+ output: z.string().describe("The converted reference list."),
62
+ },
63
+ run: async (a) => {
64
+ const { format: fmt, entries } = parseAny(a.text, a.from ?? "auto");
65
+ if (!entries.length)
66
+ throw new Error("No entries were found in the text, so there is nothing to convert.");
67
+ const output = serialize(entries, a.to);
68
+ return {
69
+ text: `Converted ${plural(entries.length, "entry")} from ${fmt} to ${a.to}.\n\n${output}`,
70
+ structured: { from: fmt, to: a.to, count: entries.length, output },
71
+ };
72
+ },
73
+ };
74
+ const dedupeEntries = {
75
+ name: "dedupe_entries",
76
+ title: "Find duplicate entries",
77
+ description: "Find duplicate references by matching DOI, by the same cite key, and by similar titles (the years must not clash). Each group says which entry to keep, the one with the most fields, and which to remove. Also returns the list as BibTeX with the extra copies dropped.",
78
+ inputSchema: {
79
+ text,
80
+ format,
81
+ title_threshold: z
82
+ .number()
83
+ .min(0.5)
84
+ .max(1)
85
+ .optional()
86
+ .describe("How alike two titles must be to count as the same, from 0.5 to 1. Defaults to 0.9."),
87
+ },
88
+ outputSchema: {
89
+ total: z.number().describe("How many entries were read."),
90
+ unique: z.number().describe("How many entries are left once the duplicates are dropped."),
91
+ groups: z.array(z.object({
92
+ keys: z.array(z.string()),
93
+ reasons: z.array(z.enum(["doi", "key", "title"])),
94
+ keep: z.string(),
95
+ remove: z.array(z.string()),
96
+ })),
97
+ deduplicated_bibtex: z.string().describe("The list as BibTeX without the removed copies."),
98
+ },
99
+ run: async (a) => {
100
+ const { entries } = parseAny(a.text, a.format ?? "auto");
101
+ const groups = findDuplicates(entries, a.title_threshold ?? 0.9);
102
+ const drop = new Set();
103
+ for (const g of groups) {
104
+ for (const i of g.indexes)
105
+ if (i !== g.keepIndex)
106
+ drop.add(i);
107
+ }
108
+ const kept = entries.filter((_, i) => !drop.has(i));
109
+ const lines = groups.map((g) => `${g.keys.join(", ")} match by ${g.reasons.join(" and ")}. Keep ${g.keep}.`);
110
+ return {
111
+ text: `Read ${plural(entries.length, "entry")} and found ${plural(groups.length, "duplicate group")}. ${kept.length} would be left.\n${bullets(lines, "No duplicates found.")}`,
112
+ structured: {
113
+ total: entries.length,
114
+ unique: kept.length,
115
+ groups: groups.map(({ keys, reasons, keep, remove }) => ({ keys, reasons, keep, remove })),
116
+ deduplicated_bibtex: toBibtex(kept),
117
+ },
118
+ };
119
+ },
120
+ };
121
+ const lintEntriesTool = {
122
+ name: "lint_entries",
123
+ 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.",
125
+ inputSchema: { text, format },
126
+ outputSchema: {
127
+ count: z.number().describe("How many entries were checked."),
128
+ errors: z.number(),
129
+ warnings: z.number(),
130
+ issues: z.array(z.object({
131
+ key: z.string(),
132
+ rule: z.enum(["missing-field", "bad-pages", "uncapitalized-title", "invalid-doi", "bad-year", "duplicate-key"]),
133
+ severity: z.enum(["error", "warning"]),
134
+ field: z.string().optional(),
135
+ message: z.string(),
136
+ })),
137
+ },
138
+ run: async (a) => {
139
+ const { entries } = parseAny(a.text, a.format ?? "auto");
140
+ const issues = lintEntries(entries);
141
+ const errors = issues.filter((i) => i.severity === "error").length;
142
+ const warnings = issues.length - errors;
143
+ const lines = issues.map((i) => `${i.key}: [${i.rule}] ${i.message}`);
144
+ return {
145
+ text: `Checked ${plural(entries.length, "entry")}: ${plural(errors, "error")}, ${plural(warnings, "warning")}.\n${bullets(lines, "No problems found.")}`,
146
+ structured: { count: entries.length, errors, warnings, issues },
147
+ };
148
+ },
149
+ };
150
+ const generateCiteKeys = {
151
+ name: "generate_cite_keys",
152
+ title: "Generate cite keys",
153
+ description: "Create consistent cite keys from the first author's family name, the year and the first meaningful word of the title, like smith2024deep. Repeated keys get a letter added. Returns the old and new key for each entry and the list as BibTeX with the new keys.",
154
+ inputSchema: {
155
+ text,
156
+ format,
157
+ include_title_word: z.boolean().optional().describe("Add the first meaningful title word, so you get smith2024deep and not smith2024. Defaults to true."),
158
+ },
159
+ outputSchema: {
160
+ count: z.number(),
161
+ keys: z.array(z.object({ old_key: z.string(), new_key: z.string() })),
162
+ bibtex: z.string().describe("The list as BibTeX using the new keys."),
163
+ },
164
+ run: async (a) => {
165
+ const { entries } = parseAny(a.text, a.format ?? "auto");
166
+ const used = new Set();
167
+ const keys = entries.map((e) => {
168
+ const nk = uniqueKey(makeKey(e, { titleWord: a.include_title_word ?? true }), used);
169
+ used.add(nk);
170
+ return { old_key: e.key, new_key: nk };
171
+ });
172
+ const renamed = entries.map((e, i) => ({ ...e, key: keys[i].new_key }));
173
+ const lines = keys.map((k) => (k.old_key === k.new_key ? `${k.new_key} (unchanged)` : `${k.old_key} becomes ${k.new_key}`));
174
+ 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) },
177
+ };
178
+ },
179
+ };
180
+ export const tools = [parseBibliography, convertBibliography, dedupeEntries, lintEntriesTool, generateCiteKeys];
package/llms.txt ADDED
@@ -0,0 +1,57 @@
1
+ # BibTeX Reference Manager MCP
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.0.
4
+
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
+
7
+ ## Install
8
+
9
+ Command: `npx -y bibtex-reference-manager-mcp`
10
+
11
+ MCP config for Claude Desktop, Cursor and similar apps:
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "bibtex": {
17
+ "command": "npx",
18
+ "args": ["-y", "bibtex-reference-manager-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Needs Node.js 20 or newer.
25
+
26
+ ## Tools
27
+
28
+ All tools are read only, work offline and take `text` (the reference list). Most take an optional `format` of auto, bibtex, ris or csl_json. The default, auto, looks at the text.
29
+
30
+ - parse_bibliography: returns normalized entries (key, type, authors as "Family, Given", and all fields). Field names are lower case, DOIs are bare, and the year is filled in from a date when needed.
31
+ - convert_bibliography: takes `to` (bibtex, ris, csl_json or text) and optional `from`. Returns the converted list in `output`. BibTeX page ranges are written as 12--15.
32
+ - dedupe_entries: finds duplicate groups by DOI, same cite key (ignoring case) and title similarity. Optional `title_threshold` from 0.5 to 1, default 0.9. Titles are not matched when both entries have a year and the years differ. Each group gives `keep` (the entry with the most fields) and `remove`. Also returns `deduplicated_bibtex`.
33
+ - lint_entries: returns issues with `key`, `rule`, `severity`, `field` and `message`. Rules: missing-field, bad-pages, uncapitalized-title, invalid-doi, bad-year, duplicate-key.
34
+ - generate_cite_keys: returns old and new keys and `bibtex` with the new keys. Keys look like smith2024deep. Set `include_title_word` to false for smith2024. Repeated keys get a letter, like smith2024deepa.
35
+
36
+ ## Example prompts
37
+
38
+ - "Lint this .bib file and list what to fix."
39
+ - "Remove the duplicate references and give me the clean BibTeX."
40
+ - "Convert this RIS export to BibTeX and rename the keys."
41
+ - "Turn this CSL JSON into a plain text reference list."
42
+
43
+ ## FAQ
44
+
45
+ Does it look up DOIs online? No. It checks only that a DOI has the shape 10.xxxx/something.
46
+
47
+ Does it send data anywhere? No. It makes no network requests and keeps nothing between calls.
48
+
49
+ What happens with an empty list or text it cannot place? The tool returns an error that says so, and asks for the format to be passed.
50
+
51
+ Which entry types are checked for required fields? article, book, inproceedings, incollection, phdthesis, mastersthesis, techreport, unpublished, misc and online. Other types need a title.
52
+
53
+ ## Links
54
+
55
+ - Source: https://github.com/AnonymousOnyx22/bibtex-reference-manager-mcp
56
+ - npm: https://www.npmjs.com/package/bibtex-reference-manager-mcp
57
+ - License: MIT
package/package.json CHANGED
@@ -1,6 +1,90 @@
1
1
  {
2
2
  "name": "bibtex-reference-manager-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "BibTeX reference manager MCP server for AI apps. Ask your AI to parse, clean, convert, dedupe, lint and format BibTeX, RIS and CSL JSON reference lists, and to make consistent cite keys. Works offline with no account.",
5
+ "type": "module",
6
+ "main": "build/src/index.js",
7
+ "bin": {
8
+ "bibtex-reference-manager-mcp": "build/src/index.js"
9
+ },
10
+ "files": [
11
+ "build/src",
12
+ "README.md",
13
+ "llms.txt",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "devEngines": {
21
+ "runtime": {
22
+ "name": "node",
23
+ "version": "^22.12.0 || ^24.0.0 || >=26.0.0"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "tsc && node -e \"require('fs').chmodSync('build/src/index.js', 0o755)\"",
28
+ "test": "vitest run",
29
+ "start": "node build/src/index.js",
30
+ "prepublishOnly": "npm run build && npm test"
31
+ },
32
+ "keywords": [
33
+ "bibtex",
34
+ "bibtex reference manager",
35
+ "bibtex cleaner",
36
+ "bibtex converter",
37
+ "bib file",
38
+ "ris",
39
+ "ris converter",
40
+ "csl json",
41
+ "citation",
42
+ "citations",
43
+ "references",
44
+ "bibliography",
45
+ "reference manager",
46
+ "cite key",
47
+ "citation key",
48
+ "dedupe references",
49
+ "duplicate references",
50
+ "doi",
51
+ "doi validator",
52
+ "latex",
53
+ "zotero",
54
+ "endnote",
55
+ "mendeley",
56
+ "academic writing",
57
+ "thesis",
58
+ "mcp",
59
+ "mcp server",
60
+ "mcp-server",
61
+ "model-context-protocol",
62
+ "bibtex-reference-manager-mcp",
63
+ "student",
64
+ "students",
65
+ "university",
66
+ "education",
67
+ "claude",
68
+ "cursor"
69
+ ],
70
+ "author": "AnonymousOnyx22",
71
+ "license": "MIT",
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/AnonymousOnyx22/bibtex-reference-manager-mcp.git"
75
+ },
76
+ "homepage": "https://github.com/AnonymousOnyx22/bibtex-reference-manager-mcp#readme",
77
+ "bugs": {
78
+ "url": "https://github.com/AnonymousOnyx22/bibtex-reference-manager-mcp/issues"
79
+ },
80
+ "mcpName": "io.github.AnonymousOnyx22/bibtex-reference-manager-mcp",
81
+ "dependencies": {
82
+ "@modelcontextprotocol/sdk": "^1.32.1",
83
+ "zod": "^4.6.7"
84
+ },
85
+ "devDependencies": {
86
+ "@types/node": "^26.6.5",
87
+ "typescript": "^7.0.2",
88
+ "vitest": "^5.0.5"
89
+ }
90
+ }