@xevos117/mcp-zotero 2.0.0 → 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
  }
@@ -123,8 +133,20 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
123
133
  }
124
134
  // Collect all unique item keys
125
135
  const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
136
+ // Keys from the document go into API URL paths (one GET per key): only letters and digits, so a crafted
137
+ // <zcite> cannot point the request at another endpoint ("../", "?", "&"). The skill's inject.js applies the same check
138
+ const invalid = [...uniqueKeys].filter((k) => !/^[A-Za-z0-9]{1,32}$/.test(k));
139
+ if (invalid.length > 0) {
140
+ throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (letters and digits only)`);
141
+ }
126
142
  // Fetch CSL data from Zotero
127
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
+ }
128
150
  // Replace each zcite tag with a field code
129
151
  let injected = 0;
130
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() {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { resolveDois } from "../utils/doi-resolver.js";
4
5
  import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
@@ -19,10 +20,9 @@ WORKFLOW TIPS:
19
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.`,
20
21
  inputSchema: {
21
22
  dois: z
22
- .array(z.string())
23
+ .array(z.string().trim().min(1, "DOI must not be empty"))
23
24
  .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
24
- collection_key: z
25
- .string()
25
+ collection_key: zoteroKey()
26
26
  .optional()
27
27
  .describe("Zotero collection key to add items to. Get this from create_collection or get_collections."),
28
28
  tags: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { postInBatches } from "../utils/write-results.js";
4
5
  import { ZOTERO_ITEM_TYPES, ITEM_TYPE_FIELDS, ITEM_TYPE_CREATOR_TYPES, TITLE_FIELD_NAME, } from "../utils/zotero-item-types.js";
@@ -81,8 +82,7 @@ Invalid fields or creatorTypes for a given type are rejected with helpful error
81
82
  .array(ItemSchema)
82
83
  .min(1)
83
84
  .describe("Array of items to add. Each item must have itemType and title. Additional fields depend on the item type."),
84
- collection_key: z
85
- .string()
85
+ collection_key: zoteroKey()
86
86
  .optional()
87
87
  .describe("Zotero collection key to add all items to. Get this from create_collection or get_collections."),
88
88
  tags: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { formatWriteErrors } from "../utils/write-results.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -15,12 +16,11 @@ export const toolConfig = {
15
16
  .string()
16
17
  .optional()
17
18
  .describe('MIME type of the linked resource (e.g. "application/pdf", "text/html")'),
18
- parent_item: z
19
- .string()
19
+ parent_item: zoteroKey()
20
20
  .optional()
21
21
  .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
22
22
  collections: z
23
- .array(z.string())
23
+ .array(zoteroKey())
24
24
  .optional()
25
25
  .describe("Collection keys to add the attachment to (only used for standalone attachments, ignored when parent_item is set)"),
26
26
  tags: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { formatWriteErrors } from "../utils/write-results.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -7,8 +8,7 @@ export const toolConfig = {
7
8
  description: "Create a new collection (folder) in your Zotero library. Optionally nest it under a parent collection. Returns the new collection key and name. Use the key with add_items_by_doi to organize imported papers.",
8
9
  inputSchema: {
9
10
  name: z.string().describe("Name of the new collection"),
10
- parent_collection: z
11
- .string()
11
+ parent_collection: zoteroKey()
12
12
  .optional()
13
13
  .describe("Zotero collection key of the parent collection. Get this from get_collections."),
14
14
  ...libraryArgsSchema,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
5
  import { canDeleteCollections } from "../utils/unsafe-operations.js";
@@ -7,9 +8,7 @@ export const toolConfig = {
7
8
  name: "delete_collection",
8
9
  description: "Delete a collection (folder) from your Zotero library. Items inside the collection are NOT deleted — they remain in your library. Requires UNSAFE_OPERATIONS environment variable set to 'all'.",
9
10
  inputSchema: {
10
- collection_key: z
11
- .string()
12
- .min(1)
11
+ collection_key: zoteroKey()
13
12
  .describe("Zotero collection key to delete. Get this from get_collections."),
14
13
  ...libraryArgsSchema,
15
14
  },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
5
  import { canDeleteItems } from "../utils/unsafe-operations.js";
@@ -9,7 +10,7 @@ export const toolConfig = {
9
10
  description: "Delete one or more items from your Zotero library: they are permanently deleted (not moved to the Zotero trash). Accepts up to 50 item keys per call. Requires UNSAFE_OPERATIONS environment variable set to 'items' or 'all'.",
10
11
  inputSchema: {
11
12
  item_keys: z
12
- .array(z.string())
13
+ .array(zoteroKey())
13
14
  .min(1)
14
15
  .max(50)
15
16
  .describe('Array of Zotero item keys to delete (e.g. ["EUHUT5K3", "F9UQM7N2"]). Max 50 per call.'),
@@ -44,7 +45,8 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
44
45
  item_keys,
45
46
  });
46
47
  }
47
- const keysToDelete = [...foundKeys];
48
+ // only keys the caller asked for, whatever the API returned
49
+ const keysToDelete = item_keys.filter((k) => foundKeys.has(k));
48
50
  await zoteroApi
49
51
  .library(libraryType, libraryId)
50
52
  .items()
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, formatEmptyResult, errorMessage, logToolError } from "../utils/error-formatter.js";
3
4
  import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
4
5
  import { fetchAllPages, fetchItemsByKeys } from "../utils/pagination.js";
@@ -8,11 +9,10 @@ export const toolConfig = {
8
9
  description: "For each Zotero item, check Unpaywall for open access PDFs and attach them. Items must have a DOI. Uses the same source as Zotero Desktop's 'Find Available PDFs'.",
9
10
  inputSchema: {
10
11
  item_keys: z
11
- .array(z.string())
12
+ .array(zoteroKey())
12
13
  .optional()
13
14
  .describe("Array of Zotero item keys to process (mutually exclusive with collection_key)"),
14
- collection_key: z
15
- .string()
15
+ collection_key: zoteroKey()
16
16
  .optional()
17
17
  .describe("Process all items in this collection (mutually exclusive with item_keys)"),
18
18
  skip_if_attachment_exists: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../utils/error-formatter.js";
4
5
  import { formatCreators, formatTags } from "../utils/item-formatter.js";
@@ -8,7 +9,7 @@ export const toolConfig = {
8
9
  name: "get_collection_items",
9
10
  description: "Get all items in a specific Zotero collection. Returns item keys, titles, authors, and dates. Use the collectionKey from get_collections. Use the returned item keys with get_items_details, get_item_fulltext, or inject_citations.",
10
11
  inputSchema: {
11
- collectionKey: z.string().describe("The collection key/ID"),
12
+ collectionKey: zoteroKey().describe("The collection key/ID"),
12
13
  excludeAttachments: z
13
14
  .boolean()
14
15
  .default(true)
@@ -1,13 +1,16 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "../utils/json-response.js";
1
2
  import { z } from "zod";
3
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
4
  import { isZoteroApiError } from "../types/zotero-types.js";
3
5
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
6
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
5
- 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";
6
9
  export const toolConfig = {
7
10
  name: "get_item_fulltext",
8
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.",
9
12
  inputSchema: {
10
- item_key: z.string().describe("The Zotero item key (parent item or attachment key)"),
13
+ item_key: zoteroKey().describe("The Zotero item key (parent item or attachment key)"),
11
14
  max_characters: z
12
15
  .number()
13
16
  .optional()
@@ -31,11 +34,20 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
31
34
  .items(item_key)
32
35
  .get();
33
36
  const itemData = itemResponse.getData();
34
- // 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)
35
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
+ }
36
42
  return fetchFulltext(item_key, item_key, libraryType, libraryId, apiKey, max_characters);
37
43
  }
38
- 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
+ }
39
51
  if (!pdfAttachment?.key) {
40
52
  // Provide context-aware error messages
41
53
  if (itemData.itemType === "webpage" || itemData.itemType === "blogPost") {
@@ -59,16 +71,28 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
59
71
  }
60
72
  async function fetchFulltext(itemKey, attachmentKey, libraryType, userId, apiKey, maxCharacters) {
61
73
  const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${attachmentKey}/fulltext`;
62
- const response = await fetch(url, {
63
- headers: { "Zotero-API-Key": apiKey },
64
- });
74
+ const response = await fetchWithTimeout(url, { headers: { "Zotero-API-Key": apiKey } }, TIMEOUTS.zoteroApiMs, "Zotero fulltext request");
65
75
  if (response.status === 404) {
66
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 });
67
77
  }
68
78
  if (!response.ok) {
69
79
  return formatErrorResponse(`Zotero fulltext API returned status ${response.status}`, { item_key: itemKey, attachment_key: attachmentKey });
70
80
  }
71
- 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
+ }
72
96
  let text = data.content;
73
97
  let truncated = false;
74
98
  if (maxCharacters > 0 && text.length > maxCharacters) {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../utils/error-formatter.js";
3
4
  import { formatCreators } from "../utils/item-formatter.js";
4
5
  import { fetchItemsByKeys } from "../utils/pagination.js";
@@ -8,7 +9,7 @@ export const toolConfig = {
8
9
  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 one call per item. 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
10
  inputSchema: {
10
11
  item_keys: z
11
- .array(z.string())
12
+ .array(zoteroKey())
12
13
  .describe('Array of Zotero item keys (e.g. ["EUHUT5K3", "F9UQM7N2"]). Get these from search_library, add_items_by_doi, or get_collection_items.'),
13
14
  include_abstract: z
14
15
  .boolean()
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, errorMessage, logToolError } from "../utils/error-formatter.js";
3
4
  import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -19,13 +20,12 @@ export const toolConfig = {
19
20
  .string()
20
21
  .optional()
21
22
  .default("application/pdf")
22
- .describe('MIME type of the file (default: "application/pdf")'),
23
- parent_item: z
24
- .string()
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
+ 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."),
27
27
  collections: z
28
- .array(z.string())
28
+ .array(zoteroKey())
29
29
  .optional()
30
30
  .describe("Collection keys (only used for standalone attachments, ignored when parent_item is set)"),
31
31
  tags: z
@@ -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,3 +1,4 @@
1
+ import { ZOTERO_KEY } from "./zotero-keys.js";
1
2
  const PAGE_LIMIT = 100;
2
3
  /** getData() returns an object for single-item requests and an array otherwise. */
3
4
  export function asArray(data) {
@@ -30,15 +31,22 @@ export const MAX_KEYS_PER_REQUEST = 50;
30
31
  * Fetch items by key in chunks of MAX_KEYS_PER_REQUEST, each with an explicit `limit`
31
32
  * (the API default page size is 25, so a bare `itemKey` GET silently drops the rest).
32
33
  * Returns the items found and the library version of the last response.
34
+ *
35
+ * When no key in an `itemKey` filter is well-formed, the Web API ignores the filter and returns
36
+ * the whole library. Tools reject malformed keys up front (zoteroKey schema); as a second line of
37
+ * defense malformed keys are never sent here (no request at all if none is valid) and only items
38
+ * whose key was requested are returned.
33
39
  */
34
40
  export async function fetchItemsByKeys(buildRequest, keys) {
35
41
  const items = [];
36
42
  let version = null;
37
- for (let start = 0; start < keys.length; start += MAX_KEYS_PER_REQUEST) {
38
- const chunk = keys.slice(start, start + MAX_KEYS_PER_REQUEST);
43
+ const valid = [...new Set(keys.filter((k) => ZOTERO_KEY.test(k)))];
44
+ const requested = new Set(valid);
45
+ for (let start = 0; start < valid.length; start += MAX_KEYS_PER_REQUEST) {
46
+ const chunk = valid.slice(start, start + MAX_KEYS_PER_REQUEST);
39
47
  const response = await buildRequest({ itemKey: chunk.join(","), limit: chunk.length });
40
48
  const data = response.getData();
41
- items.push(...asArray(data));
49
+ items.push(...asArray(data).filter((item) => typeof item.key === "string" && requested.has(item.key)));
42
50
  version = response.getVersion();
43
51
  }
44
52
  return { items, version };
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ /** Zotero object keys (items, collections): 8 characters from this alphabet, never 0, 1, O or lowercase. */
3
+ export const ZOTERO_KEY = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/;
4
+ export const INVALID_KEY_MESSAGE = "Invalid Zotero key: expected 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ";
5
+ /**
6
+ * Schema for every key argument of every tool. Keys end up in API URL paths (zotero-api-client does not
7
+ * encode path segments, and URL parsing resolves "..") and in itemKey filters (ignored by the API when
8
+ * no key is well-formed): a malformed key must be rejected before any request.
9
+ */
10
+ export const zoteroKey = () => z.string().regex(ZOTERO_KEY, INVALID_KEY_MESSAGE);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "2.0.0",
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
  }