@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 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 in a single call |
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, 382 tests)
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/config";
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
- pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
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
  ],
@@ -28,7 +28,7 @@ const ItemSchema = z
28
28
  continue;
29
29
  if (!validFields.has(key)) {
30
30
  ctx.addIssue({
31
- code: z.ZodIssueCode.custom,
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: z.ZodIssueCode.custom,
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
- export async function handleGetCollections(zoteroApi, userId, _args) {
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: collections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
13
- if (!Array.isArray(collections) || collections.length === 0) {
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 title, authors, date, DOI, item type, publication title, and URL for each item. Set include_abstract to include abstracts (excluded by default to keep responses lightweight).",
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
- result[key] = {
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
- result[key].abstractNote = item.abstractNote;
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
- return {
91
- itemType: CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle",
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
- const createResponse = await zoteroApi
114
- .library("user", userId)
115
- .items()
116
- .post([itemData]);
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
- const authResponse = await fetch(authUrl, {
139
- method: "POST",
140
- headers: {
141
- "Zotero-API-Key": apiKey,
142
- "Content-Type": "application/x-www-form-urlencoded",
143
- "If-None-Match": "*",
144
- },
145
- body: authBody.toString(),
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
- const uploadResponse = await fetch(auth.url, {
166
- method: "POST",
167
- headers: { "Content-Type": auth.contentType },
168
- body: uploadBody,
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
- const registerResponse = await fetch(authUrl, {
182
- method: "POST",
183
- headers: {
184
- "Zotero-API-Key": apiKey,
185
- "Content-Type": "application/x-www-form-urlencoded",
186
- "If-None-Match": "*",
187
- },
188
- body: `upload=${auth.uploadKey}`,
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.7",
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.0.4",
46
- "dotenv": "^16.4.7",
47
- "fast-xml-parser": "^5.3.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.24.1",
51
- "zotero-api-client": "^0.46.0"
50
+ "zod": "^4.3.6",
51
+ "zotero-api-client": "^0.48.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@types/node": "latest",
54
+ "@types/node": "^25.5.0",
55
55
  "typescript": "latest",
56
- "vitest": "^4.0.18"
56
+ "vitest": "^4.1.0"
57
57
  }
58
58
  }