zotero-mcp
# zotero-mcp
A local-only, read-only MCP server for [Zotero](https://www.zotero.org/).
## AI disclosure
This tool was built by Claude and hardly verified by me. I advise against using
it on your Zotero database (and if you ignore that advise, make sure you have up
to date offline backups).
## Features
- **Local-only.** Talks to the Zotero desktop client's local API on
`127.0.0.1:23119`. No API key, no zotero.org account, no network traffic.
- **Read-only.** Every tool is a retrieval call, enforced by an allowlist; the
server has no code path that can add, edit, or delete anything. Note that the
local API itself is *not* read-only — Zotero 10+ supports `POST`/`PUT`/
`PATCH`/`DELETE` on `/api/` once a client obtains a local API key via
`POST /api/local/authorize`. This server never requests such a key and holds
`api_key=None`, so the write path stays unreachable; the read-only guarantee
is enforced here, not by the API.
- **stdio transport only.**
## Requirements
- Zotero 7 or newer, running
- Python 3.13+
- Zotero's local API enabled: **Settings → Advanced → "Allow other applications
on this computer to communicate with Zotero"**
Without that setting the API returns `403` and every tool reports how to fix it.
## Install
From a local checkout:
```bash
uv tool install .
```
## Usage
Register it with Claude Code:
```bash
claude mcp add zotero -- uvx zotero-mcp
```
Or add it to your MCP client config directly:
```json
{
"mcpServers": {
"zotero": {
"command": "uvx",
"args": [ "zotero-mcp" ]
}
}
}
```
## Tools
| Tool | Purpose |
| --- | --- |
| `search_items` | Search all fields, tags and attachment text; results ranked by where the match occurred. Optional item-type and tag filters. |
| `get_item` | Full metadata for one item key. |
| `get_item_children` | Attachments and notes belonging to an item. |
| `get_item_fulltext` | Indexed text of an attachment (PDF, snapshot), as plain text with a header line. |
| `list_collections` | Collections, optionally top-level only. |
| `get_collection_items` | Items inside a collection. |
| `list_tags` | Tags used in the library. |
| `get_recent_items` | Most recently added items. |
| `library_stats` | Item and collection counts. |
Item keys are 8-character strings such as `ABCD2345`. To read a PDF's text,
call `get_item_children` on a reference first to get its *attachment* key, then
pass that to `get_item_fulltext`.
## Why not read `zotero.sqlite` directly?
Zotero's [developer documentation](https://www.zotero.org/support/dev/client_coding/direct_sqlite_database_access)
states that the SQLite schema is an internal implementation detail that may
change between releases, and that direct access must be read-only to avoid
corruption (Zotero's caching layer interferes with SQLite file locking). The
local API is the supported interface, works while Zotero is running, and returns
stable documented JSON.
The tradeoff: Zotero must be open. Reading the SQLite file would work with
Zotero closed, at the cost of coupling to an unstable schema.
## Notes
- **`itemType` negation:** the [API docs](https://www.zotero.org/support/dev/web_api/v3/basics)
document exactly three forms — `itemType=book`, `itemType=book || journalArticle`
(OR), and `itemType=-attachment` (NOT). Negating a *group* is not documented,
and unsupported expressions **fail open**: they return `200 OK` with the
filter silently dropped rather than a `400`. Measured on this library:
`-attachment` → 578 results, `-attachment || note` → 1184 (the unfiltered
total), `-(attachment)` → 1184. An unknown type such as `garbagetype` returns
0 results rather than erroring. Because a broken filter yields *more* rows
than a working one, this server sends only the documented `-attachment` and
drops remaining notes and annotations in code, over-fetching so the requested
`limit` is still filled.
- **`get_item_fulltext` returns plain text, not JSON.** Every other tool
returns structured records, but a document is a text payload: serialising it
as JSON escapes each newline into a literal `\n` and collapses the whole
document onto one line (measured: 894 escapes in a single 51,003-char line),
forcing the caller to decode it before reading. The tool returns the text
with a one-line header instead, which preserves line breaks and is slightly
smaller than the escaped JSON was. Returning `str` alone is not enough:
FastMCP still advertises an output schema and emits `{"result": "..."}` as
structured content, which clients that prefer structured output render as
JSON — re-escaping the newlines. The tool is therefore declared
`@mcp.tool(output_schema=None)` so only the plain-text block is sent.
- **Search ranks, because Zotero only filters.** `search_items` defaults to
`qmode="everything"` (all fields, tags and indexed attachment text) rather
than the API's `titleCreatorYear` default. The narrow default made recall
brittle: Zotero requires *every* whitespace-separated term to match, so
searching `KEMTLS post-quantum TLS without handshake signatures` returned
**zero** hits — `KEMTLS` appears in the abstract, not the title, and that one
term zeroed the query. The same search now finds the paper.
Zotero's quicksearch filters without ranking, so hits are scored locally by
match location — title, then creator/date, tags, abstract/venue, and finally
attachment text — and each result reports its `matchedOn`. Whole-word matches
outrank substring ones, so searching `Shor` surfaces Shor's paper above
*Shorter Koblitz Curves*. `qmode="fields"` (all fields and tags, no
attachment text) is also accepted; it works on the local API but is not in
the web API docs.
- Results are condensed (envelope and empty fields stripped) to keep responses
small; abstracts are truncated in list views but returned in full by
`get_item`.
- `ZOTERO_LIBRARY_ID` and `ZOTERO_LOCALE` can override the defaults (`0`,
`en-US`).
## Tests
```bash
uv run pytest
```
The suite mocks pyzotero, so it runs without Zotero open.
## License
Released into the public domain under [the Unlicense](https://unlicense.org).
See [LICENSE](LICENSE).
TDQS
Scored across 9 tools
Each tool targets a distinct operation: retrieving single items, searching, listing attachments, getting fulltext, browsing collections/tags, and library stats. There is no meaningful overlap between tool purposes, and descriptions clarify edge cases like attachment keys.
Most tools follow a verb_noun pattern (get_item, list_collections, search_items), with 'get' for single-item retrieval and 'list' for enumerations. The only outlier is library_stats, which uses a noun-noun form, slightly breaking the pattern.
With 9 tools, the server is well-scoped for a Zotero library read-only workflow. Each tool covers a distinct aspect of browsing and retrieving references, without unnecessary redundancy or excessive granularity.
The toolset covers the core read operations: item retrieval, search, attachments, fulltext, collections, tags, and recent items. Missing write operations (create/update/delete) are likely intentional for a reference-manager assistant, and a method to list all items at once is absent but can be approximated via search.