@xevos117/mcp-zotero 1.0.7 → 1.0.9
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 +2 -2
- package/build/server.js +2 -1
- package/build/tools/add-items-by-doi.js +21 -3
- package/build/tools/add-items.js +2 -2
- package/build/tools/find-and-attach-pdfs.js +21 -2
- package/build/tools/get-collections.js +24 -5
- package/build/tools/get-items-details.js +22 -8
- package/build/tools/import-pdf-to-zotero.js +7 -0
- package/build/utils/concurrency.js +11 -1
- package/build/utils/csl-to-zotero.js +63 -6
- package/build/utils/pdf-uploader.js +97 -27
- package/build/utils/zotero-fulltext.js +6 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
132
132
|
| `get_collections` | List all collections (folders) with keys, names, and parent relationships |
|
|
133
133
|
| `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
|
|
134
134
|
| `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
|
|
135
|
-
| `get_items_details` | Batch metadata retrieval for multiple items
|
|
135
|
+
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.) |
|
|
136
136
|
| `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
|
|
137
137
|
|
|
138
138
|
### Adding content
|
|
@@ -165,7 +165,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
165
165
|
```bash
|
|
166
166
|
npm install
|
|
167
167
|
npm run build # Compile TypeScript
|
|
168
|
-
npm test # Run tests (vitest,
|
|
168
|
+
npm test # Run tests (vitest, 404 tests)
|
|
169
169
|
npx tsx src/server.ts # Run directly without building
|
|
170
170
|
```
|
|
171
171
|
|
package/build/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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 +6,7 @@ import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
|
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
import { lookupOaPdf } from "../utils/unpaywall.js";
|
|
8
8
|
import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
|
|
9
|
-
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
9
|
+
import { mapWithConcurrency, createCancellationToken } from "../utils/concurrency.js";
|
|
10
10
|
export const toolConfig = {
|
|
11
11
|
name: "add_items_by_doi",
|
|
12
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.
|
|
@@ -54,6 +54,7 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
54
54
|
}];
|
|
55
55
|
}
|
|
56
56
|
// Email is valid — process all items in parallel (reuse probe for first)
|
|
57
|
+
const cancelToken = createCancellationToken();
|
|
57
58
|
const settled = await mapWithConcurrency(itemsWithDoi, async (item, i) => {
|
|
58
59
|
const oaResult = i === 0 ? probe : await lookupOaPdf(item.doi);
|
|
59
60
|
if (oaResult.found && oaResult.pdf_url) {
|
|
@@ -61,6 +62,9 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
61
62
|
url: oaResult.pdf_url,
|
|
62
63
|
parentItem: item.item_key,
|
|
63
64
|
});
|
|
65
|
+
if (!uploadResult.success && uploadResult.error.code === "storage_quota_exceeded") {
|
|
66
|
+
cancelToken.cancelled = true;
|
|
67
|
+
}
|
|
64
68
|
return {
|
|
65
69
|
item_key: item.item_key,
|
|
66
70
|
doi: item.doi,
|
|
@@ -80,7 +84,7 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
80
84
|
? "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
85
|
: "No open access PDF found",
|
|
82
86
|
};
|
|
83
|
-
});
|
|
87
|
+
}, undefined, cancelToken);
|
|
84
88
|
return settled
|
|
85
89
|
.filter((r) => r.status === "fulfilled")
|
|
86
90
|
.map((r) => r.value);
|
|
@@ -131,12 +135,20 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
|
|
|
131
135
|
});
|
|
132
136
|
}
|
|
133
137
|
let pdf_results;
|
|
138
|
+
let pdf_attach_error;
|
|
134
139
|
if (auto_attach_pdf) {
|
|
135
140
|
const apiKey = process.env.ZOTERO_API_KEY;
|
|
136
141
|
if (apiKey) {
|
|
137
|
-
|
|
142
|
+
try {
|
|
143
|
+
pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
pdf_attach_error = `PDF attachment failed: ${err instanceof Error ? err.message : String(err)}. Items were created successfully.`;
|
|
147
|
+
logger.error("PDF attach phase failed", { error: pdf_attach_error });
|
|
148
|
+
}
|
|
138
149
|
}
|
|
139
150
|
}
|
|
151
|
+
const quotaHit = pdf_results?.some((r) => r.error?.includes("storage quota"));
|
|
140
152
|
return {
|
|
141
153
|
content: [
|
|
142
154
|
{
|
|
@@ -145,6 +157,12 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
|
|
|
145
157
|
success,
|
|
146
158
|
failed: resolved.failed,
|
|
147
159
|
...(pdf_results !== undefined ? { pdf_results } : {}),
|
|
160
|
+
...(pdf_attach_error !== undefined ? { pdf_attach_error } : {}),
|
|
161
|
+
...(quotaHit
|
|
162
|
+
? {
|
|
163
|
+
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.",
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
148
166
|
}, null, 2),
|
|
149
167
|
},
|
|
150
168
|
],
|
package/build/tools/add-items.js
CHANGED
|
@@ -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
|
});
|
|
@@ -4,7 +4,7 @@ import { formatErrorResponse } from "../utils/error-formatter.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { lookupOaPdfWithFallbacks } from "../utils/unpaywall.js";
|
|
6
6
|
import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
|
|
7
|
-
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
7
|
+
import { mapWithConcurrency, createCancellationToken } from "../utils/concurrency.js";
|
|
8
8
|
import { fetchAllPages } from "../utils/pagination.js";
|
|
9
9
|
export const toolConfig = {
|
|
10
10
|
name: "find_and_attach_pdfs",
|
|
@@ -72,6 +72,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
// 3. Process each item in parallel
|
|
75
|
+
const cancelToken = createCancellationToken();
|
|
75
76
|
const settled = await mapWithConcurrency(keys, async (key) => {
|
|
76
77
|
const item = itemMap.get(key);
|
|
77
78
|
if (!item) {
|
|
@@ -145,6 +146,15 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
145
146
|
pdf_url: pdfUrl,
|
|
146
147
|
};
|
|
147
148
|
}
|
|
149
|
+
if (!uploadResult.success && uploadResult.error.code === "storage_quota_exceeded") {
|
|
150
|
+
cancelToken.cancelled = true;
|
|
151
|
+
return {
|
|
152
|
+
item_key: key,
|
|
153
|
+
doi,
|
|
154
|
+
status: "quota_exceeded",
|
|
155
|
+
reason: uploadResult.error.message,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
148
158
|
}
|
|
149
159
|
return {
|
|
150
160
|
item_key: key,
|
|
@@ -153,7 +163,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
153
163
|
reason: `Download failed for all ${urlsToTry.length} URL(s)`,
|
|
154
164
|
source: primary.source ?? undefined,
|
|
155
165
|
};
|
|
156
|
-
});
|
|
166
|
+
}, undefined, cancelToken);
|
|
157
167
|
const results = settled
|
|
158
168
|
.filter((r) => r.status === "fulfilled")
|
|
159
169
|
.map((r) => r.value);
|
|
@@ -161,6 +171,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
161
171
|
let notFound = 0;
|
|
162
172
|
let skipped = 0;
|
|
163
173
|
let errors = 0;
|
|
174
|
+
let quotaExceeded = 0;
|
|
164
175
|
for (const r of results) {
|
|
165
176
|
if (r.status === "attached")
|
|
166
177
|
attached++;
|
|
@@ -170,6 +181,8 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
170
181
|
skipped++;
|
|
171
182
|
else if (r.status === "error")
|
|
172
183
|
errors++;
|
|
184
|
+
else if (r.status === "quota_exceeded")
|
|
185
|
+
quotaExceeded++;
|
|
173
186
|
}
|
|
174
187
|
return {
|
|
175
188
|
content: [
|
|
@@ -181,7 +194,13 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
181
194
|
not_found: notFound,
|
|
182
195
|
skipped,
|
|
183
196
|
errors,
|
|
197
|
+
quota_exceeded: quotaExceeded,
|
|
184
198
|
dry_run,
|
|
199
|
+
...(quotaExceeded > 0
|
|
200
|
+
? {
|
|
201
|
+
storage_quota_warning: "Zotero storage quota is full. Remaining PDF uploads were skipped. Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
185
204
|
results,
|
|
186
205
|
}, null, 2),
|
|
187
206
|
},
|
|
@@ -1,21 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
2
3
|
import { formatErrorResponse } from "../utils/error-formatter.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
5
|
import { fetchAllPages } from "../utils/pagination.js";
|
|
5
6
|
export const toolConfig = {
|
|
6
7
|
name: "get_collections",
|
|
7
|
-
description: "List all collections (folders) in your Zotero library. Returns collection keys, names, and parent relationships. Use collection keys with get_collection_items or as parent_collection in create_collection.",
|
|
8
|
-
inputSchema: {
|
|
8
|
+
description: "List all collections (folders) in your Zotero library. Returns collection keys, names, and parent relationships. Use collection keys with get_collection_items or as parent_collection in create_collection. Trashed collections are excluded by default.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
include_trashed: z
|
|
11
|
+
.boolean()
|
|
12
|
+
.optional()
|
|
13
|
+
.default(false)
|
|
14
|
+
.describe("Include trashed (deleted) collections. Default false."),
|
|
15
|
+
},
|
|
9
16
|
};
|
|
10
|
-
|
|
17
|
+
const GetCollectionsSchema = z.object(toolConfig.inputSchema);
|
|
18
|
+
export async function handleGetCollections(zoteroApi, userId, args) {
|
|
19
|
+
const { include_trashed } = GetCollectionsSchema.parse(args);
|
|
11
20
|
try {
|
|
12
|
-
const { items:
|
|
13
|
-
if (!Array.isArray(
|
|
21
|
+
const { items: allCollections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
|
|
22
|
+
if (!Array.isArray(allCollections) || allCollections.length === 0) {
|
|
14
23
|
return formatErrorResponse("No collections found", {
|
|
15
24
|
suggestion: "Create a collection in your Zotero library first",
|
|
16
25
|
helpUrl: "https://www.zotero.org/support/collections",
|
|
17
26
|
});
|
|
18
27
|
}
|
|
28
|
+
// Zotero API returns deleted collections despite not requesting them —
|
|
29
|
+
// filter client-side as a workaround
|
|
30
|
+
const collections = include_trashed
|
|
31
|
+
? allCollections
|
|
32
|
+
: allCollections.filter((c) => !c.deleted);
|
|
33
|
+
if (collections.length === 0) {
|
|
34
|
+
return formatErrorResponse("No collections found", {
|
|
35
|
+
suggestion: "All collections are in the trash. Use include_trashed=true to see them.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
19
38
|
return {
|
|
20
39
|
content: [
|
|
21
40
|
{ type: "text", text: JSON.stringify(collections, null, 2) },
|
|
@@ -5,7 +5,7 @@ import { formatCreators } from "../utils/item-formatter.js";
|
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
export const toolConfig = {
|
|
7
7
|
name: "get_items_details",
|
|
8
|
-
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of calling get_item_details multiple times. Returns
|
|
8
|
+
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of calling get_item_details multiple times. Returns all type-specific fields (e.g. bookTitle for bookSection, proceedingsTitle for conferencePaper, university for thesis). Set include_abstract to include abstracts (excluded by default to keep responses lightweight).",
|
|
9
9
|
inputSchema: {
|
|
10
10
|
item_keys: z
|
|
11
11
|
.array(z.string())
|
|
@@ -18,6 +18,12 @@ export const toolConfig = {
|
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
const GetItemsDetailsSchema = z.object(toolConfig.inputSchema);
|
|
21
|
+
/** Fields excluded from the response (structural/internal, not bibliographic metadata). */
|
|
22
|
+
const SKIP_FIELDS = new Set([
|
|
23
|
+
"key", "version", "dateAdded", "dateModified",
|
|
24
|
+
"collections", "tags", "relations", "creators",
|
|
25
|
+
"abstractNote",
|
|
26
|
+
]);
|
|
21
27
|
export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
22
28
|
const { item_keys, include_abstract } = GetItemsDetailsSchema.parse(args);
|
|
23
29
|
if (item_keys.length === 0) {
|
|
@@ -40,18 +46,26 @@ export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
|
40
46
|
const key = item.key;
|
|
41
47
|
if (!key)
|
|
42
48
|
continue;
|
|
43
|
-
|
|
49
|
+
const entry = {
|
|
50
|
+
itemType: item.itemType || "document",
|
|
44
51
|
title: item.title || "Untitled",
|
|
45
52
|
authors: formatCreators(item.creators),
|
|
46
|
-
date: item.date || "No date",
|
|
47
|
-
doi: item.DOI || null,
|
|
48
|
-
itemType: item.itemType || "document",
|
|
49
|
-
publicationTitle: item.publicationTitle || null,
|
|
50
|
-
url: item.url || null,
|
|
51
53
|
};
|
|
54
|
+
// Include all non-empty bibliographic fields from the Zotero response
|
|
55
|
+
const raw = item;
|
|
56
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
57
|
+
if (SKIP_FIELDS.has(field))
|
|
58
|
+
continue;
|
|
59
|
+
if (field in entry)
|
|
60
|
+
continue;
|
|
61
|
+
if (value === undefined || value === null || value === "" || value === false)
|
|
62
|
+
continue;
|
|
63
|
+
entry[field] = value;
|
|
64
|
+
}
|
|
52
65
|
if (include_abstract && item.abstractNote) {
|
|
53
|
-
|
|
66
|
+
entry.abstractNote = item.abstractNote;
|
|
54
67
|
}
|
|
68
|
+
result[key] = entry;
|
|
55
69
|
}
|
|
56
70
|
return {
|
|
57
71
|
content: [
|
|
@@ -69,6 +69,13 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
|
69
69
|
return formatErrorResponse("File exceeds 100 MB limit", {
|
|
70
70
|
size_bytes: result.error.sizeBytes,
|
|
71
71
|
});
|
|
72
|
+
case "storage_quota_exceeded":
|
|
73
|
+
return formatErrorResponse("Zotero storage quota exceeded", {
|
|
74
|
+
status: result.error.status,
|
|
75
|
+
suggestion: "Your Zotero cloud storage is full. Free up space by deleting stored files in your Zotero library, or upgrade your storage plan at https://www.zotero.org/settings/storage. Note: item metadata and linked URL attachments do not consume storage — only imported file attachments do.",
|
|
76
|
+
item_key: result.itemKey,
|
|
77
|
+
note: "An attachment metadata record was created but the file could not be uploaded. You may want to delete it with delete_items or retry after freeing storage.",
|
|
78
|
+
});
|
|
72
79
|
case "auth_failed":
|
|
73
80
|
return formatErrorResponse("Upload authorization failed", {
|
|
74
81
|
status: result.error.status,
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
export const DEFAULT_CONCURRENCY = 5;
|
|
2
|
+
export function createCancellationToken() {
|
|
3
|
+
return { cancelled: false };
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Runs `fn` over `items` with at most `concurrency` invocations in flight.
|
|
4
7
|
* Returns results in input order as `PromiseSettledResult<R>[]`.
|
|
8
|
+
*
|
|
9
|
+
* When `cancel` is provided and `cancel.cancelled` is set to `true`,
|
|
10
|
+
* workers stop picking up new items. Items that were never started
|
|
11
|
+
* have `undefined` slots in the returned array — callers that filter
|
|
12
|
+
* with `.filter(r => r?.status === "fulfilled")` handle this safely.
|
|
5
13
|
*/
|
|
6
|
-
export async function mapWithConcurrency(items, fn, concurrency = DEFAULT_CONCURRENCY) {
|
|
14
|
+
export async function mapWithConcurrency(items, fn, concurrency = DEFAULT_CONCURRENCY, cancel) {
|
|
7
15
|
const results = new Array(items.length);
|
|
8
16
|
let nextIndex = 0;
|
|
9
17
|
async function worker() {
|
|
10
18
|
while (nextIndex < items.length) {
|
|
19
|
+
if (cancel?.cancelled)
|
|
20
|
+
break;
|
|
11
21
|
const i = nextIndex++;
|
|
12
22
|
try {
|
|
13
23
|
const value = await fn(items[i], i);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
|
|
1
2
|
const CSL_TO_ZOTERO_TYPE = {
|
|
3
|
+
// Standard CSL types
|
|
2
4
|
"article": "journalArticle",
|
|
3
5
|
"article-journal": "journalArticle",
|
|
4
6
|
"article-magazine": "magazineArticle",
|
|
@@ -34,6 +36,23 @@ const CSL_TO_ZOTERO_TYPE = {
|
|
|
34
36
|
"standard": "standard",
|
|
35
37
|
"thesis": "thesis",
|
|
36
38
|
"webpage": "webpage",
|
|
39
|
+
// Non-standard types returned by CrossRef/DataCite DOI content negotiation
|
|
40
|
+
"journal-article": "journalArticle",
|
|
41
|
+
"book-chapter": "bookSection",
|
|
42
|
+
"proceedings-article": "conferencePaper",
|
|
43
|
+
"posted-content": "preprint",
|
|
44
|
+
"dissertation": "thesis",
|
|
45
|
+
"monograph": "book",
|
|
46
|
+
"reference-entry": "encyclopediaArticle",
|
|
47
|
+
"component": "document",
|
|
48
|
+
"peer-review": "journalArticle",
|
|
49
|
+
"edited-book": "book",
|
|
50
|
+
"reference-book": "book",
|
|
51
|
+
"book-series": "book",
|
|
52
|
+
"book-part": "bookSection",
|
|
53
|
+
"proceedings": "book",
|
|
54
|
+
"journal": "journalArticle",
|
|
55
|
+
"report-series": "report",
|
|
37
56
|
};
|
|
38
57
|
const ZOTERO_TO_CSL_TYPE = {
|
|
39
58
|
journalArticle: "article-journal",
|
|
@@ -74,7 +93,27 @@ const ZOTERO_TO_CSL_TYPE = {
|
|
|
74
93
|
email: "personal_communication",
|
|
75
94
|
instantMessage: "personal_communication",
|
|
76
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Maps CSL `container-title` to the correct Zotero field name per item type.
|
|
98
|
+
* Types not listed here have no container-title equivalent.
|
|
99
|
+
*/
|
|
100
|
+
const CONTAINER_TITLE_FIELD = {
|
|
101
|
+
journalArticle: "publicationTitle",
|
|
102
|
+
magazineArticle: "publicationTitle",
|
|
103
|
+
newspaperArticle: "publicationTitle",
|
|
104
|
+
bookSection: "bookTitle",
|
|
105
|
+
dictionaryEntry: "dictionaryTitle",
|
|
106
|
+
conferencePaper: "proceedingsTitle",
|
|
107
|
+
blogPost: "blogTitle",
|
|
108
|
+
encyclopediaArticle: "encyclopediaTitle",
|
|
109
|
+
forumPost: "forumTitle",
|
|
110
|
+
webpage: "websiteTitle",
|
|
111
|
+
radioBroadcast: "programTitle",
|
|
112
|
+
tvBroadcast: "programTitle",
|
|
113
|
+
podcast: "programTitle",
|
|
114
|
+
};
|
|
77
115
|
export function cslToZoteroItem(csl, options) {
|
|
116
|
+
const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
|
|
78
117
|
const creators = (csl.author ?? []).map((a) => ({
|
|
79
118
|
firstName: a.given ?? "",
|
|
80
119
|
lastName: a.family ?? a.literal ?? "",
|
|
@@ -87,13 +126,11 @@ export function cslToZoteroItem(csl, options) {
|
|
|
87
126
|
collections.push(options.collectionKey);
|
|
88
127
|
}
|
|
89
128
|
const tags = (options?.tags ?? []).map((t) => ({ tag: t }));
|
|
90
|
-
|
|
91
|
-
|
|
129
|
+
// Build candidate fields (CSL → Zotero field names)
|
|
130
|
+
const candidates = {
|
|
92
131
|
title: csl.title ?? "",
|
|
93
|
-
creators,
|
|
94
132
|
date,
|
|
95
133
|
DOI: csl.DOI ?? "",
|
|
96
|
-
publicationTitle: csl["container-title"] ?? "",
|
|
97
134
|
volume: csl.volume ?? "",
|
|
98
135
|
issue: csl.issue ?? "",
|
|
99
136
|
pages: csl.page ?? "",
|
|
@@ -107,9 +144,29 @@ export function cslToZoteroItem(csl, options) {
|
|
|
107
144
|
numPages: csl["number-of-pages"] ?? "",
|
|
108
145
|
series: csl["collection-title"] ?? "",
|
|
109
146
|
language: csl.language ?? "",
|
|
110
|
-
collections,
|
|
111
|
-
tags,
|
|
112
147
|
};
|
|
148
|
+
// Map container-title to the correct field for this item type
|
|
149
|
+
const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
|
|
150
|
+
if (containerTitleField) {
|
|
151
|
+
candidates[containerTitleField] = csl["container-title"] ?? "";
|
|
152
|
+
}
|
|
153
|
+
// Filter: only include fields valid for this item type
|
|
154
|
+
const validFields = ITEM_TYPE_FIELDS[itemType];
|
|
155
|
+
const result = { itemType, creators, collections, tags };
|
|
156
|
+
if (validFields) {
|
|
157
|
+
for (const [field, value] of Object.entries(candidates)) {
|
|
158
|
+
if (validFields.has(field)) {
|
|
159
|
+
result[field] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Unknown item type — include all fields as fallback
|
|
165
|
+
for (const [field, value] of Object.entries(candidates)) {
|
|
166
|
+
result[field] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
113
170
|
}
|
|
114
171
|
export function zoteroItemToCsl(item) {
|
|
115
172
|
const authors = (item.creators ?? [])
|
|
@@ -110,10 +110,24 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
110
110
|
else {
|
|
111
111
|
itemData.collections = options.collections ?? [];
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
let createResponse;
|
|
114
|
+
try {
|
|
115
|
+
createResponse = await zoteroApi
|
|
116
|
+
.library("user", userId)
|
|
117
|
+
.items()
|
|
118
|
+
.post([itemData]);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: {
|
|
125
|
+
code: "item_creation_failed",
|
|
126
|
+
message: `Failed to create attachment item: ${detail}`,
|
|
127
|
+
networkDetail: detail,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
117
131
|
if (!createResponse.isSuccess()) {
|
|
118
132
|
const errors = createResponse.getErrors();
|
|
119
133
|
const errorMsg = Object.values(errors).join("; ") || "Unknown error";
|
|
@@ -135,16 +149,42 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
135
149
|
filesize: String(buffer.length),
|
|
136
150
|
mtime: String(Date.now()),
|
|
137
151
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
let authResponse;
|
|
153
|
+
try {
|
|
154
|
+
authResponse = await fetch(authUrl, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"Zotero-API-Key": apiKey,
|
|
158
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
159
|
+
"If-None-Match": "*",
|
|
160
|
+
},
|
|
161
|
+
body: authBody.toString(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
itemKey,
|
|
169
|
+
error: {
|
|
170
|
+
code: "auth_failed",
|
|
171
|
+
message: `Upload authorization failed: ${detail}`,
|
|
172
|
+
networkDetail: detail,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
147
176
|
if (!authResponse.ok) {
|
|
177
|
+
if (authResponse.status === 413) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
itemKey,
|
|
181
|
+
error: {
|
|
182
|
+
code: "storage_quota_exceeded",
|
|
183
|
+
message: "Zotero storage quota exceeded. The file cannot be uploaded because your Zotero cloud storage is full. Free up space by deleting stored files in Zotero, or upgrade your storage plan at https://www.zotero.org/settings/storage.",
|
|
184
|
+
status: 413,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
148
188
|
return {
|
|
149
189
|
success: false,
|
|
150
190
|
itemKey,
|
|
@@ -162,11 +202,26 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
162
202
|
const prefix = Buffer.from(auth.prefix, "utf-8");
|
|
163
203
|
const suffix = Buffer.from(auth.suffix, "utf-8");
|
|
164
204
|
const uploadBody = Buffer.concat([prefix, buffer, suffix]);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
205
|
+
let uploadResponse;
|
|
206
|
+
try {
|
|
207
|
+
uploadResponse = await fetch(auth.url, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": auth.contentType },
|
|
210
|
+
body: uploadBody,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
itemKey,
|
|
218
|
+
error: {
|
|
219
|
+
code: "upload_failed",
|
|
220
|
+
message: `File upload failed: ${detail}`,
|
|
221
|
+
networkDetail: detail,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
170
225
|
if (!uploadResponse.ok) {
|
|
171
226
|
return {
|
|
172
227
|
success: false,
|
|
@@ -178,15 +233,30 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
178
233
|
},
|
|
179
234
|
};
|
|
180
235
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
236
|
+
let registerResponse;
|
|
237
|
+
try {
|
|
238
|
+
registerResponse = await fetch(authUrl, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: {
|
|
241
|
+
"Zotero-API-Key": apiKey,
|
|
242
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
243
|
+
"If-None-Match": "*",
|
|
244
|
+
},
|
|
245
|
+
body: `upload=${auth.uploadKey}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
itemKey,
|
|
253
|
+
error: {
|
|
254
|
+
code: "registration_failed",
|
|
255
|
+
message: `Upload registration failed: ${detail}`,
|
|
256
|
+
networkDetail: detail,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
190
260
|
if (!registerResponse.ok) {
|
|
191
261
|
return {
|
|
192
262
|
success: false,
|
|
@@ -15,6 +15,12 @@ export async function putFulltext(userId, itemKey, apiKey, content, totalPages)
|
|
|
15
15
|
if (response.status === 204) {
|
|
16
16
|
return { success: true };
|
|
17
17
|
}
|
|
18
|
+
if (response.status === 413) {
|
|
19
|
+
return {
|
|
20
|
+
success: false,
|
|
21
|
+
error: "Fulltext content too large for Zotero API (status 413). The PDF text may exceed the fulltext size limit.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
18
24
|
return {
|
|
19
25
|
success: false,
|
|
20
26
|
error: `Fulltext PUT failed with status ${response.status}`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xevos117/mcp-zotero",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.9",
|
|
5
5
|
"description": "Unofficial MCP server for Zotero integration with citation management",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
"author": "Xevos17",
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
46
|
-
"dotenv": "^
|
|
47
|
-
"fast-xml-parser": "^5.
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
46
|
+
"dotenv": "^17.3.1",
|
|
47
|
+
"fast-xml-parser": "^5.5.8",
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
49
|
"unpdf": "^1.4.0",
|
|
50
|
-
"zod": "^3.
|
|
51
|
-
"zotero-api-client": "^0.
|
|
50
|
+
"zod": "^4.3.6",
|
|
51
|
+
"zotero-api-client": "^0.48.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@types/node": "
|
|
54
|
+
"@types/node": "^25.5.0",
|
|
55
55
|
"typescript": "latest",
|
|
56
|
-
"vitest": "^4.0
|
|
56
|
+
"vitest": "^4.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|