@xevos117/mcp-zotero 1.0.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -3
- package/build/citation-injector/injector.js +16 -12
- package/build/server.js +16 -7
- package/build/tools/add-items-by-doi.js +53 -123
- package/build/tools/add-items.js +22 -57
- package/build/tools/add-linked-url-attachment.js +24 -43
- package/build/tools/create-collection.js +24 -43
- package/build/tools/delete-collection.js +12 -23
- package/build/tools/delete-items.js +11 -27
- package/build/tools/find-and-attach-pdfs.js +56 -128
- package/build/tools/get-collection-items.js +13 -24
- package/build/tools/get-collections.js +21 -37
- package/build/tools/get-item-fulltext.js +14 -27
- package/build/tools/get-items-details.js +40 -55
- package/build/tools/get-user-id.js +7 -6
- package/build/tools/import-pdf-to-zotero.js +26 -35
- package/build/tools/index.js +22 -29
- package/build/tools/inject-citations.js +15 -34
- package/build/tools/search-library.js +27 -43
- package/build/utils/attachments.js +9 -0
- package/build/utils/concurrency.js +19 -1
- package/build/utils/csl-to-zotero.js +12 -5
- package/build/utils/doi-resolver.js +2 -1
- package/build/utils/error-formatter.js +36 -11
- package/build/utils/library-context.js +99 -0
- package/build/utils/oa-pdf.js +86 -0
- package/build/utils/pagination.js +25 -2
- package/build/utils/pdf-text-extractor.js +3 -2
- package/build/utils/pdf-uploader.js +140 -42
- package/build/utils/unpaywall.js +37 -70
- package/build/utils/write-results.js +57 -0
- package/build/utils/zotero-fulltext.js +8 -2
- package/package.json +15 -13
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ LLMs without filesystem access — including Claude Desktop, which connects to M
|
|
|
32
32
|
|
|
33
33
|
## Setup
|
|
34
34
|
|
|
35
|
+
Requires **Node.js 22 or newer**.
|
|
36
|
+
|
|
35
37
|
1. Get your Zotero credentials:
|
|
36
38
|
|
|
37
39
|
```bash
|
|
@@ -50,6 +52,65 @@ LLMs without filesystem access — including Claude Desktop, which connects to M
|
|
|
50
52
|
export UNSAFE_OPERATIONS="none" # Optional: "none" | "items" | "all" (see below)
|
|
51
53
|
```
|
|
52
54
|
|
|
55
|
+
### Targeting a group library
|
|
56
|
+
|
|
57
|
+
By default the server targets your personal Zotero library (`/users/<ZOTERO_USER_ID>/...`).
|
|
58
|
+
To target a group library instead, set two additional environment variables:
|
|
59
|
+
|
|
60
|
+
| Variable | Values | Required? | Default |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `ZOTERO_LIBRARY_TYPE` | `user` \| `group` | No | `user` |
|
|
63
|
+
| `ZOTERO_LIBRARY_ID` | numeric library ID | Yes when type=`group`; otherwise falls back to `ZOTERO_USER_ID` | — |
|
|
64
|
+
|
|
65
|
+
Example for a group library:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
ZOTERO_API_KEY=...
|
|
69
|
+
ZOTERO_USER_ID=12345 # optional for groups: used by get_user_id and per-call library_type="user"
|
|
70
|
+
ZOTERO_LIBRARY_TYPE=group
|
|
71
|
+
ZOTERO_LIBRARY_ID=6178978 # the group ID
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The API key in `ZOTERO_API_KEY` must have access to the target group library — generate or scope keys at https://www.zotero.org/settings/keys.
|
|
75
|
+
|
|
76
|
+
The server refuses to start if `ZOTERO_LIBRARY_TYPE=group` is set without `ZOTERO_LIBRARY_ID`, or if a library ID is not numeric.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
### Per-call library override
|
|
80
|
+
|
|
81
|
+
Every tool accepts two optional args that override the env defaults for that single call:
|
|
82
|
+
|
|
83
|
+
- `library_type` — `"user"` or `"group"`
|
|
84
|
+
- `library_id` — numeric library ID
|
|
85
|
+
|
|
86
|
+
Resolution order: per-call arg > env var > implicit default (`user`, `ZOTERO_USER_ID`). `library_id` must be numeric. Overriding `library_type` to `group` on a server configured for a user library requires `library_id`; overriding to `user` on a group server without `library_id` uses `ZOTERO_USER_ID`.
|
|
87
|
+
|
|
88
|
+
Note that per-call overrides let the LLM reach any library the API key can access. Scope the key to the libraries you want exposed; deletions remain gated by `UNSAFE_OPERATIONS`.
|
|
89
|
+
|
|
90
|
+
This lets a single MCP instance target multiple libraries (e.g., a staging group and a final-output group) without restarting:
|
|
91
|
+
|
|
92
|
+
```jsonc
|
|
93
|
+
// First call targets the staging group
|
|
94
|
+
{
|
|
95
|
+
"tool": "add_items",
|
|
96
|
+
"args": {
|
|
97
|
+
"items": [...],
|
|
98
|
+
"library_type": "group",
|
|
99
|
+
"library_id": "5597114"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Second call targets DART-output (different group)
|
|
104
|
+
{
|
|
105
|
+
"tool": "add_items",
|
|
106
|
+
"args": {
|
|
107
|
+
"items": [...],
|
|
108
|
+
"library_type": "group",
|
|
109
|
+
"library_id": "6178978"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
53
114
|
## Environment Variables
|
|
54
115
|
|
|
55
116
|
| Variable | Required | Description |
|
|
@@ -75,7 +136,7 @@ To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of t
|
|
|
75
136
|
|
|
76
137
|
- If `UNSAFE_OPERATIONS` is not set, empty, or set to an unrecognized value, it defaults to `none`.
|
|
77
138
|
- The value is **case-insensitive** (e.g. `ALL`, `Items`, `NONE` all work).
|
|
78
|
-
- `delete_items`
|
|
139
|
+
- `delete_items` deletes items **permanently**: the Zotero Web API multi-item DELETE does not move them to the Zotero trash, so they cannot be restored from the desktop client.
|
|
79
140
|
- `delete_collection` removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
|
|
80
141
|
- The `all` value includes both item and collection deletion because managing collections inherently requires item-level access.
|
|
81
142
|
|
|
@@ -132,7 +193,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
132
193
|
| `get_collections` | List all collections (folders) with keys, names, and parent relationships |
|
|
133
194
|
| `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
|
|
134
195
|
| `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
|
|
135
|
-
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, 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` |
|
|
136
197
|
| `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
|
|
137
198
|
|
|
138
199
|
### Adding content
|
|
@@ -150,7 +211,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
150
211
|
|
|
151
212
|
| Tool | Description |
|
|
152
213
|
|---|---|
|
|
153
|
-
| `delete_items` |
|
|
214
|
+
| `delete_items` | Permanently delete up to 50 items per call (not moved to the Zotero trash). Requires `UNSAFE_OPERATIONS=items` or `all` |
|
|
154
215
|
| `delete_collection` | Delete a collection (folder). Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
|
|
155
216
|
|
|
156
217
|
### Citation & documents
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import JSZip from "jszip";
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { format, parse } from "node:path";
|
|
3
4
|
import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-codes.js";
|
|
4
5
|
import { formatCitationText } from "./citation-formatter.js";
|
|
5
6
|
import { regexEscape, unescapeXml } from "./xml-utils.js";
|
|
@@ -20,11 +21,13 @@ function parseZciteMatches(documentXml) {
|
|
|
20
21
|
// and values like "pp. 12 & 15" need a second unescape
|
|
21
22
|
attrs[attrMatch[1]] = unescapeXml(attrMatch[2]);
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
// Tolerate "KEY1, KEY2": a stray space would otherwise become part of the key (404 on fetch)
|
|
25
|
+
const keys = (attrs["keys"] ?? "").split(",").map((k) => k.trim()).filter(Boolean);
|
|
26
|
+
if (keys.length === 0)
|
|
24
27
|
continue;
|
|
25
28
|
matches.push({
|
|
26
29
|
fullMatch,
|
|
27
|
-
keys
|
|
30
|
+
keys,
|
|
28
31
|
locator: attrs["locator"] || undefined,
|
|
29
32
|
prefix: attrs["prefix"] || undefined,
|
|
30
33
|
suffix: attrs["suffix"] || undefined,
|
|
@@ -33,11 +36,11 @@ function parseZciteMatches(documentXml) {
|
|
|
33
36
|
}
|
|
34
37
|
return matches;
|
|
35
38
|
}
|
|
36
|
-
async function fetchCslData(keys, zoteroApi, userId) {
|
|
39
|
+
async function fetchCslData(keys, zoteroApi, libraryType, userId) {
|
|
37
40
|
const cslData = new Map();
|
|
38
41
|
for (const key of keys) {
|
|
39
42
|
const response = await zoteroApi
|
|
40
|
-
.library(
|
|
43
|
+
.library(libraryType, userId)
|
|
41
44
|
.items(key)
|
|
42
45
|
.get();
|
|
43
46
|
const zoteroItem = response.getData();
|
|
@@ -45,13 +48,13 @@ async function fetchCslData(keys, zoteroApi, userId) {
|
|
|
45
48
|
}
|
|
46
49
|
return cslData;
|
|
47
50
|
}
|
|
48
|
-
function buildCitationItems(match, cslData, userId) {
|
|
51
|
+
function buildCitationItems(match, cslData, libraryType, userId) {
|
|
49
52
|
return match.keys.map((key, idx) => {
|
|
50
53
|
const itemData = cslData.get(key) ?? { type: "article-journal" };
|
|
51
54
|
const item = {
|
|
52
55
|
id: idx,
|
|
53
|
-
uris: [`http://zotero.org
|
|
54
|
-
uri: [`http://zotero.org
|
|
56
|
+
uris: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
|
|
57
|
+
uri: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
|
|
55
58
|
itemData,
|
|
56
59
|
};
|
|
57
60
|
if (match.locator)
|
|
@@ -92,7 +95,10 @@ function replaceZciteInXml(xml, escapedZciteTag, fieldCodeXml) {
|
|
|
92
95
|
}
|
|
93
96
|
return xml;
|
|
94
97
|
}
|
|
95
|
-
export async function injectCitations(filePath, zoteroApi, userId, style) {
|
|
98
|
+
export async function injectCitations(filePath, zoteroApi, libraryType, userId, style) {
|
|
99
|
+
// Derive "<name>_cited<ext>" from the basename only: a directory containing ".docx" stays untouched
|
|
100
|
+
const { dir, name, ext } = parse(filePath);
|
|
101
|
+
const outputPath = format({ dir, name: `${name}_cited`, ext });
|
|
96
102
|
const fileBuffer = await readFile(filePath);
|
|
97
103
|
const zip = await JSZip.loadAsync(fileBuffer);
|
|
98
104
|
const documentEntry = zip.file("word/document.xml");
|
|
@@ -103,7 +109,6 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
|
|
|
103
109
|
documentXml = normalizeZciteTags(documentXml);
|
|
104
110
|
const matches = parseZciteMatches(documentXml);
|
|
105
111
|
if (matches.length === 0) {
|
|
106
|
-
const outputPath = filePath.replace(".docx", "_cited.docx");
|
|
107
112
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
|
108
113
|
await writeFile(outputPath, buffer);
|
|
109
114
|
return { outputPath, found: 0, injected: 0, warnings: [] };
|
|
@@ -119,11 +124,11 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
|
|
|
119
124
|
// Collect all unique item keys
|
|
120
125
|
const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
|
|
121
126
|
// Fetch CSL data from Zotero
|
|
122
|
-
const cslData = await fetchCslData(uniqueKeys, zoteroApi, userId);
|
|
127
|
+
const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
|
|
123
128
|
// Replace each zcite tag with a field code
|
|
124
129
|
let injected = 0;
|
|
125
130
|
for (const match of matches) {
|
|
126
|
-
const citationItems = buildCitationItems(match, cslData, userId);
|
|
131
|
+
const citationItems = buildCitationItems(match, cslData, libraryType, userId);
|
|
127
132
|
const itemDataList = match.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
|
|
128
133
|
const formattedText = formatCitationText(itemDataList, style, match.num);
|
|
129
134
|
const fieldCodeXml = generateZoteroFieldCode(citationItems, formattedText);
|
|
@@ -138,7 +143,6 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
|
|
|
138
143
|
documentXml = documentXml.replace("</w:body>", `${biblXml}</w:body>`);
|
|
139
144
|
// Save
|
|
140
145
|
zip.file("word/document.xml", documentXml);
|
|
141
|
-
const outputPath = filePath.replace(".docx", "_cited.docx");
|
|
142
146
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
|
143
147
|
await writeFile(outputPath, buffer);
|
|
144
148
|
return { outputPath, found: matches.length, injected, warnings };
|
package/build/server.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import "dotenv
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
dotenv.config({ quiet: true });
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
import { createRequire } from "module";
|
|
6
7
|
import { registerAllTools } from "./tools/index.js";
|
|
7
8
|
import { logger } from "./utils/logger.js";
|
|
8
9
|
import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
|
|
10
|
+
import { getDefaultLibrary } from "./utils/library-context.js";
|
|
11
|
+
// Report the package version in the MCP handshake (package.json sits one level above both src/ and build/).
|
|
12
|
+
const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
9
13
|
// Re-export for backward compatibility with existing tests
|
|
10
14
|
export { formatErrorResponse } from "./utils/error-formatter.js";
|
|
11
15
|
export { handleToolCall } from "./tools/index.js";
|
|
@@ -15,7 +19,7 @@ class ZoteroServer {
|
|
|
15
19
|
userId;
|
|
16
20
|
unsafeOps;
|
|
17
21
|
constructor(options = {}) {
|
|
18
|
-
this.server = new McpServer({ name: "zotero", version:
|
|
22
|
+
this.server = new McpServer({ name: "zotero", version: SERVER_VERSION }, {
|
|
19
23
|
capabilities: { tools: {} },
|
|
20
24
|
instructions: [
|
|
21
25
|
"Zotero library management and citation injection for Word documents.",
|
|
@@ -24,7 +28,7 @@ class ZoteroServer {
|
|
|
24
28
|
"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)",
|
|
25
29
|
"2. Generate .docx: create a Word document with <zcite keys=\"ITEMKEY\"/> placeholders where citations should appear. Each <zcite> must be in its own TextRun.",
|
|
26
30
|
"3. Call inject_citations with the .docx file path — it fetches metadata from Zotero automatically.",
|
|
27
|
-
"4. Tell the user to open in Word with Zotero plugin →
|
|
31
|
+
"4. Tell the user to open it in Word with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
|
|
28
32
|
"",
|
|
29
33
|
"KEY NOTES:",
|
|
30
34
|
"- add_items_by_doi auto-attaches OA PDFs via Unpaywall at no cost. Do not disable auto_attach_pdf unless it causes errors.",
|
|
@@ -38,13 +42,18 @@ class ZoteroServer {
|
|
|
38
42
|
this.zoteroApi = options.zoteroApi;
|
|
39
43
|
}
|
|
40
44
|
else {
|
|
41
|
-
this.userId = process.env.ZOTERO_USER_ID || "";
|
|
42
45
|
const apiKey = process.env.ZOTERO_API_KEY || "";
|
|
43
|
-
if (!apiKey
|
|
44
|
-
throw new Error("Missing ZOTERO_API_KEY
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
throw new Error("Missing ZOTERO_API_KEY environment variable");
|
|
45
48
|
}
|
|
49
|
+
// Validate the library configuration eagerly so a misconfigured server fails
|
|
50
|
+
// fast at startup rather than on the first tool call. ZOTERO_LIBRARY_ID takes
|
|
51
|
+
// precedence (group libraries); ZOTERO_USER_ID is the user-library fallback.
|
|
52
|
+
this.userId = getDefaultLibrary().id;
|
|
53
|
+
// zotero-api-client >= 0.51 ships a single CJS entry (lib/main.cjs) that relies on the
|
|
54
|
+
// global fetch; the old lib/main-node.cjs (cross-fetch polyfill) no longer exists.
|
|
46
55
|
const require = createRequire(import.meta.url);
|
|
47
|
-
const zoteroApiFactory = require("zotero-api-client
|
|
56
|
+
const { default: zoteroApiFactory } = require("zotero-api-client");
|
|
48
57
|
this.zoteroApi = zoteroApiFactory(apiKey);
|
|
49
58
|
}
|
|
50
59
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import { formatErrorResponse } from "../utils/error-formatter.js";
|
|
2
|
+
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
4
3
|
import { resolveDois } from "../utils/doi-resolver.js";
|
|
5
4
|
import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
5
|
+
import { postInBatches } from "../utils/write-results.js";
|
|
6
|
+
import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
|
|
7
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
10
8
|
export const toolConfig = {
|
|
11
9
|
name: "add_items_by_doi",
|
|
12
10
|
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.
|
|
@@ -35,130 +33,62 @@ WORKFLOW TIPS:
|
|
|
35
33
|
.boolean()
|
|
36
34
|
.default(true)
|
|
37
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
|
+
...libraryArgsSchema,
|
|
38
37
|
},
|
|
39
38
|
};
|
|
40
39
|
const AddItemsByDoiSchema = z.object(toolConfig.inputSchema);
|
|
41
|
-
async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
42
|
-
const itemsWithDoi = items.filter((item) => item.doi);
|
|
43
|
-
if (itemsWithDoi.length === 0)
|
|
44
|
-
return [];
|
|
45
|
-
// Probe Unpaywall config with the first DOI — if email is bad, skip entirely
|
|
46
|
-
const probe = await lookupOaPdf(itemsWithDoi[0].doi);
|
|
47
|
-
if (probe.warning) {
|
|
48
|
-
return [{
|
|
49
|
-
item_key: itemsWithDoi[0].item_key,
|
|
50
|
-
doi: itemsWithDoi[0].doi,
|
|
51
|
-
pdf_attached: false,
|
|
52
|
-
source: null,
|
|
53
|
-
error: probe.warning,
|
|
54
|
-
}];
|
|
55
|
-
}
|
|
56
|
-
// Email is valid — process all items in parallel (reuse probe for first)
|
|
57
|
-
const settled = await mapWithConcurrency(itemsWithDoi, async (item, i) => {
|
|
58
|
-
const oaResult = i === 0 ? probe : await lookupOaPdf(item.doi);
|
|
59
|
-
if (oaResult.found && oaResult.pdf_url) {
|
|
60
|
-
const uploadResult = await downloadAndUploadPdf(zoteroApi, userId, apiKey, {
|
|
61
|
-
url: oaResult.pdf_url,
|
|
62
|
-
parentItem: item.item_key,
|
|
63
|
-
});
|
|
64
|
-
return {
|
|
65
|
-
item_key: item.item_key,
|
|
66
|
-
doi: item.doi,
|
|
67
|
-
pdf_attached: uploadResult.success,
|
|
68
|
-
source: oaResult.source,
|
|
69
|
-
error: uploadResult.success ? undefined : uploadResult.error.message,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
item_key: item.item_key,
|
|
74
|
-
doi: item.doi,
|
|
75
|
-
pdf_attached: false,
|
|
76
|
-
source: null,
|
|
77
|
-
oa_status: oaResult.oa_status ?? undefined,
|
|
78
|
-
landing_url: oaResult.landing_url ?? undefined,
|
|
79
|
-
error: oaResult.landing_url
|
|
80
|
-
? "Open access copy exists at a repository but no direct PDF link is available. The user can download it manually from the landing page and use import_pdf_to_zotero to attach it."
|
|
81
|
-
: "No open access PDF found",
|
|
82
|
-
};
|
|
83
|
-
});
|
|
84
|
-
return settled
|
|
85
|
-
.filter((r) => r.status === "fulfilled")
|
|
86
|
-
.map((r) => r.value);
|
|
87
|
-
}
|
|
88
40
|
export async function handleAddItemsByDoi(zoteroApi, userId, args) {
|
|
89
|
-
const { dois, collection_key, tags, auto_attach_pdf } = AddItemsByDoiSchema.parse(args);
|
|
41
|
+
const { dois, collection_key, tags, auto_attach_pdf, library_type, library_id } = AddItemsByDoiSchema.parse(args);
|
|
42
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
90
43
|
if (dois.length === 0) {
|
|
91
44
|
return formatErrorResponse("At least one DOI is required");
|
|
92
45
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
|
|
101
|
-
collectionKey: collection_key,
|
|
102
|
-
tags,
|
|
103
|
-
}));
|
|
104
|
-
const response = await zoteroApi
|
|
105
|
-
.library("user", userId)
|
|
106
|
-
.items()
|
|
107
|
-
.post(zoteroItems);
|
|
108
|
-
if (!response.isSuccess()) {
|
|
109
|
-
const errors = response.getErrors();
|
|
110
|
-
const errorMessages = Object.entries(errors)
|
|
111
|
-
.map(([idx, msg]) => `Item ${idx}: ${typeof msg === "object" ? JSON.stringify(msg) : msg}`)
|
|
112
|
-
.join(", ");
|
|
113
|
-
return formatErrorResponse(`Zotero API write failed: ${errorMessages}`);
|
|
114
|
-
}
|
|
115
|
-
const createdItems = response.getData();
|
|
116
|
-
if (!createdItems || !Array.isArray(createdItems) || createdItems.length === 0) {
|
|
117
|
-
return formatErrorResponse("Zotero API returned empty response - items may not have been created");
|
|
118
|
-
}
|
|
119
|
-
const success = [];
|
|
120
|
-
for (let i = 0; i < resolved.success.length; i++) {
|
|
121
|
-
const r = resolved.success[i];
|
|
122
|
-
const entity = response.getEntityByIndex(i);
|
|
123
|
-
const itemKey = entity?.key;
|
|
124
|
-
if (!itemKey) {
|
|
125
|
-
return formatErrorResponse(`Failed to get item key for DOI ${r.doi} at index ${i}. API response may be invalid.`);
|
|
126
|
-
}
|
|
127
|
-
success.push({
|
|
128
|
-
doi: r.doi,
|
|
129
|
-
item_key: itemKey,
|
|
130
|
-
title: entity?.title ?? r.data.title ?? "Untitled",
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
let pdf_results;
|
|
134
|
-
if (auto_attach_pdf) {
|
|
135
|
-
const apiKey = process.env.ZOTERO_API_KEY;
|
|
136
|
-
if (apiKey) {
|
|
137
|
-
pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
content: [
|
|
142
|
-
{
|
|
143
|
-
type: "text",
|
|
144
|
-
text: JSON.stringify({
|
|
145
|
-
success,
|
|
146
|
-
failed: resolved.failed,
|
|
147
|
-
...(pdf_results !== undefined ? { pdf_results } : {}),
|
|
148
|
-
}, null, 2),
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
};
|
|
46
|
+
const resolved = await resolveDois(dois);
|
|
47
|
+
if (resolved.success.length === 0) {
|
|
48
|
+
return formatErrorResponse("All DOI resolutions failed", {
|
|
49
|
+
failed: resolved.failed,
|
|
50
|
+
});
|
|
152
51
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
52
|
+
const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
|
|
53
|
+
collectionKey: collection_key,
|
|
54
|
+
tags,
|
|
55
|
+
}));
|
|
56
|
+
const write = await postInBatches((batch) => zoteroApi.library(libraryType, libraryId).items().post(batch), zoteroItems);
|
|
57
|
+
// Report resolution and write failures together, so a partial write never hides created items
|
|
58
|
+
const failed = [
|
|
59
|
+
...resolved.failed,
|
|
60
|
+
...write.failed.map(({ index, error }) => ({ doi: resolved.success[index].doi, error })),
|
|
61
|
+
];
|
|
62
|
+
if (write.created.length === 0) {
|
|
63
|
+
const errorMessages = write.failed.map(({ index, error }) => `Item ${index}: ${error}`).join("; ");
|
|
64
|
+
return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, { failed });
|
|
163
65
|
}
|
|
66
|
+
const success = write.created.map(({ index, entity }) => {
|
|
67
|
+
const { doi, data } = resolved.success[index];
|
|
68
|
+
return { doi, item_key: entity.key, title: entity.title ?? data.title ?? "Untitled" };
|
|
69
|
+
});
|
|
70
|
+
let pdf_results;
|
|
71
|
+
const apiKey = process.env.ZOTERO_API_KEY;
|
|
72
|
+
if (auto_attach_pdf && apiKey) {
|
|
73
|
+
const targets = success.filter((s) => s.doi).map((s) => ({ itemKey: s.item_key, doi: s.doi }));
|
|
74
|
+
const results = await attachOpenAccessPdfs(targets, {
|
|
75
|
+
zoteroApi,
|
|
76
|
+
library: { type: libraryType, id: libraryId },
|
|
77
|
+
apiKey,
|
|
78
|
+
});
|
|
79
|
+
pdf_results = results.map((r) => r.status === "attached"
|
|
80
|
+
? { ...r, pdf_attached: true, pdf_url: r.url_used }
|
|
81
|
+
: { ...r, pdf_attached: false, error: r.reason });
|
|
82
|
+
}
|
|
83
|
+
const quotaHit = pdf_results?.some((r) => r.status === "quota_exceeded");
|
|
84
|
+
return formatJsonResult({
|
|
85
|
+
success,
|
|
86
|
+
failed,
|
|
87
|
+
...(pdf_results !== undefined ? { pdf_results } : {}),
|
|
88
|
+
...(quotaHit
|
|
89
|
+
? {
|
|
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.",
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
});
|
|
164
94
|
}
|
package/build/tools/add-items.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { logger } from "../utils/logger.js";
|
|
2
|
+
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
3
|
+
import { postInBatches } from "../utils/write-results.js";
|
|
5
4
|
import { ZOTERO_ITEM_TYPES, ITEM_TYPE_FIELDS, ITEM_TYPE_CREATOR_TYPES, TITLE_FIELD_NAME, } from "../utils/zotero-item-types.js";
|
|
5
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
6
6
|
const CreatorSchema = z
|
|
7
7
|
.object({
|
|
8
8
|
firstName: z.string().optional(),
|
|
@@ -28,7 +28,7 @@ const ItemSchema = z
|
|
|
28
28
|
continue;
|
|
29
29
|
if (!validFields.has(key)) {
|
|
30
30
|
ctx.addIssue({
|
|
31
|
-
code:
|
|
31
|
+
code: "custom",
|
|
32
32
|
message: `Field "${key}" is not valid for itemType "${data.itemType}". Valid fields: ${[...validFields].join(", ")}`,
|
|
33
33
|
path: [key],
|
|
34
34
|
});
|
|
@@ -38,7 +38,7 @@ const ItemSchema = z
|
|
|
38
38
|
for (const [i, creator] of (data.creators ?? []).entries()) {
|
|
39
39
|
if (!validCreatorTypes.has(creator.creatorType)) {
|
|
40
40
|
ctx.addIssue({
|
|
41
|
-
code:
|
|
41
|
+
code: "custom",
|
|
42
42
|
message: `Invalid creatorType "${creator.creatorType}" for "${data.itemType}". Valid: ${[...validCreatorTypes].join(", ")}`,
|
|
43
43
|
path: ["creators", i, "creatorType"],
|
|
44
44
|
});
|
|
@@ -89,11 +89,13 @@ Invalid fields or creatorTypes for a given type are rejected with helpful error
|
|
|
89
89
|
.array(z.string())
|
|
90
90
|
.optional()
|
|
91
91
|
.describe("Tags to apply to all items"),
|
|
92
|
+
...libraryArgsSchema,
|
|
92
93
|
},
|
|
93
94
|
};
|
|
94
95
|
const AddItemsSchema = z.object(toolConfig.inputSchema);
|
|
95
96
|
export async function handleAddItems(zoteroApi, userId, args) {
|
|
96
|
-
const { items, collection_key, tags } = AddItemsSchema.parse(args);
|
|
97
|
+
const { items, collection_key, tags, library_type, library_id } = AddItemsSchema.parse(args);
|
|
98
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
97
99
|
const payloads = items.map((item) => {
|
|
98
100
|
const { itemType, title, creators, ...fields } = item;
|
|
99
101
|
const titleFieldName = TITLE_FIELD_NAME[itemType] ?? "title";
|
|
@@ -119,56 +121,19 @@ export async function handleAddItems(zoteroApi, userId, args) {
|
|
|
119
121
|
}
|
|
120
122
|
return payload;
|
|
121
123
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const i = Number(idx);
|
|
133
|
-
failed.push({ index: i, title: items[i].title, error: msg });
|
|
134
|
-
}
|
|
135
|
-
const created = response.getData();
|
|
136
|
-
let successIdx = 0;
|
|
137
|
-
for (let i = 0; i < items.length; i++) {
|
|
138
|
-
if (!errorIndices.has(String(i))) {
|
|
139
|
-
const entity = created[successIdx++];
|
|
140
|
-
success.push({
|
|
141
|
-
index: i,
|
|
142
|
-
item_key: entity?.key ?? "unknown",
|
|
143
|
-
title: entity?.title ?? items[i].title,
|
|
144
|
-
item_type: items[i].itemType,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (success.length === 0 && failed.length > 0) {
|
|
149
|
-
return formatErrorResponse("All items failed to create", { failed });
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
content: [
|
|
153
|
-
{
|
|
154
|
-
type: "text",
|
|
155
|
-
text: JSON.stringify({
|
|
156
|
-
success,
|
|
157
|
-
...(failed.length > 0 ? { failed } : {}),
|
|
158
|
-
}, null, 2),
|
|
159
|
-
},
|
|
160
|
-
],
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
if (isZoteroApiError(err)) {
|
|
165
|
-
logger.error("Tool execution failed", {
|
|
166
|
-
tool: "add_items",
|
|
167
|
-
status: err.response?.status,
|
|
168
|
-
errorMessage: err.message,
|
|
169
|
-
url: err.response?.url,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
throw err;
|
|
124
|
+
const write = await postInBatches((batch) => zoteroApi.library(libraryType, libraryId).items().post(batch), payloads);
|
|
125
|
+
const success = write.created.map(({ index, entity }) => ({
|
|
126
|
+
index,
|
|
127
|
+
item_key: entity.key,
|
|
128
|
+
title: entity.title ?? items[index].title,
|
|
129
|
+
item_type: items[index].itemType,
|
|
130
|
+
}));
|
|
131
|
+
const failed = write.failed.map(({ index, error }) => ({ index, title: items[index].title, error }));
|
|
132
|
+
if (success.length === 0 && failed.length > 0) {
|
|
133
|
+
return formatErrorResponse("All items failed to create", { failed });
|
|
173
134
|
}
|
|
135
|
+
return formatJsonResult({
|
|
136
|
+
success,
|
|
137
|
+
...(failed.length > 0 ? { failed } : {}),
|
|
138
|
+
});
|
|
174
139
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
3
|
+
import { formatWriteErrors } from "../utils/write-results.js";
|
|
4
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
5
5
|
export const toolConfig = {
|
|
6
6
|
name: "add_linked_url_attachment",
|
|
7
7
|
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.",
|
|
@@ -27,11 +27,13 @@ export const toolConfig = {
|
|
|
27
27
|
.array(z.string())
|
|
28
28
|
.optional()
|
|
29
29
|
.describe("Tags to apply to the attachment"),
|
|
30
|
+
...libraryArgsSchema,
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
33
|
const AddLinkedUrlAttachmentSchema = z.object(toolConfig.inputSchema);
|
|
33
34
|
export async function handleAddLinkedUrlAttachment(zoteroApi, userId, args) {
|
|
34
|
-
const { url, title, content_type, parent_item, collections, tags } = AddLinkedUrlAttachmentSchema.parse(args);
|
|
35
|
+
const { url, title, content_type, parent_item, collections, tags, library_type, library_id } = AddLinkedUrlAttachmentSchema.parse(args);
|
|
36
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
35
37
|
const itemData = {
|
|
36
38
|
itemType: "attachment",
|
|
37
39
|
linkMode: "linked_url",
|
|
@@ -49,44 +51,23 @@ export async function handleAddLinkedUrlAttachment(zoteroApi, userId, args) {
|
|
|
49
51
|
else {
|
|
50
52
|
itemData.collections = collections ?? [];
|
|
51
53
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
details: errorMsg,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
const created = response.getData();
|
|
65
|
-
const item = created[0];
|
|
66
|
-
return {
|
|
67
|
-
content: [
|
|
68
|
-
{
|
|
69
|
-
type: "text",
|
|
70
|
-
text: JSON.stringify({
|
|
71
|
-
item_key: item.key,
|
|
72
|
-
title: item.title ?? title ?? url,
|
|
73
|
-
url,
|
|
74
|
-
parent_item: parent_item ?? null,
|
|
75
|
-
link_mode: "linked_url",
|
|
76
|
-
}, null, 2),
|
|
77
|
-
},
|
|
78
|
-
],
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
if (isZoteroApiError(err)) {
|
|
83
|
-
logger.error("Tool execution failed", {
|
|
84
|
-
tool: "add_linked_url_attachment",
|
|
85
|
-
status: err.response?.status,
|
|
86
|
-
errorMessage: err.message,
|
|
87
|
-
url: err.response?.url,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
throw err;
|
|
54
|
+
const response = await zoteroApi
|
|
55
|
+
.library(libraryType, libraryId)
|
|
56
|
+
.items()
|
|
57
|
+
.post([itemData]);
|
|
58
|
+
if (!response.isSuccess()) {
|
|
59
|
+
const errorMsg = formatWriteErrors(response.getErrors());
|
|
60
|
+
return formatErrorResponse("Failed to create linked URL attachment", {
|
|
61
|
+
details: errorMsg,
|
|
62
|
+
});
|
|
91
63
|
}
|
|
64
|
+
const created = response.getData();
|
|
65
|
+
const item = created[0];
|
|
66
|
+
return formatJsonResult({
|
|
67
|
+
item_key: item.key,
|
|
68
|
+
title: item.title ?? title ?? url,
|
|
69
|
+
url,
|
|
70
|
+
parent_item: parent_item ?? null,
|
|
71
|
+
link_mode: "linked_url",
|
|
72
|
+
});
|
|
92
73
|
}
|