@xevos117/mcp-zotero 2.0.2 → 2.0.4

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/README.md CHANGED
@@ -34,6 +34,10 @@ LLMs without filesystem access — including Claude Desktop, which connects to M
34
34
 
35
35
  Requires **Node.js 22 or newer**.
36
36
 
37
+ The server is listed in the official [MCP Registry](https://registry.modelcontextprotocol.io) as
38
+ `io.github.Xevos117/mcp-zotero` (starting with the first release after 2.0.2), so registry-aware clients can find and install it from
39
+ there; the configuration below works with any MCP client.
40
+
37
41
  1. Get your Zotero credentials:
38
42
 
39
43
  ```bash
@@ -160,7 +164,30 @@ To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of t
160
164
 
161
165
  ## Integration with Claude Desktop
162
166
 
163
- Add to your Claude Desktop configuration:
167
+ ### Install as a Claude Desktop extension (one click)
168
+
169
+ 1. Download `mcp-zotero.mcpb` from the [latest release](https://github.com/Xevos117/mcp-zotero/releases/latest)
170
+ (available from the first release after 2.0.2).
171
+ 2. Open the file with Claude Desktop (macOS or Windows; e.g. double-click it) and confirm in the installation dialog.
172
+ The bundle itself is not platform-specific (`manifest.json` lists macOS, Windows and Linux, and CI runs it on
173
+ Linux), so other MCPB-compatible clients can install it too.
174
+ 3. Fill in your **Zotero API key** and **Zotero user ID** (see [Setup](#setup)). Optional settings: library type and
175
+ group ID (for a group library), the email for Unpaywall (open-access PDF lookup) and which deletions are allowed
176
+ (default: none, see [Unsafe Operations](#unsafe-operations)).
177
+
178
+ The extension bundles the server and all its dependencies, so no npm install is needed. It needs Node.js 22 or newer
179
+ (`compatibility.runtimes.node` in the manifest): according to the MCPB documentation, Claude Desktop runs Node.js
180
+ extensions with the runtime that ships with the app. The API key is declared as a sensitive setting (masked
181
+ input, stored securely by the client). Deletions stay disabled unless you change *Deletions allowed* in the
182
+ extension settings.
183
+
184
+ The extension contains only the MCP server: the Claude skills in [`skills/`](skills/) (citation injection inside
185
+ the sandbox, scientific writing) are installed separately from their `.zip` files in the same release, as described
186
+ in [Claude Skill Setup](#claude-skill-setup-for-claudeai-projects-and-claude-desktop).
187
+
188
+ ### Manual configuration (any MCP client)
189
+
190
+ Add to your Claude Desktop configuration (or the equivalent file of another MCP client):
164
191
 
165
192
  ```json
166
193
  {
@@ -181,9 +208,13 @@ Add to your Claude Desktop configuration:
181
208
  ## Integration with Claude Code
182
209
 
183
210
  ```bash
184
- claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"env":{"ZOTERO_API_KEY":"...","ZOTERO_USER_ID":"..."}}'
211
+ claude mcp add zotero -e ZOTERO_API_KEY=YOUR_API_KEY -e ZOTERO_USER_ID=YOUR_USER_ID -- npx -y @xevos117/mcp-zotero
185
212
  ```
186
213
 
214
+ Add `-e UNPAYWALL_EMAIL=...` or `-e UNSAFE_OPERATIONS=items` as needed, and `--scope user` to make the server
215
+ available in every project (the default `local` scope covers only the current project). From a clone of this repository,
216
+ `npx tsx src/server.ts` runs the server from source instead (see [Development](#development)).
217
+
187
218
  ## Available Tools
188
219
 
189
220
  ### Library browsing
@@ -193,7 +224,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
193
224
  | `get_collections` | List all collections (folders) with keys, names, and parent relationships |
194
225
  | `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
195
226
  | `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
196
- | `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string and the structured `creators` |
227
+ | `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string and the structured `creators`; keys not in the library are listed in `not_found` |
197
228
  | `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
198
229
 
199
230
  ### Adding content
@@ -229,6 +260,8 @@ npm run build # Compile TypeScript
229
260
  npm test # Unit and per-tool tests (vitest, fetch faked, ~1000 tests)
230
261
  npm run test:e2e # Built server over stdio against a local fake Zotero API
231
262
  npm run smoke # Quick stdio smoke test of the built server (user + group library)
263
+ npm run bundle # Claude Desktop extension: dist/mcp-zotero.mcpb (production dependencies only)
264
+ npm run smoke -- --bundle dist/mcp-zotero.mcpb # smoke test of the unpacked bundle, started from its manifest
232
265
  npx tsx src/server.ts # Run directly without building
233
266
  ```
234
267
 
@@ -238,8 +271,8 @@ npx tsx src/server.ts # Run directly without building
238
271
  |---|---|---|---|
239
272
  | Unit / per tool | `npm test` | every PR (Node 22, 24, 26) | every tool through a real MCP client, Zotero faked at the `fetch` level; produced `.docx` files are validated against the official CSL schemas and rendered with citeproc-js ([test/helpers/docx-validate.ts](test/helpers/docx-validate.ts)) |
240
273
  | E2E + smoke | `npm run test:e2e`, `npm run smoke` | every PR | the compiled server over stdio, user and group libraries |
241
- | Live | `npm run test:live` | PRs from this repository, `main`, nightly | the real Zotero Web API on a dedicated test account ([test/live](test/live)); skipped unless `ZOTERO_TEST_API_KEY` is set |
242
- | Agent smoke | [workflow](.github/workflows/claude-agent-smoke.yml) | PRs from this repository, manual runs (e.g. on `main` after a merge) | a Claude Code agent uses the server like a user, following the versioned skill [`.claude/skills/test-mcp-zotero`](.claude/skills/test-mcp-zotero/SKILL.md): `inject_citations` ⇄ `inject.js` parity in every style, fixed edge cases and up to 5 exploratory scenarios on what the PR changes. The workflow sets up the MCP server, the tool set and guard hooks (Bash allowlist, delete policy, audit log); local runs use the same pieces ([test/live/agent/README.md](test/live/agent/README.md)). The verdict comes from [test/live/verify-claims.mjs](test/live/verify-claims.mjs), not from the agent |
274
+ | Live | `npm run test:live` ([workflow](.github/workflows/claude-agent-smoke.yml), shared with the agent smoke test) | PRs from this repository, `main`, weekly (Friday), manual runs | the real Zotero Web API on a dedicated test account ([test/live](test/live)); skipped unless `ZOTERO_TEST_API_KEY` is set |
275
+ | Agent smoke | [workflow](.github/workflows/claude-agent-smoke.yml), after the live suite passed | PRs from this repository, manual runs (e.g. on `main` after a merge) | a Claude Code agent uses the server like a user, following the versioned skill [`.claude/skills/test-mcp-zotero`](.claude/skills/test-mcp-zotero/SKILL.md): `inject_citations` ⇄ `inject.js` parity in every style, fixed edge cases and up to 5 exploratory scenarios on what the PR changes. The workflow sets up the MCP server, the tool set and guard hooks (Bash allowlist, delete policy, audit log); local runs use the same pieces ([test/live/agent/README.md](test/live/agent/README.md)). The verdict comes from [test/live/verify-claims.mjs](test/live/verify-claims.mjs), not from the agent; each run writes a Job Summary (verdict, scenarios, exploratory findings, friction, turns/cost/duration) and keeps one comment on the PR up to date, both scrubbed of anything that identifies the test account |
243
276
  | Word | manual | every release | [test/WORD_CHECKLIST.md](test/WORD_CHECKLIST.md): open an injected document in Word with the Zotero plugin |
244
277
 
245
278
  Every test case is listed in [test/TEST_CASES.md](test/TEST_CASES.md).
@@ -310,12 +343,21 @@ Other owner settings:
310
343
 
311
344
  ### Releasing
312
345
 
313
- 1. Update `CHANGELOG.md` (move `Unreleased` to the new version) and `version` in `package.json`.
346
+ 1. Update `CHANGELOG.md` (move `Unreleased` to the new version) and the version in `package.json`, in
347
+ `server.json` (`version` and `packages[0].version`) and in `manifest.json` (`npm test` fails if they differ).
314
348
  2. Make sure CI, live tests and the agent smoke test are green on `main`.
315
349
  3. Run the manual [Word checklist](test/WORD_CHECKLIST.md) on a document injected by the release candidate
316
350
  and record the results in its log.
317
- 4. Publish the GitHub release: the [release workflow](.github/workflows/release.yml) runs the tests, publishes
318
- to npm and attaches the skill zips.
351
+ 4. Before the first release that ships the Claude Desktop extension (and whenever `manifest.json` or the Node.js
352
+ requirement changes): build it with `npm run bundle`, install `dist/mcp-zotero.mcpb` once in a real Claude
353
+ Desktop (macOS or Windows), fill in a test key and user ID, and check that the 15 tools appear and
354
+ `get_collections` answers.
355
+ 5. Publish the GitHub release: the [release workflow](.github/workflows/release.yml) runs the tests, publishes
356
+ to npm, publishes `server.json` to the [MCP Registry](https://registry.modelcontextprotocol.io) and attaches
357
+ the skill zips and the Claude Desktop extension (`mcp-zotero.mcpb`). The release tag must be the version
358
+ (e.g. `2.0.3`): the registry and bundle jobs check out the tag and fail if `package.json` differs.
359
+ 6. If only the `mcp-registry` job fails, use **Re-run failed jobs** on the run: **Re-run all jobs** stops at the
360
+ npm publish (the version already exists) and so never reaches the registry.
319
361
 
320
362
  ### Debug with MCP Inspector
321
363
 
@@ -1,10 +1,69 @@
1
- export function formatCitationText(items, style, num) {
1
+ /**
2
+ * What differs between the author-date styles in the placeholder text, as citeproc renders them
3
+ * with the CSL styles bundled with Zotero: "(Smith, 2023, p. 12)" in apa/harvard, "(Smith 2023, 12)"
4
+ * in chicago; "Doe & Roe" only in apa.
5
+ */
6
+ const AUTHOR_DATE = {
7
+ apa: { yearSep: ", ", and: "&", pageLabel: true },
8
+ harvard: { yearSep: ", ", and: "and", pageLabel: true },
9
+ chicago: { yearSep: " ", and: "and", pageLabel: false },
10
+ };
11
+ /**
12
+ * Locator as citeproc renders a locator without label (= page): "12" → "p. 12", "12-15" → "pp. 12–15";
13
+ * a leading "p."/"pp." written by the user is replaced, any other label ("chap. 3", "fig. 2") is kept.
14
+ */
15
+ export function formatLocator(locator, pageLabel) {
16
+ const bare = locator.trim().replace(/^pp?\.\s*/i, "");
17
+ if (!/^[\divxlcdm]/i.test(bare) || /^[a-z]{2,}\.?\s/i.test(bare))
18
+ return locator.trim();
19
+ const pages = bare.replace(/(\d)\s*-+\s*(\d)/g, "$1–$2");
20
+ if (!pageLabel)
21
+ return pages;
22
+ return `${/[–,&]/.test(pages) ? "pp." : "p."} ${pages}`;
23
+ }
24
+ /** prefix/suffix the way citeproc joins them: a space after the prefix, before a suffix that does not start with punctuation. */
25
+ function affixes(opts) {
26
+ const prefix = opts.prefix?.trim() ? `${opts.prefix.trim()} ` : "";
27
+ const s = opts.suffix?.trim() ?? "";
28
+ const suffix = !s ? "" : /^[,;.:)\]]/.test(s) ? s : ` ${s}`;
29
+ return { prefix, suffix };
30
+ }
31
+ /** "2,3" or "5-7" → one number per item when the count matches, otherwise undefined. */
32
+ function numbersPerItem(num, count) {
33
+ const out = [];
34
+ for (const part of num.split(",").map((p) => p.trim())) {
35
+ const range = /^(\d+)\s*[-–]\s*(\d+)$/.exec(part);
36
+ if (range)
37
+ for (let n = Number(range[1]); n <= Number(range[2]); n++)
38
+ out.push(String(n));
39
+ else
40
+ out.push(part);
41
+ }
42
+ return out.length === count ? out : undefined;
43
+ }
44
+ function numberedText(count, style, opts) {
45
+ const num = opts.num ?? "?";
46
+ const { prefix, suffix } = affixes(opts);
47
+ // nlm-citation-sequence (vancouver) does not print locators
48
+ const locator = opts.locator && style === "ieee" ? `, ${formatLocator(opts.locator, true)}` : "";
49
+ if (!prefix && !suffix && !locator)
50
+ return `[${num}]`;
51
+ const perItem = numbersPerItem(num, count) ?? [num];
52
+ // ieee: each item in its own brackets ("see [1, p. 12], see [2, p. 12]"); vancouver: one group
53
+ if (style === "ieee")
54
+ return perItem.map((n) => `${prefix}[${n}${locator}]${suffix}`).join(", ");
55
+ return `[${perItem.map((n) => `${prefix}${n}${suffix}`).join(",")}]`;
56
+ }
57
+ export function formatCitationText(items, style, opts = {}) {
2
58
  if (style === "ieee" || style === "vancouver") {
3
- return num ? `[${num}]` : "[?]";
59
+ return numberedText(items.length, style, opts);
4
60
  }
61
+ const rules = AUTHOR_DATE[style] ?? AUTHOR_DATE.apa;
62
+ const { prefix, suffix } = affixes(opts);
63
+ const locator = opts.locator ? `, ${formatLocator(opts.locator, rules.pageLabel)}` : "";
5
64
  const parts = items.map((item) => {
6
65
  const authors = item.author;
7
- const firstAuthor = authors?.[0]?.family ?? "Unknown";
66
+ const name = (a) => a.family ?? a.literal ?? "Unknown";
8
67
  const year = item.issued?.["date-parts"]?.[0]?.[0]?.toString() ?? "n.d.";
9
68
  let authorText;
10
69
  if (!authors || authors.length === 0) {
@@ -16,15 +75,15 @@ export function formatCitationText(items, style, num) {
16
75
  : "Unknown";
17
76
  }
18
77
  else if (authors.length > 2) {
19
- authorText = `${firstAuthor} et al.`;
78
+ authorText = `${name(authors[0])} et al.`;
20
79
  }
21
80
  else if (authors.length === 2) {
22
- authorText = `${firstAuthor} & ${authors[1].family}`;
81
+ authorText = `${name(authors[0])} ${rules.and} ${name(authors[1])}`;
23
82
  }
24
83
  else {
25
- authorText = firstAuthor;
84
+ authorText = name(authors[0]);
26
85
  }
27
- return `${authorText}, ${year}`;
86
+ return `${prefix}${authorText}${rules.yearSep}${year}${locator}${suffix}`;
28
87
  });
29
88
  return `(${parts.join("; ")})`;
30
89
  }
@@ -0,0 +1,121 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * Zotero document preferences (style, field type) in docProps/custom.xml, as Zotero for Word writes them: the
4
+ * serialized document data split into the custom properties ZOTERO_PREF_1…n, at most 255 characters each (Word's
5
+ * limit for a property; the plugin concatenates _1, _2… until one is missing). Without them the first Refresh opens
6
+ * Document Preferences and proposes Zotero's last used style instead of the injected one (H-W1, word-proxy job).
7
+ * A document that already has ZOTERO_PREF_* properties is already managed by Zotero and is left as it is.
8
+ * The skill's inject.js writes the same properties (keep both in sync).
9
+ */
10
+ /** Tool style → id of the CSL style shipped with Zotero (class in-text). */
11
+ export const ZOTERO_STYLE_IDS = {
12
+ apa: "http://www.zotero.org/styles/apa",
13
+ ieee: "http://www.zotero.org/styles/ieee",
14
+ vancouver: "http://www.zotero.org/styles/nlm-citation-sequence",
15
+ harvard: "http://www.zotero.org/styles/harvard-cite-them-right",
16
+ chicago: "http://www.zotero.org/styles/chicago-author-date",
17
+ };
18
+ const CUSTOM_PART = "docProps/custom.xml";
19
+ const CUSTOM_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.custom-properties+xml";
20
+ const CUSTOM_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
21
+ /** fmtid of user-defined custom properties. */
22
+ const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
23
+ export const PREF_CHUNK = 255;
24
+ const SESSION_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
25
+ const escapeXml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
26
+ function sessionId() {
27
+ return [...randomBytes(8)].map((b) => SESSION_ALPHABET[b % SESSION_ALPHABET.length]).join("");
28
+ }
29
+ /** The document data Zotero serializes (data-version 3), citations in the text (no noteType), fields as Word fields. */
30
+ export function zoteroDocumentData(styleId, session = sessionId()) {
31
+ return (`<data data-version="3"><session id="${session}"/>` +
32
+ `<style id="${escapeXml(styleId)}" locale="en-US" hasBibliography="1" bibliographyStyleHasBeenSet="0"/>` +
33
+ `<prefs><pref name="fieldType" value="Field"/></prefs></data>`);
34
+ }
35
+ /** The value split into ZOTERO_PREF_1…n chunks. */
36
+ export function prefChunks(value) {
37
+ const out = [];
38
+ for (let i = 0; i < value.length; i += PREF_CHUNK)
39
+ out.push(value.slice(i, i + PREF_CHUNK));
40
+ return out;
41
+ }
42
+ export const EXISTING_PREFS_WARNING = "The document already has Zotero document preferences (ZOTERO_PREF_* in docProps/custom.xml): kept as they are, so on the " +
43
+ "first Refresh Zotero uses the style saved there, not the style of this injection.";
44
+ export const PREFS_NOT_WRITTEN_WARNING = "Zotero document preferences not written: the document's custom properties part could not be merged into, so Zotero may " +
45
+ "ask for the document style (Document Preferences) on the first Refresh.";
46
+ /** Part name of the custom properties: the target of the package relationship if there is one, else docProps/custom.xml. */
47
+ function customPartName(rels) {
48
+ for (const m of (rels ?? "").matchAll(/<Relationship\b[^>]*>/g)) {
49
+ if (!m[0].includes(CUSTOM_REL_TYPE))
50
+ continue;
51
+ const target = /\bTarget=["']([^"']+)["']/.exec(m[0])?.[1];
52
+ if (target)
53
+ return target.replace(/^\/+/, "");
54
+ }
55
+ return CUSTOM_PART;
56
+ }
57
+ /**
58
+ * Writes the Zotero document preferences for `style` into the docx: the custom properties part (merged into an
59
+ * existing one, with its root's namespace prefix), its content type and its package relationship, each only when
60
+ * missing. Never rewrites an existing part it cannot merge into: then nothing changes ("skipped"). A document that
61
+ * already has ZOTERO_PREF_* properties is left as it is ("kept").
62
+ */
63
+ export async function writeZoteroDocumentPrefs(zip, style) {
64
+ const styleId = ZOTERO_STYLE_IDS[style];
65
+ if (!styleId)
66
+ return "skipped";
67
+ const rels = await zip.file("_rels/.rels")?.async("string");
68
+ const part = customPartName(rels);
69
+ const existing = await zip.file(part)?.async("string");
70
+ if (existing && /\bname=["']ZOTERO_PREF_\d+["']/.test(existing))
71
+ return "kept";
72
+ const pids = [...(existing ?? "").matchAll(/\bpid=["'](\d+)["']/g)].map((m) => Number(m[1]));
73
+ let pid = Math.max(1, ...pids);
74
+ const root = existing ? /<(\w+:)?Properties\b([^>]*?)(\/?)>/.exec(existing) : null;
75
+ const prefix = root?.[1] ?? "";
76
+ const properties = prefChunks(zoteroDocumentData(styleId))
77
+ .map((chunk, i) => `<${prefix}property fmtid="${FMTID}" pid="${++pid}" name="ZOTERO_PREF_${i + 1}"><vt:lpwstr>${escapeXml(chunk)}</vt:lpwstr></${prefix}property>`)
78
+ .join("");
79
+ if (existing) {
80
+ if (!root)
81
+ return "skipped";
82
+ let merged;
83
+ if (root[3]) {
84
+ // empty root <Properties …/>
85
+ merged = existing.replace(root[0], `<${prefix}Properties${root[2]}>${properties}</${prefix}Properties>`);
86
+ }
87
+ else {
88
+ const close = `</${prefix}Properties>`;
89
+ const at = existing.lastIndexOf(close);
90
+ if (at < 0)
91
+ return "skipped";
92
+ merged = existing.slice(0, at) + properties + existing.slice(at);
93
+ }
94
+ // the vt prefix of the new properties must be declared on the root
95
+ if (!/xmlns:vt=/.test(merged))
96
+ merged = merged.replace(`<${prefix}Properties`, `<${prefix}Properties xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"`);
97
+ if (merged === existing || !merged.includes(properties))
98
+ return "skipped";
99
+ zip.file(part, merged);
100
+ }
101
+ else {
102
+ zip.file(part, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
103
+ '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" ' +
104
+ `xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">${properties}</Properties>`);
105
+ }
106
+ const typesEntry = zip.file("[Content_Types].xml");
107
+ if (typesEntry) {
108
+ const types = await typesEntry.async("string");
109
+ if (!types.includes(`PartName="/${part}"`)) {
110
+ zip.file("[Content_Types].xml", types.replace("</Types>", `<Override PartName="/${part}" ContentType="${CUSTOM_CONTENT_TYPE}"/></Types>`));
111
+ }
112
+ }
113
+ if (rels !== undefined && !rels.includes(CUSTOM_REL_TYPE)) {
114
+ const ids = new Set([...rels.matchAll(/\bId=["']([^"']+)["']/g)].map((m) => m[1]));
115
+ let n = 1;
116
+ while (ids.has(`rId${n}`))
117
+ n++;
118
+ zip.file("_rels/.rels", rels.replace("</Relationships>", `<Relationship Id="rId${n}" Type="${CUSTOM_REL_TYPE}" Target="${part}"/></Relationships>`));
119
+ }
120
+ return "written";
121
+ }
@@ -6,7 +6,12 @@ import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-
6
6
  import { formatCitationText } from "./citation-formatter.js";
7
7
  import { regexEscape, unescapeXml } from "./xml-utils.js";
8
8
  import { normalizeZciteTags } from "./zcite-normalizer.js";
9
+ import { EXISTING_PREFS_WARNING, PREFS_NOT_WRITTEN_WARNING, writeZoteroDocumentPrefs } from "./document-prefs.js";
9
10
  import { zoteroItemToCsl } from "../utils/csl-to-zotero.js";
11
+ import { ZOTERO_KEY } from "../utils/zotero-keys.js";
12
+ const REGENERATE_DOCX = "The file is not a Word document or is corrupted: create it again as .docx (e.g. with the docx npm package) and call again.";
13
+ const NO_ZCITE_WARNING = 'No <zcite> placeholders found: the output is an unchanged copy. Write them in the document body as <zcite keys="ITEMKEY"/> ' +
14
+ "(headers, footers, footnotes and comments are not scanned).";
10
15
  function parseZciteMatches(documentXml) {
11
16
  const findRegex = /&lt;zcite\s+[\s\S]*?\/&gt;/g;
12
17
  const matches = [];
@@ -110,10 +115,16 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
110
115
  const { dir, name, ext } = parse(filePath);
111
116
  const outputPath = format({ dir, name: `${name}_cited`, ext });
112
117
  const fileBuffer = await readFile(filePath);
113
- const zip = await JSZip.loadAsync(fileBuffer);
118
+ let zip;
119
+ try {
120
+ zip = await JSZip.loadAsync(fileBuffer);
121
+ }
122
+ catch (err) {
123
+ throw new Error(`Invalid .docx file: not a readable ZIP archive (${err instanceof Error ? err.message : String(err)}). ${REGENERATE_DOCX}`);
124
+ }
114
125
  const documentEntry = zip.file("word/document.xml");
115
126
  if (!documentEntry) {
116
- throw new Error("Invalid .docx file: word/document.xml not found");
127
+ throw new Error(`Invalid .docx file: word/document.xml not found. ${REGENERATE_DOCX}`);
117
128
  }
118
129
  let documentXml = await documentEntry.async("string");
119
130
  documentXml = normalizeZciteTags(documentXml);
@@ -121,23 +132,27 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
121
132
  if (matches.length === 0) {
122
133
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
123
134
  await writeFile(outputPath, buffer);
124
- return { outputPath, found: 0, injected: 0, warnings: [] };
135
+ return { outputPath, found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
125
136
  }
126
137
  // Warn if using a numbered style but tags are missing the num attribute
127
138
  const warnings = [];
128
139
  if (style === "ieee" || style === "vancouver") {
129
140
  const withNum = matches.filter((m) => m.num !== undefined).length;
130
141
  if (withNum < matches.length) {
131
- warnings.push(`Style '${style}' requires 'num' attribute on <zcite> tags. Found ${withNum}/${matches.length} tags with num.`);
142
+ warnings.push(`Style '${style}' requires 'num' attribute on <zcite> tags. Found ${withNum}/${matches.length} tags with num. ` +
143
+ "Citations without num show [?] until the user runs Zotero → Refresh in Word; to get the numbers now, add " +
144
+ 'num="N" (order of first citation) to every <zcite> in the original file and run again.');
132
145
  }
133
146
  }
134
147
  // Collect all unique item keys
135
148
  const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
136
- // Keys from the document go into API URL paths (one GET per key): only letters and digits, so a crafted
137
- // <zcite> cannot point the request at another endpoint ("../", "?", "&"). The skill's inject.js applies the same check
138
- const invalid = [...uniqueKeys].filter((k) => !/^[A-Za-z0-9]{1,32}$/.test(k));
149
+ // Keys from the document go into API URL paths (one GET per key) and Zotero URIs: only well-formed Zotero
150
+ // keys, so a crafted <zcite> cannot point the request at another endpoint ("../", "?", "&"), and a key Zotero
151
+ // would reject never reaches the document. The skill's inject.js applies the same check
152
+ const invalid = [...uniqueKeys].filter((k) => !ZOTERO_KEY.test(k));
139
153
  if (invalid.length > 0) {
140
- throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (letters and digits only)`);
154
+ throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (a key is 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ). ` +
155
+ "No file was written. Use the keys returned by search_library, add_items_by_doi or add_items.");
141
156
  }
142
157
  // Fetch CSL data from Zotero
143
158
  const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
@@ -145,14 +160,16 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
145
160
  // like the skill's inject.js with a key missing from metadata.json
146
161
  const missingKeys = [...uniqueKeys].filter((k) => !cslData.has(k));
147
162
  if (missingKeys.length > 0) {
148
- throw new Error(`${missingKeys.length} cited key(s) not found in the library: ${missingKeys.join(", ")}`);
163
+ throw new Error(`${missingKeys.length} cited key(s) not found in the library: ${missingKeys.join(", ")}. No file was written. ` +
164
+ "Fix these keys in the document (find existing items with search_library, or add the missing ones with add_items_by_doi " +
165
+ "or add_items) and call again; for a group library pass library_type and library_id.");
149
166
  }
150
167
  // Replace each zcite tag with a field code
151
168
  let injected = 0;
152
169
  for (const match of matches) {
153
170
  const citationItems = buildCitationItems(match, cslData, libraryType, userId);
154
171
  const itemDataList = match.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
155
- const formattedText = formatCitationText(itemDataList, style, match.num);
172
+ const formattedText = formatCitationText(itemDataList, style, match);
156
173
  const fieldCodeXml = generateZoteroFieldCode(citationItems, formattedText);
157
174
  const newXml = replaceZciteInXml(documentXml, match.fullMatch, fieldCodeXml);
158
175
  if (newXml !== documentXml) {
@@ -163,8 +180,15 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
163
180
  // Append bibliography before </w:body>
164
181
  const biblXml = generateBibliographyFieldCode();
165
182
  documentXml = documentXml.replace("</w:body>", `${biblXml}</w:body>`);
166
- // Save
183
+ // Document preferences (style, field type) where Zotero for Word reads them: no Document Preferences dialog
184
+ // on the first Refresh, the injected style is applied (H-W1)
167
185
  zip.file("word/document.xml", documentXml);
186
+ const prefs = await writeZoteroDocumentPrefs(zip, style);
187
+ if (prefs === "kept")
188
+ warnings.push(EXISTING_PREFS_WARNING);
189
+ if (prefs === "skipped")
190
+ warnings.push(PREFS_NOT_WRITTEN_WARNING);
191
+ // Save
168
192
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
169
193
  await writeFile(outputPath, buffer);
170
194
  return { outputPath, found: matches.length, injected, warnings };
package/build/server.js CHANGED
@@ -28,13 +28,16 @@ class ZoteroServer {
28
28
  "CITATION WORKFLOW:",
29
29
  "1. Collect item keys: use add_items_by_doi for items with DOIs, add_items for items without DOIs (or search_library for existing items)",
30
30
  "2. Generate .docx: create a Word document with <zcite keys=\"ITEMKEY\"/> placeholders where citations should appear. Each <zcite> must be in its own TextRun.",
31
- "3. Call inject_citations with the .docx file path — it fetches metadata from Zotero automatically.",
31
+ "3. Call inject_citations with the .docx file path — it fetches metadata from Zotero automatically. The file must be on the machine where this server runs.",
32
32
  "4. Tell the user to open it in Word with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
33
33
  "",
34
34
  "KEY NOTES:",
35
- "- add_items_by_doi auto-attaches OA PDFs via Unpaywall at no cost. Do not disable auto_attach_pdf unless it causes errors.",
35
+ "- add_items_by_doi auto-attaches open-access PDFs found via Unpaywall (needs UNPAYWALL_EMAIL in the server settings; uses the user's Zotero storage). Do not disable auto_attach_pdf unless it causes errors.",
36
36
  "- For IEEE/Vancouver (numbered) citation styles, every <zcite> MUST include a num attribute. num is the citation number assigned in order of first appearance in the text (first cited = num=\"1\", second = num=\"2\", etc.). For multi-item citations use comma-separated values: keys=\"KEY1,KEY2\" num=\"1,2\". Without num, citations render as [?].",
37
- "- get_user_id is only needed by the standalone skill script (inject.js), not by the inject_citations tool.",
37
+ "- If the .docx is in a sandbox or cloud workspace this server cannot see (code execution in Claude.ai or Claude Desktop), use the zotero-mcp-integrations skill instead of inject_citations. If it is not installed, recommend zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.",
38
+ "- get_user_id is only needed by the skill script (inject.js), not by the inject_citations tool.",
39
+ "- Deleting is off unless the server setting UNSAFE_OPERATIONS (\"Deletions allowed\" in the Claude Desktop extension) allows it ('items': delete_items; 'all': also delete_collection). Never work around a blocked deletion with other tools.",
40
+ "- Errors carry a hint with the next step. When it says the fix is on the user's side (API key, server settings), tell the user instead of retrying.",
38
41
  ].join("\n"),
39
42
  });
40
43
  this.unsafeOps = parseUnsafeOperations(process.env.UNSAFE_OPERATIONS);
@@ -45,7 +48,7 @@ class ZoteroServer {
45
48
  else {
46
49
  const apiKey = process.env.ZOTERO_API_KEY || "";
47
50
  if (!apiKey) {
48
- throw new Error("Missing ZOTERO_API_KEY environment variable");
51
+ throw new Error("Missing ZOTERO_API_KEY environment variable (create a key at https://www.zotero.org/settings/keys)");
49
52
  }
50
53
  // Validate the library configuration eagerly so a misconfigured server fails
51
54
  // fast at startup rather than on the first tool call. ZOTERO_LIBRARY_ID takes
@@ -6,12 +6,13 @@ import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
6
6
  import { postInBatches } from "../utils/write-results.js";
7
7
  import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
8
8
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
9
+ import { UNPAYWALL_EMAIL_HINT, UNPAYWALL_EMAIL_MISSING } from "../utils/unpaywall.js";
9
10
  export const toolConfig = {
10
11
  name: "add_items_by_doi",
11
12
  description: `Add items to your Zotero library by resolving DOIs. Works with ANY item type that has a DOI — journal articles, books, datasets, preprints, conference papers, reports, etc. For each DOI, resolves metadata via content negotiation and creates the item in Zotero with the correct type automatically. Returns a list of successfully added items (with item_key and title) and any failures.
12
13
 
13
14
  WHEN TO USE vs add_items:
14
- - Use add_items_by_doi when the item HAS a DOI — it auto-resolves all metadata and attaches OA PDFs.
15
+ - Use add_items_by_doi when the item HAS a DOI — it auto-resolves all metadata and attaches open-access PDFs (when the server has UNPAYWALL_EMAIL set).
15
16
  - Use add_items when the item does NOT have a DOI, or when you need to override specific metadata fields (add_items_by_doi does not allow metadata overrides).
16
17
  - Mixed batch: if some items have DOIs and others don't, make two separate calls — add_items_by_doi for the DOIs and add_items for the rest.
17
18
 
@@ -21,7 +22,7 @@ WORKFLOW TIPS:
21
22
  inputSchema: {
22
23
  dois: z
23
24
  .array(z.string().trim().min(1, "DOI must not be empty"))
24
- .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
25
+ .describe('Array of bare DOIs (e.g. ["10.1038/s41586-023-06647-8"], without "https://doi.org/"). Each DOI will be resolved and added to Zotero.'),
25
26
  collection_key: zoteroKey()
26
27
  .optional()
27
28
  .describe("Zotero collection key to add items to. Get this from create_collection or get_collections."),
@@ -32,7 +33,7 @@ WORKFLOW TIPS:
32
33
  auto_attach_pdf: z
33
34
  .boolean()
34
35
  .default(true)
35
- .describe("Attach freely available OA PDFs via Unpaywall (default: true). This is lightweight and adds no cost — leave enabled. Only set to false if PDF attachment is causing errors."),
36
+ .describe("Attach open-access PDFs found via Unpaywall (default: true; needs UNPAYWALL_EMAIL in the server settings, uses the user's Zotero file storage). Leave enabled unless the user does not want PDFs or attachment is causing errors."),
36
37
  ...libraryArgsSchema,
37
38
  },
38
39
  };
@@ -41,12 +42,14 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
41
42
  const { dois, collection_key, tags, auto_attach_pdf, library_type, library_id } = AddItemsByDoiSchema.parse(args);
42
43
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
43
44
  if (dois.length === 0) {
44
- return formatErrorResponse("At least one DOI is required");
45
+ return formatErrorResponse('At least one DOI is required: pass dois as an array, e.g. ["10.1038/s41586-023-06647-8"].');
45
46
  }
46
47
  const resolved = await resolveDois(dois);
47
48
  if (resolved.success.length === 0) {
48
49
  return formatErrorResponse("All DOI resolutions failed", {
49
50
  failed: resolved.failed,
51
+ hint: "Nothing was created; failed[].error gives the reason per DOI. Pass bare DOIs (10.xxxx/...) and check them for typos. " +
52
+ "For a DOI that does not resolve, add the item with add_items and its metadata instead. If every DOI failed with a timeout, network or 5xx error, retry later.",
50
53
  });
51
54
  }
52
55
  const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
@@ -61,7 +64,10 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
61
64
  ];
62
65
  if (write.created.length === 0) {
63
66
  const errorMessages = write.failed.map(({ index, error }) => `Item ${index}: ${error}`).join("; ");
64
- return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, { failed });
67
+ return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, {
68
+ failed,
69
+ hint: "Nothing was created. Fix what failed[].error reports (an invalid collection_key: check it with get_collections) and retry; for a rate limit or server error, wait a minute and retry once.",
70
+ });
65
71
  }
66
72
  const success = write.created.map(({ index, entity }) => {
67
73
  const { doi, data } = resolved.success[index];
@@ -81,14 +87,16 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
81
87
  : { ...r, pdf_attached: false, error: r.reason });
82
88
  }
83
89
  const quotaHit = pdf_results?.some((r) => r.status === "quota_exceeded");
90
+ const unpaywallOff = pdf_results?.some((r) => r.reason === UNPAYWALL_EMAIL_MISSING);
84
91
  return formatJsonResult({
85
92
  success,
86
93
  failed,
87
94
  ...(pdf_results !== undefined ? { pdf_results } : {}),
88
95
  ...(quotaHit
89
96
  ? {
90
- storage_quota_warning: "Zotero storage quota is full. Some PDF attachments were skipped. All items were created successfully (metadata only). Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
97
+ storage_quota_warning: "Zotero storage quota is full. Some PDF attachments were skipped. All items were created successfully (metadata only). Tell the user they can free up space or upgrade at https://www.zotero.org/settings/storage, then attach the PDFs with find_and_attach_pdfs.",
91
98
  }
92
99
  : {}),
100
+ ...(unpaywallOff ? { hint: UNPAYWALL_EMAIL_HINT } : {}),
93
101
  });
94
102
  }
@@ -56,7 +56,7 @@ WHEN TO USE:
56
56
  - Mixed batch: if some items have DOIs and others don't, call add_items_by_doi for the DOIs and add_items for the rest (two separate calls)
57
57
  - Prefer add_items_by_doi when DOIs are available AND you don't need to override metadata (it auto-resolves everything and attaches OA PDFs)
58
58
 
59
- BATCH: Pass multiple items in the 'items' array (single API call).
59
+ BATCH: Pass multiple items in the 'items' array (one tool call).
60
60
 
61
61
  COMMON FIELDS (available for most types):
62
62
  title, date, abstractNote, url, DOI, publisher, place, pages, volume, language, extra
@@ -130,7 +130,10 @@ export async function handleAddItems(zoteroApi, userId, args) {
130
130
  }));
131
131
  const failed = write.failed.map(({ index, error }) => ({ index, title: items[index].title, error }));
132
132
  if (success.length === 0 && failed.length > 0) {
133
- return formatErrorResponse("All items failed to create", { failed });
133
+ return formatErrorResponse("All items failed to create", {
134
+ failed,
135
+ hint: "Nothing was created. failed[].error gives Zotero's reason per item: fix those items (an invalid collection_key: check it with get_collections) and call again; retrying unchanged will fail again.",
136
+ });
134
137
  }
135
138
  return formatJsonResult({
136
139
  success,
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
3
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { formatWriteErrors } from "../utils/write-results.js";
5
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
+ import { COLLECTION_KEY_HINT, ITEM_KEY_HINT } from "../utils/error-hints.js";
6
7
  export const toolConfig = {
7
8
  name: "add_linked_url_attachment",
8
- description: "Attach a linked URL to an existing Zotero item, or create a standalone linked-URL attachment. Use this to link external PDFs, web pages, or other resources to items already in your library. If parent_item is provided, the attachment is added as a child; otherwise it is standalone.",
9
+ description: "Attach a linked URL to an existing Zotero item, or create a standalone linked-URL attachment. Use this to link external PDFs, web pages, or other resources to items already in your library. If parent_item is provided, the attachment is added as a child; otherwise it is standalone. A linked URL stores no file and no full text (get_item_fulltext cannot read it): to store a PDF in Zotero use import_pdf_to_zotero.",
9
10
  inputSchema: {
10
11
  url: z.string().url().describe("URL of the resource to link"),
11
12
  title: z
@@ -18,7 +19,7 @@ export const toolConfig = {
18
19
  .describe('MIME type of the linked resource (e.g. "application/pdf", "text/html")'),
19
20
  parent_item: zoteroKey()
20
21
  .optional()
21
- .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
22
+ .describe("Key of the parent item (a regular item, not an attachment or note), from search_library or get_collection_items. If provided, the attachment becomes a child of that item."),
22
23
  collections: z
23
24
  .array(zoteroKey())
24
25
  .optional()
@@ -59,6 +60,9 @@ export async function handleAddLinkedUrlAttachment(zoteroApi, userId, args) {
59
60
  const errorMsg = formatWriteErrors(response.getErrors());
60
61
  return formatErrorResponse("Failed to create linked URL attachment", {
61
62
  details: errorMsg,
63
+ hint: parent_item
64
+ ? `details has Zotero's reason. parent_item must be an existing regular item, not an attachment or note: ${ITEM_KEY_HINT}`
65
+ : `details has Zotero's reason. Check the collections keys: ${COLLECTION_KEY_HINT}`,
62
66
  });
63
67
  }
64
68
  const created = response.getData();
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
3
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { formatWriteErrors } from "../utils/write-results.js";
5
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
+ import { COLLECTION_KEY_HINT } from "../utils/error-hints.js";
6
7
  export const toolConfig = {
7
8
  name: "create_collection",
8
- description: "Create a new collection (folder) in your Zotero library. Optionally nest it under a parent collection. Returns the new collection key and name. Use the key with add_items_by_doi to organize imported papers.",
9
+ description: "Create a new collection (folder) in your Zotero library. Optionally nest it under a parent collection. Returns the new collection key and name. Use the key as collection_key in add_items_by_doi or add_items to organize imported papers.",
9
10
  inputSchema: {
10
11
  name: z.string().describe("Name of the new collection"),
11
12
  parent_collection: zoteroKey()
@@ -19,7 +20,7 @@ export async function handleCreateCollection(zoteroApi, userId, args) {
19
20
  const { name, parent_collection, library_type, library_id } = CreateCollectionSchema.parse(args);
20
21
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
21
22
  if (!name?.trim()) {
22
- return formatErrorResponse("Collection name is required");
23
+ return formatErrorResponse("Collection name is required: pass a non-empty name.");
23
24
  }
24
25
  const collectionData = { name: name.trim() };
25
26
  if (parent_collection) {
@@ -33,6 +34,9 @@ export async function handleCreateCollection(zoteroApi, userId, args) {
33
34
  const errorMsg = formatWriteErrors(response.getErrors());
34
35
  return formatErrorResponse("Failed to create collection", {
35
36
  details: errorMsg,
37
+ hint: parent_collection
38
+ ? `details has Zotero's reason. Check that parent_collection exists: ${COLLECTION_KEY_HINT}`
39
+ : "details has Zotero's reason: fix the name if it is the cause and retry.",
36
40
  });
37
41
  }
38
42
  const created = response.getData();