@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 +88 -1
- package/build/citation-injector/injector.js +21 -5
- package/build/server.js +3 -1
- package/build/tools/add-items-by-doi.js +1 -1
- package/build/tools/get-item-fulltext.js +30 -7
- package/build/tools/import-pdf-to-zotero.js +4 -1
- package/build/tools/inject-citations.js +1 -1
- package/build/utils/attachments.js +13 -2
- package/build/utils/doi-resolver.js +11 -2
- package/build/utils/fetch-retry.js +2 -1
- package/build/utils/item-formatter.js +2 -1
- package/build/utils/json-response.js +29 -0
- package/build/utils/pdf-uploader.js +39 -16
- package/build/utils/timeouts.js +97 -0
- package/build/utils/unpaywall.js +12 -2
- package/build/utils/zotero-fulltext.js +3 -2
- package/package.json +7 -2
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 #
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 ("../", "?", "&")
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/build/utils/unpaywall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
}
|