Skip to main content
Glama
README.md
# 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

A3.8/5.0

Scored across 9 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues