@xevos117/mcp-zotero 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -4
- package/build/citation-injector/citation-formatter.js +66 -7
- package/build/citation-injector/document-prefs.js +121 -0
- package/build/citation-injector/injector.js +54 -14
- package/build/server.js +10 -5
- package/build/tools/add-items-by-doi.js +15 -7
- package/build/tools/add-items.js +5 -2
- package/build/tools/add-linked-url-attachment.js +6 -2
- package/build/tools/create-collection.js +6 -2
- package/build/tools/delete-collection.js +7 -3
- package/build/tools/delete-items.js +7 -3
- package/build/tools/find-and-attach-pdfs.js +17 -9
- package/build/tools/get-collection-items.js +8 -7
- package/build/tools/get-collections.js +2 -2
- package/build/tools/get-item-fulltext.js +55 -17
- package/build/tools/get-items-details.js +7 -3
- package/build/tools/get-user-id.js +1 -1
- package/build/tools/import-pdf-to-zotero.js +45 -8
- package/build/tools/index.js +11 -3
- package/build/tools/inject-citations.js +33 -6
- package/build/tools/search-library.js +7 -5
- package/build/utils/attachments.js +13 -2
- package/build/utils/csl-to-zotero.js +39 -14
- package/build/utils/doi-resolver.js +20 -3
- package/build/utils/error-hints.js +179 -0
- package/build/utils/fetch-retry.js +2 -1
- package/build/utils/html-entities.js +55 -0
- package/build/utils/item-formatter.js +2 -1
- package/build/utils/json-response.js +29 -0
- package/build/utils/library-context.js +11 -11
- package/build/utils/pdf-uploader.js +41 -16
- package/build/utils/timeouts.js +97 -0
- package/build/utils/unpaywall.js +35 -6
- package/build/utils/zotero-fulltext.js +3 -2
- package/build/utils/zotero-keys.js +2 -1
- package/package.json +11 -2
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
|
-
|
|
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
|
|
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
|
|
@@ -226,10 +257,108 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
226
257
|
```bash
|
|
227
258
|
npm install
|
|
228
259
|
npm run build # Compile TypeScript
|
|
229
|
-
npm test #
|
|
260
|
+
npm test # Unit and per-tool tests (vitest, fetch faked, ~1000 tests)
|
|
261
|
+
npm run test:e2e # Built server over stdio against a local fake Zotero API
|
|
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
|
|
230
265
|
npx tsx src/server.ts # Run directly without building
|
|
231
266
|
```
|
|
232
267
|
|
|
268
|
+
### Testing
|
|
269
|
+
|
|
270
|
+
| Layer | Command | Runs on | What it covers |
|
|
271
|
+
|---|---|---|---|
|
|
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)) |
|
|
273
|
+
| E2E + smoke | `npm run test:e2e`, `npm run smoke` | every PR | the compiled server over stdio, user and group libraries |
|
|
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 |
|
|
276
|
+
| Word | manual | every release | [test/WORD_CHECKLIST.md](test/WORD_CHECKLIST.md): open an injected document in Word with the Zotero plugin |
|
|
277
|
+
|
|
278
|
+
Every test case is listed in [test/TEST_CASES.md](test/TEST_CASES.md).
|
|
279
|
+
|
|
280
|
+
**Live tests locally** use only `ZOTERO_TEST_*` variables, never your `ZOTERO_API_KEY`:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
ZOTERO_TEST_API_KEY=... ZOTERO_TEST_USER_ID=... UNPAYWALL_EMAIL=you@example.org npm run test:live
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Against a personal library add `ZOTERO_LIVE_SAFE_MODE=1`: the suite only creates items and collections
|
|
287
|
+
named `mzt-<runId>…` and tagged `mzt-run:<runId>`, deletes only those, and skips collection deletion and
|
|
288
|
+
group writes. A dedicated test account is marked by a collection named `__mzt_disposable__`.
|
|
289
|
+
|
|
290
|
+
**Dependencies** are updated monthly by a Claude Code agent ([workflow](.github/workflows/deps-update.yml),
|
|
291
|
+
skill [`.claude/skills/deps-update`](.claude/skills/deps-update/SKILL.md)); patch-only PRs are auto-merged after
|
|
292
|
+
all required checks pass, anything else waits for review. GitHub Actions versions are updated by Dependabot.
|
|
293
|
+
|
|
294
|
+
### CI setup (repository owner)
|
|
295
|
+
|
|
296
|
+
| Environment | Secrets | Variables |
|
|
297
|
+
|---|---|---|
|
|
298
|
+
| `zotero-live` (live tests, agent smoke) | `ZOTERO_TEST_API_KEY`, `ZOTERO_TEST_USER_ID`, `ZOTERO_TEST_USERNAME`, `ZOTERO_TEST_GROUP_ID` (optional), `UNPAYWALL_EMAIL` | `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID`, `ANTHROPIC_WORKSPACE_ID` |
|
|
299
|
+
| `claude-deps` (dependency agent) | — | the same four `ANTHROPIC_*` variables (the federation rule may differ) |
|
|
300
|
+
|
|
301
|
+
The agents authenticate to the Claude API with Anthropic Workload Identity Federation (GitHub OIDC, no
|
|
302
|
+
API key). Recommended: one federation rule per environment, as narrow as possible:
|
|
303
|
+
|
|
304
|
+
| Rule for | subject (exact, no `*`) | claims (exact string pairs) | condition (CEL) |
|
|
305
|
+
|---|---|---|---|
|
|
306
|
+
| `zotero-live` | `repo:Xevos117/mcp-zotero:environment:zotero-live` | `repository_id: 1152178663`, `repository_owner_id: 86290818` | `claims.job_workflow_ref.startsWith("Xevos117/mcp-zotero/.github/workflows/claude-agent-smoke.yml@")` (PR runs use the PR ref) |
|
|
307
|
+
| `claude-deps` | `repo:Xevos117/mcp-zotero:environment:claude-deps` | `repository_id: 1152178663`, `repository_owner_id: 86290818`, `job_workflow_ref: Xevos117/mcp-zotero/.github/workflows/deps-update.yml@refs/heads/main` | — |
|
|
308
|
+
|
|
309
|
+
Claims are exact-match string pairs (only `subject_prefix` supports a trailing `*`), so a prefix such as
|
|
310
|
+
"this workflow on any ref" must be written as a CEL `condition`, as above; an exact `job_workflow_ref`
|
|
311
|
+
(claude-deps, main only) can be a plain claim.
|
|
312
|
+
|
|
313
|
+
Audience `https://api.anthropic.com`. The subject is an exact match unless it ends with `*`; jobs that use
|
|
314
|
+
an environment get the OIDC subject `repo:Xevos117/mcp-zotero:environment:<environment>`, so a rule
|
|
315
|
+
written for a branch or PR subject never matches. Numeric ids (not names) keep a renamed or re-created
|
|
316
|
+
repository/account from matching.
|
|
317
|
+
|
|
318
|
+
Trade-off for `zotero-live`: the "any ref" condition is needed for the agent smoke test to run on pull
|
|
319
|
+
requests, but it also accepts a modified copy of that workflow file pushed to any branch of this repository
|
|
320
|
+
(only people with write access can push). Pinning it to the exact claim
|
|
321
|
+
`job_workflow_ref: Xevos117/mcp-zotero/.github/workflows/claude-agent-smoke.yml@refs/heads/main` closes that
|
|
322
|
+
gap but disables the agent smoke test on PRs (it would then run only on manual runs from `main`).
|
|
323
|
+
|
|
324
|
+
Other owner settings:
|
|
325
|
+
|
|
326
|
+
- The Zotero test account needs a collection named `__mzt_disposable__`.
|
|
327
|
+
- Repository settings: "Allow auto-merge", and "Allow GitHub Actions to create and approve pull requests"
|
|
328
|
+
(the dependency workflow opens its PR with `GITHUB_TOKEN`). Keep the ruleset at **0 required approvals**:
|
|
329
|
+
auto-merge of the dependency PR relies on it, and it is also what makes the second setting acceptable. That
|
|
330
|
+
setting is a conscious trade-off: it lets workflows open PRs (needed) and approve them (not used by any
|
|
331
|
+
workflow here); if approvals are ever required, it would let a workflow approve its own PR, so revisit it
|
|
332
|
+
together with any approval requirement.
|
|
333
|
+
- No GitHub App is used by any workflow (the agents authenticate to Anthropic with Workload Identity
|
|
334
|
+
Federation and use only `GITHUB_TOKEN` on GitHub, read-only for the agents). If the Claude GitHub App is
|
|
335
|
+
installed on this repository, uninstall it.
|
|
336
|
+
- Set a monthly spend limit on the Anthropic workspace used by the federation rules: it is the real cap on
|
|
337
|
+
agent costs.
|
|
338
|
+
- Residual risk: the agent smoke job that holds the OIDC token also runs the MCP server built from the PR's
|
|
339
|
+
code (that is what it tests). It runs only for same-repository PRs by the owner or a bot, the token is
|
|
340
|
+
bound to the `zotero-live` environment and the workspace spend limit caps any misuse.
|
|
341
|
+
- Required checks after the stack merges: `test (node 22)`, `test (node 24)`, `test (node 26)`,
|
|
342
|
+
`live-tests`, `agent-smoke`.
|
|
343
|
+
|
|
344
|
+
### Releasing
|
|
345
|
+
|
|
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).
|
|
348
|
+
2. Make sure CI, live tests and the agent smoke test are green on `main`.
|
|
349
|
+
3. Run the manual [Word checklist](test/WORD_CHECKLIST.md) on a document injected by the release candidate
|
|
350
|
+
and record the results in its log.
|
|
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.
|
|
361
|
+
|
|
233
362
|
### Debug with MCP Inspector
|
|
234
363
|
|
|
235
364
|
```bash
|
|
@@ -1,10 +1,69 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
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 = `${
|
|
78
|
+
authorText = `${name(authors[0])} et al.`;
|
|
20
79
|
}
|
|
21
80
|
else if (authors.length === 2) {
|
|
22
|
-
authorText = `${
|
|
81
|
+
authorText = `${name(authors[0])} ${rules.and} ${name(authors[1])}`;
|
|
23
82
|
}
|
|
24
83
|
else {
|
|
25
|
-
authorText =
|
|
84
|
+
authorText = name(authors[0]);
|
|
26
85
|
}
|
|
27
|
-
return `${authorText}
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import JSZip from "jszip";
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { format, parse } from "node:path";
|
|
4
|
+
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
4
5
|
import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-codes.js";
|
|
5
6
|
import { formatCitationText } from "./citation-formatter.js";
|
|
6
7
|
import { regexEscape, unescapeXml } from "./xml-utils.js";
|
|
7
8
|
import { normalizeZciteTags } from "./zcite-normalizer.js";
|
|
9
|
+
import { EXISTING_PREFS_WARNING, PREFS_NOT_WRITTEN_WARNING, writeZoteroDocumentPrefs } from "./document-prefs.js";
|
|
8
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).";
|
|
9
15
|
function parseZciteMatches(documentXml) {
|
|
10
16
|
const findRegex = /<zcite\s+[\s\S]*?\/>/g;
|
|
11
17
|
const matches = [];
|
|
@@ -39,10 +45,19 @@ function parseZciteMatches(documentXml) {
|
|
|
39
45
|
async function fetchCslData(keys, zoteroApi, libraryType, userId) {
|
|
40
46
|
const cslData = new Map();
|
|
41
47
|
for (const key of keys) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await zoteroApi
|
|
51
|
+
.library(libraryType, userId)
|
|
52
|
+
.items(key)
|
|
53
|
+
.get();
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Unknown keys are collected and reported together by the caller; any other failure aborts at once
|
|
57
|
+
if (isZoteroApiError(err) && err.response?.status === 404)
|
|
58
|
+
continue;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
46
61
|
const zoteroItem = response.getData();
|
|
47
62
|
cslData.set(key, zoteroItemToCsl(zoteroItem));
|
|
48
63
|
}
|
|
@@ -100,10 +115,16 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
|
|
|
100
115
|
const { dir, name, ext } = parse(filePath);
|
|
101
116
|
const outputPath = format({ dir, name: `${name}_cited`, ext });
|
|
102
117
|
const fileBuffer = await readFile(filePath);
|
|
103
|
-
|
|
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
|
+
}
|
|
104
125
|
const documentEntry = zip.file("word/document.xml");
|
|
105
126
|
if (!documentEntry) {
|
|
106
|
-
throw new Error(
|
|
127
|
+
throw new Error(`Invalid .docx file: word/document.xml not found. ${REGENERATE_DOCX}`);
|
|
107
128
|
}
|
|
108
129
|
let documentXml = await documentEntry.async("string");
|
|
109
130
|
documentXml = normalizeZciteTags(documentXml);
|
|
@@ -111,32 +132,44 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
|
|
|
111
132
|
if (matches.length === 0) {
|
|
112
133
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
|
113
134
|
await writeFile(outputPath, buffer);
|
|
114
|
-
return { outputPath, found: 0, injected: 0, warnings: [] };
|
|
135
|
+
return { outputPath, found: 0, injected: 0, warnings: [NO_ZCITE_WARNING] };
|
|
115
136
|
}
|
|
116
137
|
// Warn if using a numbered style but tags are missing the num attribute
|
|
117
138
|
const warnings = [];
|
|
118
139
|
if (style === "ieee" || style === "vancouver") {
|
|
119
140
|
const withNum = matches.filter((m) => m.num !== undefined).length;
|
|
120
141
|
if (withNum < matches.length) {
|
|
121
|
-
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.');
|
|
122
145
|
}
|
|
123
146
|
}
|
|
124
147
|
// Collect all unique item keys
|
|
125
148
|
const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
|
|
126
|
-
// Keys from the document go into API URL paths (one GET per key)
|
|
127
|
-
// <zcite> cannot point the request at another endpoint ("../", "?", "&")
|
|
128
|
-
|
|
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));
|
|
129
153
|
if (invalid.length > 0) {
|
|
130
|
-
throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (
|
|
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.");
|
|
131
156
|
}
|
|
132
157
|
// Fetch CSL data from Zotero
|
|
133
158
|
const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
|
|
159
|
+
// No placeholder citations in the user's document: a citation of a nonexistent item fails the injection,
|
|
160
|
+
// like the skill's inject.js with a key missing from metadata.json
|
|
161
|
+
const missingKeys = [...uniqueKeys].filter((k) => !cslData.has(k));
|
|
162
|
+
if (missingKeys.length > 0) {
|
|
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.");
|
|
166
|
+
}
|
|
134
167
|
// Replace each zcite tag with a field code
|
|
135
168
|
let injected = 0;
|
|
136
169
|
for (const match of matches) {
|
|
137
170
|
const citationItems = buildCitationItems(match, cslData, libraryType, userId);
|
|
138
171
|
const itemDataList = match.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
|
|
139
|
-
const formattedText = formatCitationText(itemDataList, style, match
|
|
172
|
+
const formattedText = formatCitationText(itemDataList, style, match);
|
|
140
173
|
const fieldCodeXml = generateZoteroFieldCode(citationItems, formattedText);
|
|
141
174
|
const newXml = replaceZciteInXml(documentXml, match.fullMatch, fieldCodeXml);
|
|
142
175
|
if (newXml !== documentXml) {
|
|
@@ -147,8 +180,15 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
|
|
|
147
180
|
// Append bibliography before </w:body>
|
|
148
181
|
const biblXml = generateBibliographyFieldCode();
|
|
149
182
|
documentXml = documentXml.replace("</w:body>", `${biblXml}</w:body>`);
|
|
150
|
-
//
|
|
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)
|
|
151
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
|
|
152
192
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
|
153
193
|
await writeFile(outputPath, buffer);
|
|
154
194
|
return { outputPath, found: matches.length, injected, warnings };
|
package/build/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerAllTools } from "./tools/index.js";
|
|
|
8
8
|
import { logger } from "./utils/logger.js";
|
|
9
9
|
import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
|
|
10
10
|
import { getDefaultLibrary } from "./utils/library-context.js";
|
|
11
|
+
import { withRequestTimeout } from "./utils/timeouts.js";
|
|
11
12
|
// Report the package version in the MCP handshake (package.json sits one level above both src/ and build/).
|
|
12
13
|
const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
13
14
|
// Re-export for backward compatibility with existing tests
|
|
@@ -27,13 +28,16 @@ class ZoteroServer {
|
|
|
27
28
|
"CITATION WORKFLOW:",
|
|
28
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)",
|
|
29
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.",
|
|
30
|
-
"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.",
|
|
31
32
|
"4. Tell the user to open it in Word with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
|
|
32
33
|
"",
|
|
33
34
|
"KEY NOTES:",
|
|
34
|
-
"- add_items_by_doi auto-attaches
|
|
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.",
|
|
35
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 [?].",
|
|
36
|
-
"-
|
|
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.",
|
|
37
41
|
].join("\n"),
|
|
38
42
|
});
|
|
39
43
|
this.unsafeOps = parseUnsafeOperations(process.env.UNSAFE_OPERATIONS);
|
|
@@ -44,7 +48,7 @@ class ZoteroServer {
|
|
|
44
48
|
else {
|
|
45
49
|
const apiKey = process.env.ZOTERO_API_KEY || "";
|
|
46
50
|
if (!apiKey) {
|
|
47
|
-
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)");
|
|
48
52
|
}
|
|
49
53
|
// Validate the library configuration eagerly so a misconfigured server fails
|
|
50
54
|
// fast at startup rather than on the first tool call. ZOTERO_LIBRARY_ID takes
|
|
@@ -54,7 +58,8 @@ class ZoteroServer {
|
|
|
54
58
|
// global fetch; the old lib/main-node.cjs (cross-fetch polyfill) no longer exists.
|
|
55
59
|
const require = createRequire(import.meta.url);
|
|
56
60
|
const { default: zoteroApiFactory } = require("zotero-api-client");
|
|
57
|
-
|
|
61
|
+
// every request carries its own AbortSignal.timeout (TIMEOUTS.zoteroApiMs)
|
|
62
|
+
this.zoteroApi = withRequestTimeout(zoteroApiFactory(apiKey));
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
setupHandlers() {
|