mcp-zotero
# MCP Zotero
> **Note:** This is an unofficial community project and is not affiliated with, endorsed by, or supported by the Zotero team or the Corporation for Digital Scholarship. "Zotero" is a registered trademark of the Corporation for Digital Scholarship.
A Model Context Protocol server for Zotero integration. It gives any LLM full access to your Zotero library: search, organize, add papers by DOI, import PDFs, read full-text content, and inject live citations into Word documents.
> Originally based on [mcp-zotero](https://github.com/kaliaboi/mcp-zotero) by Abhishek Kalia.
> This project has since been extensively rewritten with a new architecture, 15 tools (up from 5), citation injection, PDF management, and Claude skill support.
## How it works
The server is designed to be **usable by any LLM without external documentation**. On connection, it sends workflow instructions via the MCP `instructions` field, and each tool description includes cross-references and usage guidance. An LLM that has never seen this server before can discover the full workflow — from adding papers to producing a cited Word document — directly from the tool listing.
For advanced use cases (PDF upload policy, citation style guidance, source transparency), a **Claude skill** is included for Claude.ai Projects. But the skill is optional: the MCP server is fully self-documenting.
## Local vs Remote LLMs
| Scenario | MCP server | Skill needed? |
|---|---|---|
| LLM with filesystem access (Claude Code, LM Studio, etc.) | All 15 tools | No |
| LLM without filesystem access (Claude.ai Projects, Claude Desktop) | API tools (search, add, metadata) | Yes, for citation injection |
LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` files on disk.
LLMs without filesystem access — including Claude Desktop, which connects to MCP but cannot generate files locally — can use the included **Claude skill** (`skills/zotero-skill-mcp-integrations/`), which runs citation injection entirely inside a sandbox. MCP tools handle all Zotero API operations; the skill handles document assembly.
### Claude Skill Setup (for Claude.ai Projects and Claude Desktop)
1. Download the skill `.zip` from the latest [GitHub Release](https://github.com/Xevos117/mcp-zotero/releases)
2. Extract it and upload the folder to your Claude.ai Project as a skill
3. The skill enables citation injection directly inside the sandbox, without requiring local filesystem access
## Setup
Requires **Node.js 22 or newer**.
1. Get your Zotero credentials:
```bash
# Create an API key at https://www.zotero.org/settings/keys
# (enable library read/write + file access)
# Then retrieve your user ID:
curl -H "Zotero-API-Key: YOUR_API_KEY" https://api.zotero.org/keys/current
```
2. Set environment variables:
```bash
export ZOTERO_API_KEY="your-api-key"
export ZOTERO_USER_ID="user-id-from-curl"
export UNPAYWALL_EMAIL="your@email.edu" # Optional: enables OA PDF lookup via Unpaywall
export UNSAFE_OPERATIONS="none" # Optional: "none" | "items" | "all" (see below)
```
### Targeting a group library
By default the server targets your personal Zotero library (`/users/<ZOTERO_USER_ID>/...`).
To target a group library instead, set two additional environment variables:
| Variable | Values | Required? | Default |
|---|---|---|---|
| `ZOTERO_LIBRARY_TYPE` | `user` \| `group` | No | `user` |
| `ZOTERO_LIBRARY_ID` | numeric library ID | Yes when type=`group`; otherwise falls back to `ZOTERO_USER_ID` | — |
Example for a group library:
```
ZOTERO_API_KEY=...
ZOTERO_USER_ID=12345 # optional for groups: used by get_user_id and per-call library_type="user"
ZOTERO_LIBRARY_TYPE=group
ZOTERO_LIBRARY_ID=6178978 # the group ID
```
The API key in `ZOTERO_API_KEY` must have access to the target group library — generate or scope keys at https://www.zotero.org/settings/keys.
The server refuses to start if `ZOTERO_LIBRARY_TYPE=group` is set without `ZOTERO_LIBRARY_ID`, or if a library ID is not numeric.
### Per-call library override
Every tool accepts two optional args that override the env defaults for that single call:
- `library_type` — `"user"` or `"group"`
- `library_id` — numeric library ID
Resolution order: per-call arg > env var > implicit default (`user`, `ZOTERO_USER_ID`). `library_id` must be numeric. Overriding `library_type` to `group` on a server configured for a user library requires `library_id`; overriding to `user` on a group server without `library_id` uses `ZOTERO_USER_ID`.
Note that per-call overrides let the LLM reach any library the API key can access. Scope the key to the libraries you want exposed; deletions remain gated by `UNSAFE_OPERATIONS`.
This lets a single MCP instance target multiple libraries (e.g., a staging group and a final-output group) without restarting:
```jsonc
// First call targets the staging group
{
"tool": "add_items",
"args": {
"items": [...],
"library_type": "group",
"library_id": "5597114"
}
}
// Second call targets DART-output (different group)
{
"tool": "add_items",
"args": {
"items": [...],
"library_type": "group",
"library_id": "6178978"
}
}
```
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `ZOTERO_API_KEY` | Yes | API key for Zotero Web API v3. Create one at [zotero.org/settings/keys](https://www.zotero.org/settings/keys) with library read/write and file access permissions. |
| `ZOTERO_USER_ID` | Yes | Your Zotero numeric user ID. Retrieve it with `curl -H "Zotero-API-Key: KEY" https://api.zotero.org/keys/current`. |
| `UNPAYWALL_EMAIL` | No | Email for Unpaywall API requests ([rate-limit policy](https://unpaywall.org/products/api)). Enables OA PDF lookup in `add_items_by_doi` and `find_and_attach_pdfs`. If not set, OA PDF features are silently skipped. |
| `UNSAFE_OPERATIONS` | No | Controls destructive operations (deletion). See [Unsafe Operations](#unsafe-operations) below. Default: `none` (all deletions blocked). |
### Unsafe Operations
By default, the MCP server **does not allow any deletion**. This is a safety measure to prevent an LLM from accidentally deleting items or collections from your library.
To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of the following values:
| Value | `delete_items` | `delete_collection` | Use case |
|---|---|---|---|
| `none` (default) | Blocked | Blocked | Safe mode — no deletions possible |
| `items` | **Allowed** | Blocked | Allow deleting items but protect collection structure |
| `all` | **Allowed** | **Allowed** | Full access — items and collections can be deleted |
**Important notes:**
- If `UNSAFE_OPERATIONS` is not set, empty, or set to an unrecognized value, it defaults to `none`.
- The value is **case-insensitive** (e.g. `ALL`, `Items`, `NONE` all work).
- `delete_items` deletes items **permanently**: the Zotero Web API multi-item DELETE does not move them to the Zotero trash, so they cannot be restored from the desktop client.
- `delete_collection` removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
- The `all` value includes both item and collection deletion because managing collections inherently requires item-level access.
**Configuration example:**
```json
{
"mcpServers": {
"zotero": {
"command": "npx",
"args": ["-y", "@xevos117/mcp-zotero"],
"env": {
"ZOTERO_API_KEY": "YOUR_API_KEY",
"ZOTERO_USER_ID": "YOUR_USER_ID",
"UNSAFE_OPERATIONS": "items"
}
}
}
}
```
## Integration with Claude Desktop
Add to your Claude Desktop configuration:
```json
{
"mcpServers": {
"zotero": {
"command": "npx",
"args": ["-y", "@xevos117/mcp-zotero"],
"env": {
"ZOTERO_API_KEY": "YOUR_API_KEY",
"ZOTERO_USER_ID": "YOUR_USER_ID",
"UNPAYWALL_EMAIL": "YOUR_EMAIL"
}
}
}
}
```
## Integration with Claude Code
```bash
claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"env":{"ZOTERO_API_KEY":"...","ZOTERO_USER_ID":"..."}}'
```
## Available Tools
### Library browsing
| Tool | Description |
|---|---|
| `get_collections` | List all collections (folders) with keys, names, and parent relationships |
| `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
| `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string and the structured `creators` |
| `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
### Adding content
| Tool | Description |
|---|---|
| `add_items_by_doi` | Add papers by DOI with automatic metadata resolution. Auto-attaches OA PDFs via Unpaywall |
| `add_items` | Add items with direct metadata — supports all 37 Zotero item types (books, theses, reports, etc.), batch-capable |
| `create_collection` | Create a new collection, optionally nested under a parent |
| `import_pdf_to_zotero` | Download a PDF from URL, upload to Zotero storage, auto-index full text |
| `find_and_attach_pdfs` | Batch OA PDF lookup and auto-attach via Unpaywall (by item keys or collection) |
| `add_linked_url_attachment` | Attach a URL to an existing item or create a standalone link |
### Deleting content
| Tool | Description |
|---|---|
| `delete_items` | Permanently delete up to 50 items per call (not moved to the Zotero trash). Requires `UNSAFE_OPERATIONS=items` or `all` |
| `delete_collection` | Delete a collection (folder). Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
### Citation & documents
| Tool | Description |
|---|---|
| `inject_citations` | Inject live Zotero citations into a Word document. Supports APA, IEEE, Vancouver, Harvard, Chicago. Output is saved in the same folder as the input file with a `_cited` suffix (e.g. `paper.docx` → `paper_cited.docx`) |
| `get_user_id` | Returns the configured Zotero user ID |
## Development
```bash
npm install
npm run build # Compile TypeScript
npm test # Unit and per-tool tests (vitest, fetch faked, ~1000 tests)
npm run test:e2e # Built server over stdio against a local fake Zotero API
npm run smoke # Quick stdio smoke test of the built server (user + group library)
npx tsx src/server.ts # Run directly without building
```
### Testing
| Layer | Command | Runs on | What it covers |
|---|---|---|---|
| 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)) |
| E2E + smoke | `npm run test:e2e`, `npm run smoke` | every PR | the compiled server over stdio, user and group libraries |
| 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 |
| 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 |
| Word | manual | every release | [test/WORD_CHECKLIST.md](test/WORD_CHECKLIST.md): open an injected document in Word with the Zotero plugin |
Every test case is listed in [test/TEST_CASES.md](test/TEST_CASES.md).
**Live tests locally** use only `ZOTERO_TEST_*` variables, never your `ZOTERO_API_KEY`:
```bash
ZOTERO_TEST_API_KEY=... ZOTERO_TEST_USER_ID=... UNPAYWALL_EMAIL=you@example.org npm run test:live
```
Against a personal library add `ZOTERO_LIVE_SAFE_MODE=1`: the suite only creates items and collections
named `mzt-<runId>…` and tagged `mzt-run:<runId>`, deletes only those, and skips collection deletion and
group writes. A dedicated test account is marked by a collection named `__mzt_disposable__`.
**Dependencies** are updated monthly by a Claude Code agent ([workflow](.github/workflows/deps-update.yml),
skill [`.claude/skills/deps-update`](.claude/skills/deps-update/SKILL.md)); patch-only PRs are auto-merged after
all required checks pass, anything else waits for review. GitHub Actions versions are updated by Dependabot.
### CI setup (repository owner)
| Environment | Secrets | Variables |
|---|---|---|
| `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` |
| `claude-deps` (dependency agent) | — | the same four `ANTHROPIC_*` variables (the federation rule may differ) |
The agents authenticate to the Claude API with Anthropic Workload Identity Federation (GitHub OIDC, no
API key). Recommended: one federation rule per environment, as narrow as possible:
| Rule for | subject (exact, no `*`) | claims (exact string pairs) | condition (CEL) |
|---|---|---|---|
| `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) |
| `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` | — |
Claims are exact-match string pairs (only `subject_prefix` supports a trailing `*`), so a prefix such as
"this workflow on any ref" must be written as a CEL `condition`, as above; an exact `job_workflow_ref`
(claude-deps, main only) can be a plain claim.
Audience `https://api.anthropic.com`. The subject is an exact match unless it ends with `*`; jobs that use
an environment get the OIDC subject `repo:Xevos117/mcp-zotero:environment:<environment>`, so a rule
written for a branch or PR subject never matches. Numeric ids (not names) keep a renamed or re-created
repository/account from matching.
Trade-off for `zotero-live`: the "any ref" condition is needed for the agent smoke test to run on pull
requests, but it also accepts a modified copy of that workflow file pushed to any branch of this repository
(only people with write access can push). Pinning it to the exact claim
`job_workflow_ref: Xevos117/mcp-zotero/.github/workflows/claude-agent-smoke.yml@refs/heads/main` closes that
gap but disables the agent smoke test on PRs (it would then run only on manual runs from `main`).
Other owner settings:
- The Zotero test account needs a collection named `__mzt_disposable__`.
- Repository settings: "Allow auto-merge", and "Allow GitHub Actions to create and approve pull requests"
(the dependency workflow opens its PR with `GITHUB_TOKEN`). Keep the ruleset at **0 required approvals**:
auto-merge of the dependency PR relies on it, and it is also what makes the second setting acceptable. That
setting is a conscious trade-off: it lets workflows open PRs (needed) and approve them (not used by any
workflow here); if approvals are ever required, it would let a workflow approve its own PR, so revisit it
together with any approval requirement.
- No GitHub App is used by any workflow (the agents authenticate to Anthropic with Workload Identity
Federation and use only `GITHUB_TOKEN` on GitHub, read-only for the agents). If the Claude GitHub App is
installed on this repository, uninstall it.
- Set a monthly spend limit on the Anthropic workspace used by the federation rules: it is the real cap on
agent costs.
- Residual risk: the agent smoke job that holds the OIDC token also runs the MCP server built from the PR's
code (that is what it tests). It runs only for same-repository PRs by the owner or a bot, the token is
bound to the `zotero-live` environment and the workspace spend limit caps any misuse.
- Required checks after the stack merges: `test (node 22)`, `test (node 24)`, `test (node 26)`,
`live-tests`, `agent-smoke`.
### Releasing
1. Update `CHANGELOG.md` (move `Unreleased` to the new version) and `version` in `package.json`.
2. Make sure CI, live tests and the agent smoke test are green on `main`.
3. Run the manual [Word checklist](test/WORD_CHECKLIST.md) on a document injected by the release candidate
and record the results in its log.
4. Publish the GitHub release: the [release workflow](.github/workflows/release.yml) runs the tests, publishes
to npm and attaches the skill zips.
### Debug with MCP Inspector
```bash
npx @modelcontextprotocol/inspector npx tsx src/server.ts
```
## License
MIT - see [LICENSE](LICENSE) for details.
TDQS
Scored across 15 tools
Each tool targets a distinct resource+action pair, and overlapping tools (e.g., add_items vs add_items_by_doi vs import_pdf_to_zotero) are explicitly differentiated with clear use cases and workflows. No two tools appear to do the same job.
All tool names follow a consistent verb_noun pattern in snake_case (e.g., get_collections, create_collection, add_items_by_doi, delete_items). Even multi-word names maintain the verb-first convention, making the API predictable.
15 tools is well within the ideal range for a domain-specific library management server. Each tool serves a distinct purpose with no redundancy, and the scope is appropriately focused on Zotero workflows.
The tool surface covers library CRUD for collections and items, searching, metadata retrieval, fulltext access, PDF attachment, and citation injection. Minor gaps exist: there are no update/modify operations for existing items or collections, and no tools for managing individual attachments beyond adding them.