bibtex-reference-manager-mcp 0.1.1 → 0.1.2

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - 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.
6
+
7
+ - `convert_bibliography` takes an optional `style` (`apa`, `mla` or `ieee`) for the plain text output, so a formatted reference list takes one call. Without it the output is the same as before.
8
+
3
9
  ## 0.1.1
4
10
 
5
11
  - The README now has a Common workflows section showing how the five tools work together in a typical thesis or cleaning workflow.
package/README.md CHANGED
@@ -139,9 +139,10 @@ The format is detected for you. If a file is not recognized, say which format it
139
139
  | Tool | What it does |
140
140
  | --- | --- |
141
141
  | `parse_bibliography` | Reads BibTeX, RIS or CSL JSON text and returns normalized entries: lower case field names, tidy spacing, bare DOIs, the year filled in from a date, and authors split into "Family, Given". |
142
- | `convert_bibliography` | Converts between BibTeX, RIS, CSL JSON and plain text. The input format is detected unless you pass `from`. Page ranges become `12--15` in BibTeX. |
142
+ | `convert_bibliography` | Converts between BibTeX, RIS, CSL JSON and plain text. The input format is detected unless you pass `from`. Page ranges become `12--15` in BibTeX. For plain text, pass `style` as `apa` (the default), `mla` or `ieee` 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
144
  | `lint_entries` | Flags missing required fields, bad page ranges, uncapitalized titles, invalid DOIs, odd years and repeated keys. |
145
+ | `fix_entries` | Applies the safe fixes from `lint_entries` and returns the cleaned BibTeX: double hyphen page ranges, capitalized titles and bare DOIs. It lists each change and does not guess missing fields. |
145
146
  | `generate_cite_keys` | Makes keys from the first author, the year and the first useful title word, like `smith2024deep`. Repeats get a letter, like `smith2024deepa`. |
146
147
 
147
148
  Every tool takes the reference list as `text`, and every tool returns both a readable answer and structured fields. All of them are read only. They never change a file, because you paste the text in and get the result back.
@@ -154,7 +155,7 @@ Every tool takes the reference list as `text`, and every tool returns both a rea
154
155
 
155
156
  ## Common workflows
156
157
 
157
- These show how the five tools fit together. All of them take the reference list as `text` and are read only.
158
+ These show how the six tools fit together. All of them take the reference list as `text` and are read only.
158
159
 
159
160
  ### Clean a Zotero or Mendeley export
160
161
 
@@ -173,7 +174,7 @@ These show how the five tools fit together. All of them take the reference list
173
174
  ### Check a finished .bib before you hand it in
174
175
 
175
176
  1. Run `parse_bibliography` to normalize.
176
- 2. Run `lint_entries` and fix what it flags.
177
+ 2. Run `lint_entries`, then `fix_entries` to apply the safe fixes it can make.
177
178
  3. Run `dedupe_entries` to make sure nothing is listed twice.
178
179
 
179
180
  ## Privacy
@@ -200,7 +201,7 @@ npm run build
200
201
  npm test
201
202
  ```
202
203
 
203
- The code is in `src`. `src/bib.ts` reads and writes the formats, `src/analyze.ts` finds duplicates and lint problems, and `src/tools.ts` defines the five tools.
204
+ The code is in `src`. `src/bib.ts` reads and writes the formats, `src/analyze.ts` finds duplicates and lint problems, and `src/tools.ts` defines the six tools.
204
205
 
205
206
  ## More free student MCP servers
206
207
 
@@ -167,3 +167,44 @@ export function lintEntries(entries) {
167
167
  }
168
168
  return issues;
169
169
  }
170
+ function capitalizeTitle(title) {
171
+ const raw = title.trim();
172
+ if (/^[a-z]/.test(raw))
173
+ return raw[0].toUpperCase() + raw.slice(1);
174
+ let depth = 0;
175
+ return raw
176
+ .split(/(\s+)/)
177
+ .map((word) => {
178
+ const inBraces = depth > 0 || word.startsWith("{");
179
+ for (const c of word)
180
+ depth += c === "{" ? 1 : c === "}" ? -1 : 0;
181
+ if (inBraces || SMALL_WORDS.has(word.toLowerCase()))
182
+ return word;
183
+ return word.replace(/^[a-z]/, (c) => c.toUpperCase());
184
+ })
185
+ .join("");
186
+ }
187
+ export function fixEntries(entries) {
188
+ const fixes = [];
189
+ const fixed = entries.map((e) => {
190
+ const f = { ...e.fields };
191
+ const set = (field, to) => {
192
+ if (to !== f[field])
193
+ fixes.push({ key: e.key, field, from: f[field], to });
194
+ f[field] = to;
195
+ };
196
+ if (f.pages && pageProblem(f.pages)?.startsWith("use a double hyphen")) {
197
+ set("pages", f.pages.trim().replace(/\s*[-–—]\s*/, "--"));
198
+ }
199
+ if (f.title && lintEntries([{ ...e, fields: f }]).some((i) => i.rule === "uncapitalized-title")) {
200
+ set("title", capitalizeTitle(f.title));
201
+ }
202
+ if (f.doi) {
203
+ const doi = cleanDoi(f.doi);
204
+ if (/^10\.\d{4,9}\/\S+$/.test(doi))
205
+ set("doi", doi);
206
+ }
207
+ return { ...e, fields: f };
208
+ });
209
+ return { entries: fixed, fixes };
210
+ }
package/build/src/bib.js CHANGED
@@ -510,9 +510,90 @@ function initials(given) {
510
510
  .map((w) => (w.includes("-") ? w.split("-").map((p) => `${p[0].toUpperCase()}.`).join("-") : `${w[0].toUpperCase()}.`))
511
511
  .join(" ");
512
512
  }
513
- export function toText(entries) {
513
+ function endWith(s) {
514
+ return /[.?!]$/.test(s) ? s : `${s}.`;
515
+ }
516
+ function mlaText(e) {
517
+ const f = e.fields;
518
+ const people = parseNames(f.author).map((n) => ({
519
+ first: n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${plain(n.given)}`,
520
+ last: n.literal || !n.given ? plain(n.family) : `${plain(n.given)} ${plain(n.family)}`,
521
+ }));
522
+ let who = "";
523
+ if (people.length === 1)
524
+ who = people[0].first;
525
+ else if (people.length === 2)
526
+ who = `${people[0].first}, and ${people[1].last}`;
527
+ else if (people.length > 2)
528
+ who = `${people[0].first}, et al`;
529
+ const parts = [];
530
+ if (who)
531
+ parts.push(endWith(who));
532
+ const container = f.journal ?? f.booktitle;
533
+ if (f.title)
534
+ parts.push(container ? `"${endWith(plain(f.title))}"` : endWith(plain(f.title)));
535
+ const bits = [];
536
+ if (container)
537
+ bits.push(plain(container));
538
+ else if (f.publisher)
539
+ bits.push(plain(f.publisher));
540
+ if (container && f.volume)
541
+ bits.push(`vol. ${f.volume}`);
542
+ if (container && f.number)
543
+ bits.push(`no. ${f.number}`);
544
+ if (f.year)
545
+ bits.push(f.year);
546
+ if (container && f.pages)
547
+ bits.push(`pp. ${normalizePages(f.pages).replace("--", "-")}`);
548
+ if (bits.length)
549
+ parts.push(`${bits.join(", ")}.`);
550
+ if (f.doi)
551
+ parts.push(`https://doi.org/${f.doi}.`);
552
+ else if (f.url)
553
+ parts.push(`${f.url}.`);
554
+ return parts.join(" ");
555
+ }
556
+ function ieeeText(e, index) {
557
+ const f = e.fields;
558
+ const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${initials(plain(n.given))} ${plain(n.family)}`));
559
+ let who = "";
560
+ if (names.length === 1)
561
+ who = names[0];
562
+ else if (names.length === 2)
563
+ who = `${names[0]} and ${names[1]}`;
564
+ else if (names.length > 2)
565
+ who = `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
566
+ const container = f.journal ?? f.booktitle;
567
+ const title = f.title ? plain(f.title).replace(/[.,]$/, "") : "";
568
+ const bits = [];
569
+ if (container)
570
+ bits.push(plain(container));
571
+ else if (f.publisher)
572
+ bits.push(plain(f.publisher));
573
+ if (container && f.volume)
574
+ bits.push(`vol. ${f.volume}`);
575
+ if (container && f.number)
576
+ bits.push(`no. ${f.number}`);
577
+ if (container && f.pages)
578
+ bits.push(`pp. ${normalizePages(f.pages).replace("--", "-")}`);
579
+ if (f.year)
580
+ bits.push(f.year);
581
+ const head = [who, container && title ? `"${title},"` : title].filter(Boolean).join(", ");
582
+ let body = container && title ? `${head} ${bits.join(", ")}` : [head, bits.join(", ")].filter(Boolean).join(", ");
583
+ body = endWith(body.trim());
584
+ if (f.doi)
585
+ body += ` doi: ${f.doi}.`;
586
+ else if (f.url)
587
+ body += ` ${f.url}`;
588
+ return `[${index + 1}] ${body}`;
589
+ }
590
+ export function toText(entries, style = "apa") {
514
591
  return entries
515
- .map((e) => {
592
+ .map((e, i) => {
593
+ if (style === "mla")
594
+ return mlaText(e);
595
+ if (style === "ieee")
596
+ return ieeeText(e, i);
516
597
  const f = e.fields;
517
598
  const names = parseNames(f.author).map((n) => (n.literal || !n.given ? plain(n.family) : `${plain(n.family)}, ${initials(plain(n.given))}`));
518
599
  let who = "";
@@ -563,7 +644,7 @@ export function parseAny(text, format = "auto") {
563
644
  }
564
645
  return { format: fmt, entries };
565
646
  }
566
- export function serialize(entries, format) {
647
+ export function serialize(entries, format, style = "apa") {
567
648
  switch (format) {
568
649
  case "bibtex":
569
650
  return toBibtex(entries);
@@ -572,7 +653,7 @@ export function serialize(entries, format) {
572
653
  case "csl_json":
573
654
  return toCslJson(entries);
574
655
  default:
575
- return toText(entries);
656
+ return toText(entries, style);
576
657
  }
577
658
  }
578
659
  /* ---------- cite keys ---------- */
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { registerTools } from "./core/tools.js";
3
3
  import { tools } from "./tools.js";
4
- export const VERSION = "0.1.1";
4
+ export const VERSION = "0.1.2";
5
5
  export function createServer() {
6
6
  const server = new McpServer({ name: "bibtex-reference-manager-mcp", version: VERSION });
7
7
  registerTools(server, tools, null);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { bullets } from "./core/tools.js";
3
- import { findDuplicates, lintEntries } from "./analyze.js";
3
+ import { findDuplicates, fixEntries, lintEntries } from "./analyze.js";
4
4
  import { makeKey, parseAny, parseNames, plain, serialize, toBibtex, uniqueKey } from "./bib.js";
5
5
  const text = z.string().min(1).describe("The reference list as text: BibTeX, RIS or CSL JSON.");
6
6
  const format = z
@@ -48,11 +48,15 @@ const parseBibliography = {
48
48
  const convertBibliography = {
49
49
  name: "convert_bibliography",
50
50
  title: "Convert a bibliography",
51
- description: "Convert a reference list between BibTeX, RIS, CSL JSON and plain text (a simple author, year, title style). The input format is detected unless you pass it. Page ranges, DOIs and names are cleaned on the way. Example: text is a BibTeX entry and to is \"ris\", and output is the same reference as RIS. Set from only to override detection.",
51
+ description: "Convert a reference list between BibTeX, RIS, CSL JSON and plain text (a simple reference list style, APA by default, or MLA 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.",
52
52
  inputSchema: {
53
53
  text,
54
54
  to: z.enum(["bibtex", "ris", "csl_json", "text"]).describe("The format to write."),
55
55
  from: format,
56
+ style: z
57
+ .enum(["apa", "mla", "ieee"])
58
+ .optional()
59
+ .describe("The citation style for the text output: apa, mla or ieee. Defaults to apa. Ignored for the other formats."),
56
60
  },
57
61
  outputSchema: {
58
62
  from: z.string().describe("The format that was read."),
@@ -64,7 +68,7 @@ const convertBibliography = {
64
68
  const { format: fmt, entries } = parseAny(a.text, a.from ?? "auto");
65
69
  if (!entries.length)
66
70
  throw new Error("No entries were found in the text, so there is nothing to convert.");
67
- const output = serialize(entries, a.to);
71
+ const output = serialize(entries, a.to, a.style ?? "apa");
68
72
  return {
69
73
  text: `Converted ${plural(entries.length, "entry")} from ${fmt} to ${a.to}.\n\n${output}`,
70
74
  structured: { from: fmt, to: a.to, count: entries.length, output },
@@ -147,6 +151,35 @@ const lintEntriesTool = {
147
151
  };
148
152
  },
149
153
  };
154
+ const fixEntriesTool = {
155
+ name: "fix_entries",
156
+ 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.",
158
+ inputSchema: { text, format },
159
+ outputSchema: {
160
+ count: z.number().describe("How many entries were read."),
161
+ fixed: z.number().describe("How many changes were made."),
162
+ fixes: z.array(z.object({
163
+ key: z.string(),
164
+ field: z.enum(["pages", "title", "doi"]),
165
+ from: z.string(),
166
+ to: z.string(),
167
+ })),
168
+ bibtex: z.string().describe("The cleaned list as BibTeX."),
169
+ },
170
+ run: async (a) => {
171
+ const { entries } = parseAny(a.text, a.format ?? "auto");
172
+ if (!entries.length)
173
+ throw new Error("No entries were found in the text, so there is nothing to fix.");
174
+ const { entries: cleaned, fixes } = fixEntries(entries);
175
+ const bibtex = toBibtex(cleaned);
176
+ const lines = fixes.map((f) => `${f.key}: ${f.field} "${f.from}" becomes "${f.to}"`);
177
+ return {
178
+ 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 },
180
+ };
181
+ },
182
+ };
150
183
  const generateCiteKeys = {
151
184
  name: "generate_cite_keys",
152
185
  title: "Generate cite keys",
@@ -177,4 +210,4 @@ const generateCiteKeys = {
177
210
  };
178
211
  },
179
212
  };
180
- export const tools = [parseBibliography, convertBibliography, dedupeEntries, lintEntriesTool, generateCiteKeys];
213
+ export const tools = [parseBibliography, convertBibliography, dedupeEntries, lintEntriesTool, fixEntriesTool, generateCiteKeys];
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # BibTeX Reference Manager MCP
2
2
 
3
- > A free, open source MCP server that cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists. It runs offline on the user's computer with no account and no API key. npm package: bibtex-reference-manager-mcp. Version 0.1.1.
3
+ > A free, open source MCP server that cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists. It runs offline on the user's computer with no account and no API key. npm package: bibtex-reference-manager-mcp. Version 0.1.2.
4
4
 
5
5
  Use this server when a user pastes or attaches a reference list and wants it read, converted, deduplicated, checked or renamed. Pass the list as the `text` argument. The tools do the work in code, so do not rewrite entries by hand when a tool can do it.
6
6
 
@@ -66,7 +66,7 @@ Example result:
66
66
  Notes: leave format out unless auto picks the wrong one. If the text cannot be read, the tool returns an error asking for the format to be passed.
67
67
 
68
68
  ### convert_bibliography
69
- Convert between BibTeX, RIS, CSL JSON and plain text (a simple author, year, title style). Input format is detected unless passed. Page ranges become 12--15 in BibTeX. DOIs and names are cleaned on the way.
69
+ Convert between BibTeX, RIS, CSL JSON and plain text. For plain text, pass style as apa (the default), mla 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.
70
70
 
71
71
  Inputs:
72
72
  - text (required): the reference list
@@ -130,6 +130,20 @@ Example issues:
130
130
 
131
131
  Pass the whole list so repeated keys can be found. A clean list returns no problems found.
132
132
 
133
+ ### fix_entries
134
+ Apply the safe fixes that lint_entries points to and return the cleaned list as BibTeX, plus a list of each change (key, field, from, to).
135
+
136
+ Inputs:
137
+ - text (required): the reference list
138
+ - format (optional): auto, bibtex, ris, or csl_json
139
+
140
+ Fixes:
141
+ - pages: a single hyphen range like 12-15 becomes 12--15
142
+ - title: a title that starts with a lower case letter gets a capital; a title with every word in lower case gets capitals on the main words. Text inside braces is left alone
143
+ - doi: a link or doi: prefix is removed, leaving the bare DOI
144
+
145
+ Not fixed: missing fields, backwards page ranges, bad years, repeated keys and DOIs that are not valid. Run lint_entries on the result to see what is left. The list is returned as text, no file is changed.
146
+
133
147
  ### generate_cite_keys
134
148
  Create consistent cite keys from the first author's family name, the year, and the first meaningful word of the title. Repeated keys get a letter added.
135
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bibtex-reference-manager-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",