Zotero MCP Server
# Zotero MCP Server — Setup Tutorial
This project is a local [MCP](https://modelcontextprotocol.io) server that gives Claude direct, read-only access to your Zotero library: metadata, tags, collections, notes, PDF full text (with OCR fallback for scans), and PDF annotations/highlights — plus search and multi-item comparison tools, so you can do NotebookLM-style Q&A and synthesis over your own research library, entirely on your own machine.
Everything below runs **locally on your Mac**. Nothing in this project uploads your library anywhere; the only thing that leaves your machine is whatever Claude includes in its replies during a normal conversation, exactly as with any other document you show it.
Target locations on your Mac:
- Server code: `~/ClaudeFolders/ServerMCP/zotero-mcp/` (pick whatever folder you like — this is just the convention used in the setup steps below)
- Your Zotero data (already exists): `~/Zotero/` (`zotero.sqlite` + `storage/` with your PDFs)
> **Origin**: the initial idea/prompt for this project came from a post on Reddit; this implementation was then built out interactively with Claude.
---
## Features
- Read-only access to your whole Zotero library: collections, items/metadata, tags, notes, PDF full text, and PDF annotations/highlights.
- OCR fallback (via Tesseract) for scanned/image-only PDF pages that have no extractable text layer.
- Full-text and notes search backed by a separate SQLite FTS5 index this server builds and maintains itself — it never writes to Zotero's own database.
- A `compare_items` tool that bundles several items (metadata, abstracts, tags, annotations, optionally full text) in one call, for cross-paper synthesis.
- Automatically covers **Zotero group/shared libraries** as well as your personal library — a `list_libraries` tool lists what's available, and every search/list tool covers all of them by default (or can be scoped to one).
- Works as an MCP server for both Claude Desktop and the Claude Code CLI.
- Optional weekly automatic re-indexing via a macOS `launchd` job, with an `index_last_updated` freshness timestamp surfaced in relevant tool responses so Claude always tells you how current a full-text result is.
- Strictly read-only by design: it cannot create, edit, or delete anything in your Zotero library or on disk.
## Limitations
- **macOS only, as packaged.** Setup (Terminal steps, `~/Library/...` paths) and the optional automatic re-indexing (`launchd`) are macOS-specific. The Python code itself is plain and cross-platform, so it would likely run on Linux with an equivalent scheduler (cron/systemd) in place of the provided `.plist`, and probably needs only minor path handling changes for Windows — but neither has been tested, and the setup instructions below assume macOS throughout.
- Requires Zotero's desktop app to have actually synced your library locally (`zotero.sqlite` + `storage/` with the PDFs present on disk). A library that only exists in the cloud and has never been opened locally won't have anything to read.
- Only PDF attachments are extracted for full-text search; other attachment types (web page snapshots, EPUBs, images without OCR'd text, etc.) aren't indexed.
- Full-text/notes search depends on a separately built index (`rebuild_search_index` tool, or `scripts/build_index.py`) rather than being always live against the current library state — very recent additions won't show up in full-text search until the next (re)index runs (metadata search and collection/item listing are always live, no indexing needed).
- Designed as a **local** MCP server: it's meant to run on the same machine as Zotero, launched by a local Claude Desktop app or the Claude Code CLI. Using it from a remote/cloud Claude session requires that session to be bridged to your computer somehow (e.g. a device-bridge feature of whichever Claude product you're using) — this isn't a built-in feature of the server itself.
- Group/shared library visibility is limited to whatever your own Zotero account has already synced locally — the server only reads what's there; it can't request, elevate, or otherwise change your Zotero group permissions.
- No authentication, multi-user, or remote-access layer of any kind — it's a single local process reading a single local SQLite file, intended for one person's own machine.
---
## 0. How it works, in one paragraph
Zotero stores everything in a SQLite database (`zotero.sqlite`) plus a `storage/` folder holding the actual PDF files. This server opens that database **read-only** (it never writes to it, so it can't corrupt your Zotero library), and exposes a set of tools Claude can call: list collections, list/search items, fetch an item's full metadata, pull the full text of its PDF (extracted on demand with PyMuPDF, OCR'd with Tesseract if it's a scanned page), read your notes and PDF highlights/annotations, and run full-text search across the whole library using a separate search index this server builds for itself (never touching Zotero's own database file). A `compare_items` tool bundles several items at once so Claude can synthesize across papers in one go.
---
## 1. Prerequisites
Open Terminal and check you have what's needed:
```bash
python3 --version # should be 3.10 or newer
```
You'll also want [Homebrew](https://brew.sh) for installing Tesseract (OCR engine). Check if it's installed:
```bash
brew --version
```
If that fails, install Homebrew first: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
---
## 2. Get the project files onto your Mac
The project folder is prepared and delivered to you as a zip alongside this tutorial. Once you've saved/unzipped it:
```bash
mkdir -p ~/ClaudeFolders
# unzip the delivered project so the result is:
# ~/ClaudeFolders/ServerMCP/zotero-mcp/...
```
If you're setting this up with Claude's help (Cowork, or any session linked to your Mac), it can do this step directly once you grant it access to the `~/ClaudeFolders` and `~/Zotero` folders — just ask.
Resulting layout:
```
~/ClaudeFolders/ServerMCP/zotero-mcp/
├── zotero_mcp/ # the Python package (server + data access)
│ ├── server.py # MCP server entry point — the tools Claude calls
│ ├── db.py # read-only Zotero SQLite access
│ ├── fulltext.py # PDF text extraction + OCR fallback
│ ├── indexer.py # full-text search index (separate from Zotero's DB)
│ ├── tools_helpers.py
│ └── config.py
├── scripts/
│ └── build_index.py # CLI: build/update the full-text search index
├── test_connection.py # CLI: sanity-check your Zotero setup
├── pyproject.toml # dependencies + the `zotero-mcp` command
├── requirements.txt
├── .env.example # copy to .env and edit
└── README.md # this file
```
---
## 3. Install Tesseract (OCR engine)
You chose to enable OCR, so scanned/image-only PDF pages can be indexed too:
```bash
brew install tesseract
```
Verify:
```bash
tesseract --version
```
(If you ever want to turn OCR off — faster indexing, but scanned PDFs won't be full-text searchable — set `OCR_ENABLED=false` in `.env`, step 5, and skip this.)
---
## 4. Create a Python virtual environment and install the server
```bash
cd ~/ClaudeFolders/ServerMCP/zotero-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -e .
```
`pip install -e .` reads `pyproject.toml`, installs all dependencies (the `mcp` SDK, PyMuPDF, pytesseract, Pillow, python-dotenv), and registers a `zotero-mcp` command inside this virtual environment — that command is what Claude will launch later.
Check it installed cleanly:
```bash
which zotero-mcp # should print .../ServerMCP/zotero-mcp/.venv/bin/zotero-mcp
```
---
## 5. Configure `.env`
```bash
cp .env.example .env
```
Open `.env` in a text editor (or `nano .env`) and confirm/adjust:
```
ZOTERO_DATA_DIR=~/Zotero
OCR_ENABLED=true
OCR_LANGUAGE=eng
```
`ZOTERO_DATA_DIR` is the only setting you're likely to need to touch — it should already be correct given your setup. Leave the rest at their defaults unless you know you need to change them (see the comments in `.env.example` for what each one does — e.g. `ZOTERO_LIBRARY_ID` only matters if you use Zotero group libraries).
**Important:** if `~/Zotero` is synced by any cloud backup/sync tool, make sure all your PDFs have fully finished downloading to this Mac before you build the search index (step 7) — a file that's only a cloud placeholder won't have content to extract yet.
---
## 6. Sanity-check the connection
With the virtual environment still active:
```bash
python test_connection.py
```
This should print your library's item/collection/tag counts and confirm PDF files are found on disk. If it errors, it will tell you what's wrong (wrong path, Zotero database not found, etc.) — fix that before continuing.
---
## 7. Build the full-text search index
```bash
python scripts/build_index.py
```
This extracts text from every PDF (OCR'ing scanned pages) and every note, and stores it in `cache/fulltext_index.sqlite` inside this project — completely separate from Zotero's own database. Depending on your library size this can take a while the first time (OCR especially); it prints progress as it goes. It's incremental: re-running it later only processes new or changed files.
You don't have to wait for this to finish before wiring the server into Claude — `get_item_fulltext` and `get_item_notes`/`get_item_annotations` work immediately without the index (they extract on demand). Only the `search_library` tool's `fulltext`/`notes` scopes need this index built.
---
## 8. Connect it to Claude
Set up whichever client(s) you actually use — Claude Desktop, Claude Code, or both.
### 8a. Claude Desktop app
Edit (creating it if it doesn't exist):
`~/Library/Application Support/Claude/claude_desktop_config.json`
**If this file already has content** (it often does — the desktop app stores other settings there too), don't replace the whole file. Add `mcpServers` as one more top-level key inside the existing `{ ... }` object, alongside whatever's already there — a JSON file can only have one top-level object, so pasting a second `{ "mcpServers": ... }` block after the closing brace will silently break the file. After editing, validate it before restarting the app:
```bash
python3 -m json.tool ~/Library/Application\ Support/Claude/claude_desktop_config.json > /dev/null && echo "valid JSON"
```
If the file is empty or doesn't exist yet, this is the whole content:
```json
{
"mcpServers": {
"zotero": {
"command": "/Users/YOUR_USERNAME/ClaudeFolders/ServerMCP/zotero-mcp/.venv/bin/zotero-mcp"
}
}
}
```
Replace `YOUR_USERNAME` with your actual Mac username (or run `echo ~` in Terminal to get the full path and use that verbatim — use the absolute path, not `~`, since the app doesn't expand `~` itself).
Quit and reopen Claude Desktop completely (Cmd+Q, not just closing the window), then check **Settings → Developer** (or the small hammer/tools icon in a chat) to confirm "zotero" shows as connected.
### 8b. Claude Code (CLI)
Either add it as a project-scoped `.mcp.json` in whatever project folder you run `claude` from:
```json
{
"mcpServers": {
"zotero": {
"command": "/Users/YOUR_USERNAME/ClaudeFolders/ServerMCP/zotero-mcp/.venv/bin/zotero-mcp"
}
}
}
```
...or use the CLI helper (syntax can vary slightly by Claude Code version — run `claude mcp add --help` if this doesn't match what you have):
```bash
claude mcp add zotero /Users/YOUR_USERNAME/ClaudeFolders/ServerMCP/zotero-mcp/.venv/bin/zotero-mcp
```
Then verify with `claude mcp list` (or the `/mcp` slash command inside a session).
---
## 9. Try it
Start a fresh Claude conversation (Desktop or Code) and try things like:
- "List the collections in my Zotero library."
- "Search my Zotero library for anything about [your topic]."
- "Summarize the paper '[a title from your library]' and pull out anything I highlighted."
- "Compare these three papers on [topic] — what do they agree and disagree on?" (give titles/keys, or ask Claude to search first)
- "What Zotero libraries do you have access to?" (lists your personal library plus any group/shared libraries)
Claude will call `list_collections`, `search_library`, `get_item`, `get_item_fulltext`, `get_item_annotations`, `compare_items`, etc. as needed — you don't need to name the tools yourself.
Whenever Claude relies on the full-text search index (the `fulltext`/`notes` scopes of `search_library`, or `get_library_stats`), it's instructed to tell you when that index was last built (e.g. "based on your library as of 2026-09-15 03:00") — so you always know how current the results are. `get_item_fulltext`, `get_item_notes`, and `get_item_annotations` don't need this since they read live, on demand.
---
## 10. Group / shared libraries
If you're a member of any Zotero group libraries (shown under "Group Libraries" in the Zotero sidebar), the server sees them automatically — they live in the same local `zotero.sqlite` as your personal library, just under a different internal ID. No extra configuration is needed.
By default, every tool searches and lists across **all** your libraries at once — personal and group/shared combined — so "search my library for X" already covers shared libraries too. Call `list_libraries` to see exactly what's available:
```
"What Zotero libraries can you see?"
```
This returns each library's `library_id`, `name`, and `type` (`user` for your personal library, `group` for a shared one). Pass that `library_id` to `list_collections`, `list_items`, `search_metadata`, `search_library`, `get_tags`, `get_library_stats`, or `rebuild_search_index` to scope a call to just one library — e.g. "only search my 'Lab Reading Group' group library." Item-level tools (`get_item`, `get_item_fulltext`, `get_item_notes`, `get_item_annotations`, `compare_items`) work the same regardless of which library an item is in — just its key is enough.
Full-text/notes search coverage for group libraries depends on the search index having been built for them, same as your personal library — `rebuild_search_index` and `scripts/build_index.py` cover every library by default, and the weekly automatic reindex (next section) picks up new shared items too. If you notice `pdfs_indexed`/`notes_indexed` looking low for a group library right after joining it or after a teammate adds new items, run `python scripts/build_index.py --full` once, or ask Claude to call `rebuild_search_index`.
**Note on permissions**: this server only reads whatever your own Zotero desktop app already has synced locally, exactly as Zotero enforces for you — it doesn't grant, request, or bypass any group-library permissions. If a group's items aren't showing up, check that your Zotero library sync (Settings → Sync) includes that group and that it has finished syncing.
---
## 11. Keeping the index fresh
### Manually
```bash
cd ~/ClaudeFolders/ServerMCP/zotero-mcp
source .venv/bin/activate
python scripts/build_index.py
```
### Automatically, once a week
A weekly `launchd` job (`com.zoteromcp.buildindex.plist`, included in this project) handles this for you. `launchd` is the standard macOS scheduler — more reliable here than `cron`, and it catches up a missed run if your Mac was asleep at the scheduled time.
1. **Edit the paths in the plist first** — open `com.zoteromcp.buildindex.plist` and replace every `YOUR_USERNAME` with your actual Mac username (run `whoami` in Terminal if unsure), so the four absolute paths match your real project location.
2. **Install it:**
```bash
cp ~/ClaudeFolders/ServerMCP/zotero-mcp/com.zoteromcp.buildindex.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.zoteromcp.buildindex.plist
```
(If `bootstrap` errors saying the job is already loaded, that's fine — it means a previous attempt already registered it. If your macOS version doesn't recognize `bootstrap`, use the older `launchctl load -w ~/Library/LaunchAgents/com.zoteromcp.buildindex.plist` instead.)
3. **Test it immediately** rather than waiting for Sunday 3am:
```bash
launchctl start com.zoteromcp.buildindex
sleep 5
cat ~/ClaudeFolders/ServerMCP/zotero-mcp/cache/reindex.log
```
You should see the same progress output `build_index.py` prints when run manually.
4. **Check it's scheduled:**
```bash
launchctl list | grep zoteromcp
```
The job runs every Sunday at 03:00 local time by default. To change the day/time, edit the `Weekday`/`Hour`/`Minute` values in the plist (`Weekday` 0 = Sunday, 1 = Monday, etc.) and re-run the `bootstrap`/`load` command.
To remove it later: `launchctl bootout gui/$(id -u)/com.zoteromcp.buildindex` then delete the file from `~/Library/LaunchAgents/`.
---
## 12. Troubleshooting
- **"Zotero database not found"** — check `ZOTERO_DATA_DIR` in `.env` points at the folder that directly contains `zotero.sqlite`.
- **"database is locked"** — rare; the server automatically falls back to a temporary snapshot copy when this happens. If you see it constantly, close Zotero while indexing.
- **PDFs "not found on disk"** — usually means a sync tool hasn't finished downloading that file yet, or the attachment is a "linked file" pointing somewhere else. `test_connection.py` reports how many PDFs resolved.
- **OCR not running / errors mentioning tesseract** — confirm `tesseract --version` works in Terminal in general (not just inside the venv); pytesseract just shells out to it.
- **Claude Desktop doesn't show the server as connected** — double-check the `command` path in `claude_desktop_config.json` is absolute (starts with `/Users/...`, not `~`), that the JSON is valid (see step 8a), and that you fully quit (Cmd+Q) and reopened the app — closing just the window can leave the old process running in the background.
- **Server shows connected but tool calls fail on launch** — check `~/Library/Logs/Claude/mcp-server-zotero.log` for a Python traceback. If it's a relative-path/working-directory issue (e.g. something resolves to `/` instead of the project folder), the app doesn't guarantee a working directory when launching the server — `config.py` already resolves `.env` paths against the project root rather than the process's cwd to avoid exactly this, but it's the first thing to suspect if you ever add a new relative path setting.
- **Indexing is slow** — normal for a large library, especially with OCR on scanned PDFs. It only needs to happen once per file; subsequent runs skip unchanged files.
- **A collection you deleted in Zotero still shows up somewhere** — shouldn't happen: the server excludes any collection with a pending trash tombstone (Zotero's `deletedCollections` table) from `list_collections` and from an item's collection list. If you do see a stale one, it's worth re-checking this logic against your Zotero version's schema.
---
## 13. Getting help
If something doesn't match this tutorial exactly — a different Claude Desktop version, a different macOS version, an unusual Zotero setup (group libraries, WebDAV-synced attachments, etc.) — the most useful things to share when asking for help are: the output of `test_connection.py`, and the tail of `~/Library/Logs/Claude/mcp-server-zotero.log` after a restart attempt.
---
## License
MIT — see [LICENSE](./LICENSE).
TDQS
Scored across 13 tools
Most tools have clearly distinct purposes (list libraries/collections/items, get item content, search, rebuild index, stats). However, search_metadata and search_library with scope='metadata' overlap, and compare_items partially duplicates get_item + get_item_fulltext as a batch operation. Detailed descriptions mitigate but do not eliminate the ambiguity.
All 13 tools follow a consistent verb_noun snake_case pattern: list_* for enumerating multiple entities, get_* for retrieving specific content, search_* for the two search tools, plus rebuild_search_index, compare_items, and get_library_stats. The naming is highly predictable and readable.
13 tools is well within the ideal range for this server's scope. Each tool addresses a distinct part of the Zotero read-only workflow (library discovery, collection/item listing, metadata retrieval, fulltext/notes/annotations extraction, search, indexing, comparison, and stats), with no obvious bloat.
The tool set provides complete read-only coverage of the Zotero research workflow: discover libraries and collections, list and filter items, fetch full metadata and child content, search across all scopes, maintain the search index, compare multiple items, and retrieve library statistics. There are no dead ends for the stated purpose.