@xevos117/mcp-zotero 2.0.1 → 2.0.2

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
@@ -226,10 +226,97 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
226
226
  ```bash
227
227
  npm install
228
228
  npm run build # Compile TypeScript
229
- npm test # Run tests (vitest, 404 tests)
229
+ npm test # Unit and per-tool tests (vitest, fetch faked, ~1000 tests)
230
+ npm run test:e2e # Built server over stdio against a local fake Zotero API
231
+ npm run smoke # Quick stdio smoke test of the built server (user + group library)
230
232
  npx tsx src/server.ts # Run directly without building
231
233
  ```
232
234
 
235
+ ### Testing
236
+
237
+ | Layer | Command | Runs on | What it covers |
238
+ |---|---|---|---|
239
+ | 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
+ | 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`, nightly | the real Zotero Web API on a dedicated test account ([test/live](test/live)); skipped unless `ZOTERO_TEST_API_KEY` is set |
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 |
243
+ | Word | manual | every release | [test/WORD_CHECKLIST.md](test/WORD_CHECKLIST.md): open an injected document in Word with the Zotero plugin |
244
+
245
+ Every test case is listed in [test/TEST_CASES.md](test/TEST_CASES.md).
246
+
247
+ **Live tests locally** use only `ZOTERO_TEST_*` variables, never your `ZOTERO_API_KEY`:
248
+
249
+ ```bash
250
+ ZOTERO_TEST_API_KEY=... ZOTERO_TEST_USER_ID=... UNPAYWALL_EMAIL=you@example.org npm run test:live
251
+ ```
252
+
253
+ Against a personal library add `ZOTERO_LIVE_SAFE_MODE=1`: the suite only creates items and collections
254
+ named `mzt-<runId>…` and tagged `mzt-run:<runId>`, deletes only those, and skips collection deletion and
255
+ group writes. A dedicated test account is marked by a collection named `__mzt_disposable__`.
256
+
257
+ **Dependencies** are updated monthly by a Claude Code agent ([workflow](.github/workflows/deps-update.yml),
258
+ skill [`.claude/skills/deps-update`](.claude/skills/deps-update/SKILL.md)); patch-only PRs are auto-merged after
259
+ all required checks pass, anything else waits for review. GitHub Actions versions are updated by Dependabot.
260
+
261
+ ### CI setup (repository owner)
262
+
263
+ | Environment | Secrets | Variables |
264
+ |---|---|---|
265
+ | `zotero-live` (live tests, agent smoke) | `ZOTERO_TEST_API_KEY`, `ZOTERO_TEST_USER_ID`, `ZOTERO_TEST_USERNAME`, `ZOTERO_TEST_GROUP_ID` (optional), `UNPAYWALL_EMAIL` | `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID`, `ANTHROPIC_WORKSPACE_ID` |
266
+ | `claude-deps` (dependency agent) | — | the same four `ANTHROPIC_*` variables (the federation rule may differ) |
267
+
268
+ The agents authenticate to the Claude API with Anthropic Workload Identity Federation (GitHub OIDC, no
269
+ API key). Recommended: one federation rule per environment, as narrow as possible:
270
+
271
+ | Rule for | subject (exact, no `*`) | claims (exact string pairs) | condition (CEL) |
272
+ |---|---|---|---|
273
+ | `zotero-live` | `repo:Xevos117/mcp-zotero:environment:zotero-live` | `repository_id: 1152178663`, `repository_owner_id: 86290818` | `claims.job_workflow_ref.startsWith("Xevos117/mcp-zotero/.github/workflows/claude-agent-smoke.yml@")` (PR runs use the PR ref) |
274
+ | `claude-deps` | `repo:Xevos117/mcp-zotero:environment:claude-deps` | `repository_id: 1152178663`, `repository_owner_id: 86290818`, `job_workflow_ref: Xevos117/mcp-zotero/.github/workflows/deps-update.yml@refs/heads/main` | — |
275
+
276
+ Claims are exact-match string pairs (only `subject_prefix` supports a trailing `*`), so a prefix such as
277
+ "this workflow on any ref" must be written as a CEL `condition`, as above; an exact `job_workflow_ref`
278
+ (claude-deps, main only) can be a plain claim.
279
+
280
+ Audience `https://api.anthropic.com`. The subject is an exact match unless it ends with `*`; jobs that use
281
+ an environment get the OIDC subject `repo:Xevos117/mcp-zotero:environment:<environment>`, so a rule
282
+ written for a branch or PR subject never matches. Numeric ids (not names) keep a renamed or re-created
283
+ repository/account from matching.
284
+
285
+ Trade-off for `zotero-live`: the "any ref" condition is needed for the agent smoke test to run on pull
286
+ requests, but it also accepts a modified copy of that workflow file pushed to any branch of this repository
287
+ (only people with write access can push). Pinning it to the exact claim
288
+ `job_workflow_ref: Xevos117/mcp-zotero/.github/workflows/claude-agent-smoke.yml@refs/heads/main` closes that
289
+ gap but disables the agent smoke test on PRs (it would then run only on manual runs from `main`).
290
+
291
+ Other owner settings:
292
+
293
+ - The Zotero test account needs a collection named `__mzt_disposable__`.
294
+ - Repository settings: "Allow auto-merge", and "Allow GitHub Actions to create and approve pull requests"
295
+ (the dependency workflow opens its PR with `GITHUB_TOKEN`). Keep the ruleset at **0 required approvals**:
296
+ auto-merge of the dependency PR relies on it, and it is also what makes the second setting acceptable. That
297
+ setting is a conscious trade-off: it lets workflows open PRs (needed) and approve them (not used by any
298
+ workflow here); if approvals are ever required, it would let a workflow approve its own PR, so revisit it
299
+ together with any approval requirement.
300
+ - No GitHub App is used by any workflow (the agents authenticate to Anthropic with Workload Identity
301
+ Federation and use only `GITHUB_TOKEN` on GitHub, read-only for the agents). If the Claude GitHub App is
302
+ installed on this repository, uninstall it.
303
+ - Set a monthly spend limit on the Anthropic workspace used by the federation rules: it is the real cap on
304
+ agent costs.
305
+ - Residual risk: the agent smoke job that holds the OIDC token also runs the MCP server built from the PR's
306
+ code (that is what it tests). It runs only for same-repository PRs by the owner or a bot, the token is
307
+ bound to the `zotero-live` environment and the workspace spend limit caps any misuse.
308
+ - Required checks after the stack merges: `test (node 22)`, `test (node 24)`, `test (node 26)`,
309
+ `live-tests`, `agent-smoke`.
310
+
311
+ ### Releasing
312
+
313
+ 1. Update `CHANGELOG.md` (move `Unreleased` to the new version) and `version` in `package.json`.
314
+ 2. Make sure CI, live tests and the agent smoke test are green on `main`.
315
+ 3. Run the manual [Word checklist](test/WORD_CHECKLIST.md) on a document injected by the release candidate
316
+ and record the results in its log.
317
+ 4. Publish the GitHub release: the [release workflow](.github/workflows/release.yml) runs the tests, publishes
318
+ to npm and attaches the skill zips.
319
+
233
320
  ### Debug with MCP Inspector
234
321
 
235
322
  ```bash
@@ -1,6 +1,7 @@
1
1
  import JSZip from "jszip";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { format, parse } from "node:path";
4
+ import { isZoteroApiError } from "../types/zotero-types.js";
4
5
  import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-codes.js";
5
6
  import { formatCitationText } from "./citation-formatter.js";
6
7
  import { regexEscape, unescapeXml } from "./xml-utils.js";
@@ -39,10 +40,19 @@ function parseZciteMatches(documentXml) {
39
40
  async function fetchCslData(keys, zoteroApi, libraryType, userId) {
40
41
  const cslData = new Map();
41
42
  for (const key of keys) {
42
- const response = await zoteroApi
43
- .library(libraryType, userId)
44
- .items(key)
45
- .get();
43
+ let response;
44
+ try {
45
+ response = await zoteroApi
46
+ .library(libraryType, userId)
47
+ .items(key)
48
+ .get();
49
+ }
50
+ catch (err) {
51
+ // Unknown keys are collected and reported together by the caller; any other failure aborts at once
52
+ if (isZoteroApiError(err) && err.response?.status === 404)
53
+ continue;
54
+ throw err;
55
+ }
46
56
  const zoteroItem = response.getData();
47
57
  cslData.set(key, zoteroItemToCsl(zoteroItem));
48
58
  }
@@ -124,13 +134,19 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
124
134
  // Collect all unique item keys
125
135
  const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
126
136
  // Keys from the document go into API URL paths (one GET per key): only letters and digits, so a crafted
127
- // <zcite> cannot point the request at another endpoint ("../", "?", "&"); unknown keys still give a 404
137
+ // <zcite> cannot point the request at another endpoint ("../", "?", "&"). The skill's inject.js applies the same check
128
138
  const invalid = [...uniqueKeys].filter((k) => !/^[A-Za-z0-9]{1,32}$/.test(k));
129
139
  if (invalid.length > 0) {
130
140
  throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (letters and digits only)`);
131
141
  }
132
142
  // Fetch CSL data from Zotero
133
143
  const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
144
+ // No placeholder citations in the user's document: a citation of a nonexistent item fails the injection,
145
+ // like the skill's inject.js with a key missing from metadata.json
146
+ const missingKeys = [...uniqueKeys].filter((k) => !cslData.has(k));
147
+ if (missingKeys.length > 0) {
148
+ throw new Error(`${missingKeys.length} cited key(s) not found in the library: ${missingKeys.join(", ")}`);
149
+ }
134
150
  // Replace each zcite tag with a field code
135
151
  let injected = 0;
136
152
  for (const match of matches) {
package/build/server.js CHANGED
@@ -8,6 +8,7 @@ import { registerAllTools } from "./tools/index.js";
8
8
  import { logger } from "./utils/logger.js";
9
9
  import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
10
10
  import { getDefaultLibrary } from "./utils/library-context.js";
11
+ import { withRequestTimeout } from "./utils/timeouts.js";
11
12
  // Report the package version in the MCP handshake (package.json sits one level above both src/ and build/).
12
13
  const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
13
14
  // Re-export for backward compatibility with existing tests
@@ -54,7 +55,8 @@ class ZoteroServer {
54
55
  // global fetch; the old lib/main-node.cjs (cross-fetch polyfill) no longer exists.
55
56
  const require = createRequire(import.meta.url);
56
57
  const { default: zoteroApiFactory } = require("zotero-api-client");
57
- this.zoteroApi = zoteroApiFactory(apiKey);
58
+ // every request carries its own AbortSignal.timeout (TIMEOUTS.zoteroApiMs)
59
+ this.zoteroApi = withRequestTimeout(zoteroApiFactory(apiKey));
58
60
  }
59
61
  }
60
62
  setupHandlers() {
@@ -20,7 +20,7 @@ WORKFLOW TIPS:
20
20
  - To create a cited Word document, use the returned item_keys as <zcite keys="ITEMKEY"/> placeholders in a .docx, then call inject_citations. See inject_citations description for the full workflow.`,
21
21
  inputSchema: {
22
22
  dois: z
23
- .array(z.string())
23
+ .array(z.string().trim().min(1, "DOI must not be empty"))
24
24
  .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
25
25
  collection_key: zoteroKey()
26
26
  .optional()
@@ -1,9 +1,11 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "../utils/json-response.js";
1
2
  import { z } from "zod";
2
3
  import { zoteroKey } from "../utils/zotero-keys.js";
3
4
  import { isZoteroApiError } from "../types/zotero-types.js";
4
5
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
5
6
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
- import { findPdfAttachment, isPdfAttachment } from "../utils/attachments.js";
7
+ import { findPdfAttachments, isLinkedUrl, isPdfAttachment } from "../utils/attachments.js";
8
+ import { fetchWithTimeout, isTimeoutAbort, timeoutError, TIMEOUTS } from "../utils/timeouts.js";
7
9
  export const toolConfig = {
8
10
  name: "get_item_fulltext",
9
11
  description: "Get the full text content of a Zotero item's PDF attachment via Zotero's fulltext index. Zotero desktop automatically indexes PDFs when synced. Use this to read the full content of papers instead of relying on abstracts.",
@@ -32,11 +34,20 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
32
34
  .items(item_key)
33
35
  .get();
34
36
  const itemData = itemResponse.getData();
35
- // If the item itself is a PDF attachment, use it directly
37
+ // If the item itself is a PDF attachment, use it directly (a web link has no stored file)
36
38
  if (isPdfAttachment(itemData)) {
39
+ if (isLinkedUrl(itemData)) {
40
+ return formatErrorResponse("This attachment is a link to a PDF on the web (linked URL): Zotero stores no file or full text for it. Fetch the URL directly.", { item_key, url: itemData.url || undefined });
41
+ }
37
42
  return fetchFulltext(item_key, item_key, libraryType, libraryId, apiKey, max_characters);
38
43
  }
39
- const pdfAttachment = await findPdfAttachment(zoteroApi, { type: libraryType, id: libraryId }, item_key);
44
+ // Stored PDFs first: linked URLs never have server-side full text, even when listed first
45
+ const pdfs = await findPdfAttachments(zoteroApi, { type: libraryType, id: libraryId }, item_key);
46
+ const pdfAttachment = pdfs.find((p) => !isLinkedUrl(p));
47
+ const linkedPdf = pdfs.find(isLinkedUrl);
48
+ if (!pdfAttachment?.key && linkedPdf) {
49
+ return formatErrorResponse("No stored PDF attachment found. This item only links to a PDF on the web (linked URL attachment), which Zotero does not index — fetch the linked URL directly.", { item_key, attachment_key: linkedPdf.key, url: linkedPdf.url || undefined });
50
+ }
40
51
  if (!pdfAttachment?.key) {
41
52
  // Provide context-aware error messages
42
53
  if (itemData.itemType === "webpage" || itemData.itemType === "blogPost") {
@@ -60,16 +71,28 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
60
71
  }
61
72
  async function fetchFulltext(itemKey, attachmentKey, libraryType, userId, apiKey, maxCharacters) {
62
73
  const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${attachmentKey}/fulltext`;
63
- const response = await fetch(url, {
64
- headers: { "Zotero-API-Key": apiKey },
65
- });
74
+ const response = await fetchWithTimeout(url, { headers: { "Zotero-API-Key": apiKey } }, TIMEOUTS.zoteroApiMs, "Zotero fulltext request");
66
75
  if (response.status === 404) {
67
76
  return formatErrorResponse("Full text not indexed for this item. Open the PDF in Zotero desktop to trigger indexing, then sync.", { item_key: itemKey, attachment_key: attachmentKey });
68
77
  }
69
78
  if (!response.ok) {
70
79
  return formatErrorResponse(`Zotero fulltext API returned status ${response.status}`, { item_key: itemKey, attachment_key: attachmentKey });
71
80
  }
72
- const data = (await response.json());
81
+ let data;
82
+ try {
83
+ data = await readJsonObject(response);
84
+ }
85
+ catch (err) {
86
+ if (err instanceof InvalidJsonResponseError) {
87
+ return formatErrorResponse(`Zotero fulltext API: ${err.message}`, { item_key: itemKey, attachment_key: attachmentKey });
88
+ }
89
+ if (!isTimeoutAbort(err))
90
+ throw err;
91
+ return formatErrorResponse(timeoutError("Zotero fulltext request", url, TIMEOUTS.zoteroApiMs).message, {
92
+ item_key: itemKey,
93
+ attachment_key: attachmentKey,
94
+ });
95
+ }
73
96
  let text = data.content;
74
97
  let truncated = false;
75
98
  if (maxCharacters > 0 && text.length > maxCharacters) {
@@ -20,7 +20,7 @@ export const toolConfig = {
20
20
  .string()
21
21
  .optional()
22
22
  .default("application/pdf")
23
- .describe('MIME type of the file (default: "application/pdf")'),
23
+ .describe('MIME type recorded on the attachment (default: "application/pdf"). This tool imports PDFs only: the downloaded file must start with %PDF- or the import is rejected, whatever this value says. Full text is extracted only for "application/pdf".'),
24
24
  parent_item: zoteroKey()
25
25
  .optional()
26
26
  .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
@@ -80,10 +80,12 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
80
80
  case "auth_failed":
81
81
  return formatErrorResponse("Upload authorization failed", {
82
82
  status: result.error.status,
83
+ details: result.error.networkDetail, // e.g. the timeout or connection error
83
84
  });
84
85
  case "upload_failed":
85
86
  return formatErrorResponse("File upload failed", {
86
87
  status: result.error.status,
88
+ details: result.error.networkDetail, // e.g. the timeout or connection error
87
89
  });
88
90
  case "not_pdf":
89
91
  return formatErrorResponse("Downloaded file is not a valid PDF", {
@@ -94,6 +96,7 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
94
96
  case "registration_failed":
95
97
  return formatErrorResponse("Upload registration failed", {
96
98
  status: result.error.status,
99
+ details: result.error.networkDetail, // e.g. the timeout or connection error
97
100
  });
98
101
  case "item_creation_failed":
99
102
  return formatErrorResponse("import_pdf_to_zotero failed", {
@@ -27,7 +27,7 @@ Supported attributes (any order):
27
27
  - suffix: text after citation — ", emphasis added"
28
28
 
29
29
  OUTPUT:
30
- A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end.
30
+ A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end. A cited key that is not in the library, or that is not letters and digits only, is an error and no file is written.
31
31
 
32
32
  NOTE: If the inject-citations skill is available, prefer the skill workflow (runs in sandbox, no filesystem dependency). This tool serves as the primary path when the skill is not available.`,
33
33
  inputSchema: {
@@ -2,8 +2,19 @@ import { asArray } from "./pagination.js";
2
2
  export function isPdfAttachment(item) {
3
3
  return item.itemType === "attachment" && item.contentType === "application/pdf";
4
4
  }
5
+ /**
6
+ * A link to a PDF on the web (linkMode "linked_url"): Zotero stores no file for it, so it has no
7
+ * full text on the server and cannot be opened or indexed in Zotero desktop.
8
+ */
9
+ export function isLinkedUrl(item) {
10
+ return item.linkMode === "linked_url";
11
+ }
12
+ /** All PDF attachments (stored files and links) among the children of an item. */
13
+ export async function findPdfAttachments(zoteroApi, library, itemKey) {
14
+ const response = await zoteroApi.library(library.type, library.id).items(itemKey).children().get();
15
+ return asArray(response.getData()).filter(isPdfAttachment);
16
+ }
5
17
  /** First PDF attachment among the children of an item, if any. */
6
18
  export async function findPdfAttachment(zoteroApi, library, itemKey) {
7
- const response = await zoteroApi.library(library.type, library.id).items(itemKey).children().get();
8
- return asArray(response.getData()).find(isPdfAttachment);
19
+ return (await findPdfAttachments(zoteroApi, library, itemKey))[0];
9
20
  }
@@ -1,17 +1,26 @@
1
1
  import { mapWithConcurrency } from "./concurrency.js";
2
2
  import { fetchWithRetry } from "./fetch-retry.js";
3
3
  import { errorMessage } from "./error-formatter.js";
4
+ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
4
5
  export async function resolveDoi(doi) {
5
6
  const response = await fetchWithRetry(`https://doi.org/${encodeURIComponent(doi)}`, {
6
7
  headers: {
7
8
  Accept: "application/vnd.citationstyles.csl+json",
8
9
  },
9
10
  redirect: "follow",
10
- });
11
+ }, { label: "DOI resolution" });
11
12
  if (!response.ok) {
12
13
  throw new Error(`DOI resolution failed for ${doi}: ${response.status} ${response.statusText}`);
13
14
  }
14
- return (await response.json());
15
+ try {
16
+ return await readJsonObject(response);
17
+ }
18
+ catch (err) {
19
+ // doi.org answers some unresolvable DOIs with an HTML page and status 200
20
+ if (err instanceof InvalidJsonResponseError)
21
+ throw new Error(`DOI not found or not resolvable: ${doi} (${err.message})`);
22
+ throw err;
23
+ }
15
24
  }
16
25
  export async function resolveDois(dois, concurrency) {
17
26
  const settled = await mapWithConcurrency(dois, (doi) => resolveDoi(doi), concurrency);
@@ -1,3 +1,4 @@
1
+ import { fetchWithTimeout, TIMEOUTS } from "./timeouts.js";
1
2
  const DEFAULT_MAX_RETRIES = 3;
2
3
  const DEFAULT_BASE_DELAY_MS = 1000;
3
4
  const DEFAULT_RETRYABLE_STATUSES = [429, 503];
@@ -21,7 +22,7 @@ export async function fetchWithRetry(url, init, options) {
21
22
  const retryableStatuses = options?.retryableStatuses ?? DEFAULT_RETRYABLE_STATUSES;
22
23
  let lastResponse;
23
24
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
24
- const response = await fetch(url, init);
25
+ const response = await fetchWithTimeout(url, init ?? {}, options?.timeoutMs ?? TIMEOUTS.metadataMs, options?.label ?? "Request");
25
26
  lastResponse = response;
26
27
  if (!retryableStatuses.includes(response.status) || attempt === maxRetries) {
27
28
  return response;
@@ -1,6 +1,7 @@
1
1
  export function formatCreators(creators) {
2
2
  return (creators
3
- ?.map((c) => `${c.firstName || ""} ${c.lastName || ""}`.trim())
3
+ // single-field names (institutions, consortia) are stored in `name`
4
+ ?.map((c) => (c.name?.trim() || `${c.firstName || ""} ${c.lastName || ""}`.trim()))
4
5
  .filter(Boolean)
5
6
  .join(", ") || "No authors listed");
6
7
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A response body that is not the JSON object the API documents: an HTML error or landing page (doi.org answers
3
+ * an unresolvable DOI with HTML), an empty body, or a bare value. The message names the status and content type,
4
+ * never the raw JSON.parse error.
5
+ */
6
+ export class InvalidJsonResponseError extends Error {
7
+ status;
8
+ constructor(response) {
9
+ const type = response.headers?.get?.("content-type")?.split(";")[0].trim();
10
+ super(`response is not a JSON object (HTTP ${response.status}${type ? `, ${type}` : ""})`);
11
+ this.name = "InvalidJsonResponseError";
12
+ this.status = response.status;
13
+ }
14
+ }
15
+ /** Body of `response` as a JSON object; InvalidJsonResponseError otherwise. Timeouts while reading propagate. */
16
+ export async function readJsonObject(response) {
17
+ let data;
18
+ try {
19
+ data = await response.json();
20
+ }
21
+ catch (err) {
22
+ if (err instanceof SyntaxError)
23
+ throw new InvalidJsonResponseError(response);
24
+ throw err;
25
+ }
26
+ if (data === null || typeof data !== "object" || Array.isArray(data))
27
+ throw new InvalidJsonResponseError(response);
28
+ return data;
29
+ }
@@ -1,9 +1,11 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { logger } from "./logger.js";
3
4
  import { extractPdfText } from "./pdf-text-extractor.js";
4
5
  import { putFulltext } from "./zotero-fulltext.js";
5
6
  import { formatWriteErrors } from "./write-results.js";
6
7
  import { errorMessage } from "./error-formatter.js";
8
+ import { fetchWithTimeout, isTimeoutAbort, timeoutError, RequestTimeoutError, TIMEOUTS } from "./timeouts.js";
7
9
  const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
8
10
  function safeBasename(name) {
9
11
  return (name.split(/[\\/]/).pop() ?? "").replace(/[\x00-\x1f"]/g, "").trim();
@@ -53,11 +55,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
53
55
  // 1. Download the file
54
56
  let downloadResponse;
55
57
  try {
56
- downloadResponse = await fetch(options.url, {
57
- headers: {
58
- "User-Agent": "mcp-zotero/1.0 (Open Access PDF retrieval)",
59
- },
60
- });
58
+ downloadResponse = await fetchWithTimeout(options.url, { headers: { "User-Agent": "mcp-zotero/1.0 (Open Access PDF retrieval)" } }, TIMEOUTS.pdfTransferMs, "PDF download");
61
59
  }
62
60
  catch (err) {
63
61
  const detail = errorMessage(err);
@@ -87,8 +85,18 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
87
85
  contentType: responseContentType,
88
86
  });
89
87
  }
90
- const arrayBuffer = await downloadResponse.arrayBuffer();
91
- const buffer = Buffer.from(arrayBuffer);
88
+ let buffer;
89
+ try {
90
+ buffer = Buffer.from(await downloadResponse.arrayBuffer());
91
+ }
92
+ catch (err) {
93
+ // the download bound also covers the body: a server that stalls mid-file ends here
94
+ const detail = isTimeoutAbort(err) ? timeoutError("PDF download", options.url, TIMEOUTS.pdfTransferMs).message : errorMessage(err);
95
+ return {
96
+ success: false,
97
+ error: { code: "network_error", message: `Network error downloading file: ${detail}.`, networkDetail: detail },
98
+ };
99
+ }
92
100
  const filename = options.filename ??
93
101
  resolvePdfFilename(options.url, downloadResponse.headers.get("content-disposition"), options.doi);
94
102
  const title = options.title ?? filename;
@@ -179,7 +187,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
179
187
  });
180
188
  let authResponse;
181
189
  try {
182
- authResponse = await fetch(authUrl, {
190
+ authResponse = await fetchWithTimeout(authUrl, {
183
191
  method: "POST",
184
192
  headers: {
185
193
  "Zotero-API-Key": apiKey,
@@ -187,7 +195,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
187
195
  "If-None-Match": "*",
188
196
  },
189
197
  body: authBody.toString(),
190
- });
198
+ }, TIMEOUTS.zoteroApiMs, "Upload authorization");
191
199
  }
192
200
  catch (err) {
193
201
  const detail = errorMessage(err);
@@ -223,7 +231,19 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
223
231
  },
224
232
  };
225
233
  }
226
- const authData = (await authResponse.json());
234
+ let authData;
235
+ try {
236
+ authData = await readJsonObject(authResponse);
237
+ }
238
+ catch (err) {
239
+ if (!(err instanceof InvalidJsonResponseError))
240
+ throw err;
241
+ return {
242
+ success: false,
243
+ itemKey,
244
+ error: { code: "auth_failed", message: `Upload authorization failed: ${err.message}`, status: authResponse.status, networkDetail: err.message },
245
+ };
246
+ }
227
247
  // 6-7. Upload binary + register (skip if already exists)
228
248
  if (!("exists" in authData)) {
229
249
  const auth = authData;
@@ -232,11 +252,11 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
232
252
  const uploadBody = Buffer.concat([prefix, buffer, suffix]);
233
253
  let uploadResponse;
234
254
  try {
235
- uploadResponse = await fetch(auth.url, {
255
+ uploadResponse = await fetchWithTimeout(auth.url, {
236
256
  method: "POST",
237
257
  headers: { "Content-Type": auth.contentType },
238
258
  body: uploadBody,
239
- });
259
+ }, TIMEOUTS.pdfTransferMs, "File upload");
240
260
  }
241
261
  catch (err) {
242
262
  const detail = errorMessage(err);
@@ -263,7 +283,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
263
283
  }
264
284
  let registerResponse;
265
285
  try {
266
- registerResponse = await fetch(authUrl, {
286
+ registerResponse = await fetchWithTimeout(authUrl, {
267
287
  method: "POST",
268
288
  headers: {
269
289
  "Zotero-API-Key": apiKey,
@@ -271,7 +291,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
271
291
  "If-None-Match": "*",
272
292
  },
273
293
  body: `upload=${auth.uploadKey}`,
274
- });
294
+ }, TIMEOUTS.zoteroApiMs, "Upload registration");
275
295
  }
276
296
  catch (err) {
277
297
  const detail = errorMessage(err);
@@ -309,8 +329,11 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
309
329
  ? "Fulltext indexed successfully. Use get_item_fulltext to retrieve content."
310
330
  : `Fulltext PUT failed: ${putResult.error}. Sync with Zotero Desktop to index the PDF.`;
311
331
  }
312
- catch {
313
- fulltextStatus = "PDF text extraction failed. Sync with Zotero Desktop to index the PDF.";
332
+ catch (err) {
333
+ fulltextStatus =
334
+ err instanceof RequestTimeoutError
335
+ ? `Fulltext indexing failed: ${err.message}. Sync with Zotero Desktop to index the PDF.`
336
+ : "PDF text extraction failed. Sync with Zotero Desktop to index the PDF.";
314
337
  }
315
338
  }
316
339
  else {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Bounds for every network call the server makes. A remote that accepts the connection and then
3
+ * never answers would otherwise block a tool until the MCP client gives up, with no useful error.
4
+ * Values are read at call time, so tests can lower them.
5
+ */
6
+ export const TIMEOUTS = {
7
+ /** Zotero Web API: metadata reads/writes, fulltext, upload authorization and registration. */
8
+ zoteroApiMs: 30_000,
9
+ /** DOI resolution (doi.org / Crossref) and Unpaywall lookups, per attempt. */
10
+ metadataMs: 30_000,
11
+ /**
12
+ * PDF download and upload to Zotero storage, body included. Files are capped at 100 MB, so this
13
+ * allows ~0.6 MB/s for the largest file; typical papers (1-10 MB) finish in seconds.
14
+ */
15
+ pdfTransferMs: 180_000,
16
+ };
17
+ /** A network call that hit its bound. The message names the operation, host and bound. */
18
+ export class RequestTimeoutError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "RequestTimeoutError";
22
+ }
23
+ }
24
+ /** True for the abort raised by AbortSignal.timeout (DOMException "TimeoutError"). */
25
+ export function isTimeoutAbort(err) {
26
+ return typeof err === "object" && err !== null && err.name === "TimeoutError";
27
+ }
28
+ function host(url) {
29
+ try {
30
+ return new URL(url).host;
31
+ }
32
+ catch {
33
+ return url;
34
+ }
35
+ }
36
+ function seconds(ms) {
37
+ return `${ms / 1000} s`;
38
+ }
39
+ export function timeoutError(label, url, timeoutMs) {
40
+ return new RequestTimeoutError(`${label} timed out after ${seconds(timeoutMs)} (${host(url)})`);
41
+ }
42
+ /** Signal that aborts after `timeoutMs`, combined with the caller's signal when there is one. */
43
+ export function timeoutSignal(timeoutMs, signal) {
44
+ const bound = AbortSignal.timeout(timeoutMs);
45
+ return signal ? AbortSignal.any([signal, bound]) : bound;
46
+ }
47
+ /**
48
+ * fetch() with a bound. The signal stays attached to the response, so reading the body is bounded
49
+ * too: callers that read it should map isTimeoutAbort() errors with timeoutError().
50
+ */
51
+ export async function fetchWithTimeout(url, init, timeoutMs, label) {
52
+ try {
53
+ return await fetch(url, { ...init, signal: timeoutSignal(timeoutMs, init.signal) });
54
+ }
55
+ catch (err) {
56
+ if (isTimeoutAbort(err))
57
+ throw timeoutError(label, url, timeoutMs);
58
+ throw err;
59
+ }
60
+ }
61
+ const VERB_OPTS_INDEX = { get: 0, post: 1, put: 1, patch: 1, delete: 1 };
62
+ /**
63
+ * Wraps a zotero-api-client instance so every request carries a fresh AbortSignal.timeout
64
+ * (the client forwards `signal` to fetch). Chain methods return wrapped chains; terminal verbs get
65
+ * the signal merged into their options, and a timeout becomes a RequestTimeoutError.
66
+ */
67
+ export function withRequestTimeout(api, timeoutMs) {
68
+ return new Proxy(api, {
69
+ get(target, prop, receiver) {
70
+ const value = Reflect.get(target, prop, receiver);
71
+ if (typeof value !== "function" || typeof prop !== "string")
72
+ return value;
73
+ const optsIndex = VERB_OPTS_INDEX[prop];
74
+ if (optsIndex === undefined) {
75
+ return (...args) => {
76
+ const result = value.apply(target, args);
77
+ return result && typeof result === "object" ? withRequestTimeout(result, timeoutMs) : result;
78
+ };
79
+ }
80
+ return async (...args) => {
81
+ const bound = timeoutMs ?? TIMEOUTS.zoteroApiMs;
82
+ const opts = (args[optsIndex] ?? {});
83
+ const callArgs = [...args];
84
+ callArgs[optsIndex] = { ...opts, signal: timeoutSignal(bound, opts.signal) };
85
+ try {
86
+ return await value.apply(target, callArgs);
87
+ }
88
+ catch (err) {
89
+ if (isTimeoutAbort(err)) {
90
+ throw new RequestTimeoutError(`Zotero API request (${prop.toUpperCase()}) timed out after ${seconds(bound)} (api.zotero.org)`);
91
+ }
92
+ throw err;
93
+ }
94
+ };
95
+ },
96
+ });
97
+ }
@@ -1,3 +1,4 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
1
2
  import { fetchWithRetry } from "./fetch-retry.js";
2
3
  import { logger } from "./logger.js";
3
4
  import { errorMessage } from "./error-formatter.js";
@@ -28,7 +29,7 @@ export async function lookupOaPdfWithFallbacks(doi) {
28
29
  const url = `https://api.unpaywall.org/v2/${encodeURIComponent(doi)}?email=${encodeURIComponent(email)}`;
29
30
  let response;
30
31
  try {
31
- response = await fetchWithRetry(url);
32
+ response = await fetchWithRetry(url, undefined, { label: "Unpaywall lookup" });
32
33
  }
33
34
  catch (err) {
34
35
  const detail = errorMessage(err);
@@ -39,7 +40,16 @@ export async function lookupOaPdfWithFallbacks(doi) {
39
40
  logger.error("Unpaywall API error", { doi, status: response.status });
40
41
  return { primary: { ...UNAVAILABLE_RESULT }, fallback_urls: [] };
41
42
  }
42
- const data = (await response.json());
43
+ let data;
44
+ try {
45
+ data = await readJsonObject(response);
46
+ }
47
+ catch (err) {
48
+ if (!(err instanceof InvalidJsonResponseError))
49
+ throw err;
50
+ logger.error("Unpaywall API returned an invalid response", { doi, error: err.message });
51
+ return { primary: { ...UNAVAILABLE_RESULT }, fallback_urls: [] };
52
+ }
43
53
  const locations = data.is_oa
44
54
  ? [data.best_oa_location, ...(data.oa_locations ?? [])].filter((l) => l !== null && l !== undefined)
45
55
  : [];
@@ -1,6 +1,7 @@
1
+ import { fetchWithTimeout, TIMEOUTS } from "./timeouts.js";
1
2
  export async function putFulltext(libraryType, userId, itemKey, apiKey, content, totalPages) {
2
3
  const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${itemKey}/fulltext`;
3
- const response = await fetch(url, {
4
+ const response = await fetchWithTimeout(url, {
4
5
  method: "PUT",
5
6
  headers: {
6
7
  "Content-Type": "application/json",
@@ -11,7 +12,7 @@ export async function putFulltext(libraryType, userId, itemKey, apiKey, content,
11
12
  indexedPages: totalPages,
12
13
  totalPages,
13
14
  }),
14
- });
15
+ }, TIMEOUTS.zoteroApiMs, "Zotero fulltext upload");
15
16
  if (response.status === 204) {
16
17
  return { success: true };
17
18
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "2.0.1",
4
+ "version": "2.0.2",
5
5
  "description": "Unofficial MCP server for Zotero integration with citation management",
6
6
  "repository": {
7
7
  "type": "git",
@@ -28,7 +28,9 @@
28
28
  "smoke": "node scripts/smoke-stdio.mjs",
29
29
  "start": "node build/server.js",
30
30
  "prepublishOnly": "npm test && npm run build",
31
- "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts"
31
+ "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
32
+ "test:live": "npm run build && vitest run --config vitest.live.config.ts",
33
+ "test:live:cleanup": "node test/live/cleanup.mjs"
32
34
  },
33
35
  "keywords": [
34
36
  "mcp",
@@ -53,7 +55,10 @@
53
55
  "zotero-api-client": "^0.51.0"
54
56
  },
55
57
  "devDependencies": {
58
+ "@anthropic-ai/claude-agent-sdk": "^0.3.296",
56
59
  "@types/node": "^26.6.4",
60
+ "ajv": "^8.20.0",
61
+ "citeproc": "^2.4.63",
57
62
  "typescript": "^7.0.2",
58
63
  "vitest": "^5.0.3"
59
64
  }