@xevos117/mcp-zotero 2.0.2 → 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 +50 -8
- package/build/citation-injector/citation-formatter.js +66 -7
- package/build/citation-injector/document-prefs.js +121 -0
- package/build/citation-injector/injector.js +35 -11
- package/build/server.js +7 -4
- package/build/tools/add-items-by-doi.js +14 -6
- 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 +27 -12
- 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 +41 -7
- 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/csl-to-zotero.js +39 -14
- package/build/utils/doi-resolver.js +10 -2
- package/build/utils/error-hints.js +179 -0
- package/build/utils/html-entities.js +55 -0
- package/build/utils/library-context.js +11 -11
- package/build/utils/pdf-uploader.js +2 -0
- package/build/utils/unpaywall.js +24 -5
- package/build/utils/zotero-keys.js +2 -1
- package/package.json +5 -1
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
|
|
@@ -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`,
|
|
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
|
|
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.
|
|
318
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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 = /<zcite\s+[\s\S]*?\/>/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
|
-
|
|
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(
|
|
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)
|
|
137
|
-
// <zcite> cannot point the request at another endpoint ("../", "?", "&")
|
|
138
|
-
|
|
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(", ")} (
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
"-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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}`, {
|
|
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).
|
|
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
|
}
|
package/build/tools/add-items.js
CHANGED
|
@@ -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 (
|
|
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", {
|
|
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("
|
|
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
|
|
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();
|