An MCP server that gives AI agents read access to a Zotero library — search, item metadata, collections, tags, the researcher's own notes, and the indexed full text of attached PDFs.
See PRD.md for requirements.
Status: M1 (read core) + M2 (full text) implemented. Citation formatting and export (M3), prompts (M4), and write tools (M5) are not built yet — see Not yet implemented.
Installs the zotero-mcp command into its own isolated environment:
pipx install pydantic-zotero-mcp # or: pipx install /path/to/checkout
zotero-mcp --helpuv add pydantic-zotero-mcp # or: uv pip install pydantic-zotero-mcpgit clone https://github.com/jmlon/pydantic-zotero-mcp
cd pydantic-zotero-mcp
uv sync # creates ./.venv from this project's own lock file
uv run pytest
uv run ruff checkGet a read-only API key and your numeric user ID from https://www.zotero.org/settings/keys. The library ID is the number, not your username.
export ZOTERO_API_KEY=...
export ZOTERO_LIBRARY_ID=123456 # numeric
export ZOTERO_LIBRARY_TYPE=user # or group| Variable | Default | Purpose |
|---|---|---|
ZOTERO_API_KEY |
— | Web API key (required unless ZOTERO_LOCAL=true) |
ZOTERO_LIBRARY_ID |
— | Numeric user or group ID |
ZOTERO_LIBRARY_TYPE |
user |
user or group
|
ZOTERO_LOCAL |
false |
Read the Zotero 7 desktop API instead: no key, no rate limit, read-only |
ZOTERO_ALLOW_WRITES |
false |
Reserved for M5; no write tools exist yet |
ZOTERO_FULLTEXT_MAX_CHARS |
100000 |
Default full-text ceiling; per-call max_chars overrides it |
ZOTERO_DEFAULT_STYLE |
chicago-note-bibliography |
Reserved for M3 |
ZOTERO_MAX_CONCURRENCY |
4 |
Upstream request cap (Zotero asks for ≤ 4) |
ZOTERO_MCP_TRANSPORT |
stdio |
stdio or http
|
ZOTERO_MCP_HOST |
127.0.0.1 |
HTTP bind address |
ZOTERO_MCP_PORT |
8000 |
HTTP port |
ZOTERO_MCP_PATH |
/mcp |
HTTP mount path |
ZOTERO_MCP_AUTH_TOKEN |
— | Bearer token; required for HTTP |
CLI flags override environment variables.
Once installed, zotero-mcp is the entry point — no interpreter path, no python -m, no
working directory to get right, which is what an MCP client's command: wants:
# stdio (default) — an agent launches this as a subprocess
zotero-mcp
# streamable HTTP — requires ZOTERO_MCP_AUTH_TOKEN
ZOTERO_MCP_AUTH_TOKEN=secret zotero-mcp --transport http --port 8000
# read the Zotero desktop app instead of the web API
zotero-mcp --localFrom a checkout, without installing, python -m zotero_mcp still works:
uv run python -m zotero_mcpStarting with --transport http and no token exits 2 rather than serving
unauthenticated: this is a read channel into a personal library.
No subprocess, no socket. Settings are injected, so the host never needs environment variables:
from fastmcp import Client
from zotero_mcp import ZoteroSettings, create_server
server = create_server(
ZoteroSettings(
api_key=key,
library_id="123456",
library_type="user",
)
)
async with Client(server) as client: # lifespan opens here
result = await client.call_tool("search_items", {"query": "attention"})
print(result.structured_content["items"]) # dict; result.data is a modelImporting zotero_mcp has no side effects — no config read, no client built, no
network — which is what makes embedding possible. There is a test that enforces it.
For host applications that discover bundled MCP servers through Python entry points,
this package declares one in the deep_research.mcp_servers group:
[project.entry-points."deep_research.mcp_servers"]
zotero = "zotero_mcp:build_server"build_server() takes no arguments and derives settings from the environment — install
this package into the host's environment and the host can resolve and run the server
in-process by the name zotero, without importing anything by path from a config file.
One tuning note for automated hosts: this server's default full-text ceiling is 100,000
characters (~25–30k tokens for a single get_item_fulltext call), which is generous for
interactive use and far too large for an agent making many calls against a token budget —
pass a smaller max_chars per call, or lower ZOTERO_FULLTEXT_MAX_CHARS.
| Tool | Purpose |
|---|---|
get_library_info |
Size, mode, permissions. Cheap orientation call — use it first |
search_items |
Primary entry point. mode="metadata" or "fulltext" (searches PDF text) |
list_recent_items |
Recently added items, newest first |
find_item_by_identifier |
"Do I already have this?" by DOI, ISBN, arXiv ID, or key |
get_item |
Full metadata; include_children=True also lists attachments and notes |
get_item_children |
Attachments and notes, with may_have_fulltext per attachment |
get_item_notes |
The researcher's own notes, HTML stripped |
get_item_fulltext |
Indexed attachment text; resolves parent → attachment |
list_collections |
Nested collection tree |
list_collection_items |
Items in one collection |
list_tags |
Tag vocabulary, optionally prefix-filtered |
Resources: zotero://library/info, zotero://collections,
zotero://items/{key}, zotero://items/{key}/fulltext,
zotero://collections/{key}/items, zotero://schema/item-types,
zotero://schema/item-types/{type}/fields.
Projection is the point. Raw Zotero JSON is ~1 KB per item of links, library,
meta, and empty type fields. zotero_mcp/projection.py reduces a 25-item page from
~6,100 to ~2,400 estimated tokens (39% of raw), under the PRD's 4,000 budget. Null
fields are dropped at serialization by CompactModel.
pyzotero is synchronous and stateful. Zotero.request and Zotero.links are
overwritten by each call, and Total-Results is read back off the instance
afterwards — so one shared client used concurrently would report another call's
totals. gateway.py keeps a pool of up to ZOTERO_MAX_CONCURRENCY clients, checks
one out per operation, and reads response metadata inside the same worker thread that
holds it. Every call goes through anyio.to_thread.run_sync so the event loop never
blocks.
Backoff is pyzotero's job. pyzotero ≥ 1.13 already honours Backoff /
Retry-After and retries 429 internally, so the gateway does not reimplement it. It
adds a bounded 3-attempt retry for transient transport and 5xx failures only.
Nothing is silently truncated. Searches report total_matched, truncated, and
next_start; full text reports total_chars and truncated.
Results are candidates, not verdicts (PRD D3). find_item_by_identifier returns
matched_on (key / doi / title / identifier / none) plus a confidence and
all plausible candidates — a pre-print and its published version both survive. The
caller filters.
Worth knowing about, since each was a judgment call made during implementation:
-
No module-level
mcpobject. PRD 7.2 asked for both a module-levelmcp = create_server()and no import-time side effects. Those conflict: building the server validates config, so a module-level instance raisesImportErroron any machine without Zotero env vars, and breaks the in-memory path it was meant to support. Onlycreate_server()/build_default_server()exist. -
Write tools will be registered conditionally, not
enabled=False. PRD 5.5 specified@mcp.tool(enabled=False), but FastMCP 3.x has noenabledkwarg, and a disabled-but-listed tool still costs context. When M5 lands, write tools will simply not be registered unlessZOTERO_ALLOW_WRITES=true.This server targets FastMCP 3.x. Two 3.x specifics shape the code here:
enabledis gone from the decorators, andresult.datais a generated pydantic model whileresult.structured_contentis the plain dict — the tests assert on the latter, which also verifies null-omission on the wire. -
has_fulltextis three-valued. PRD 6 typed itbool, but determining it for a parent item requires a separate children request per item, which would make a 25-item search 26 requests. It isFalsewhen an item has no children at all,True/Falsefor attachments and afterget_item(include_children=True), andnull(omitted) when undetermined.ItemSummary.num_childrengives the cheap signal. -
find_item_by_identifierreturnsCitationMatch, notItemSummary | None. Follows from D3 — the old signature made exactly the identity call that decision moved to the client. -
matched_ongainedkeyandidentifierbeyond the PRD's four values, to distinguish an exact key hit from a weak search hit. -
list_recent_items(since_days=...)filters locally. Zotero has no server-side date filter, so a narrow window can return fewer items thanlimit; the responsehintsays when that happened.
uv run pytest # 80 passedThe suite uses FastMCP's in-memory transport against a FakeZotero that reproduces
pyzotero's read-metadata-off-the-instance behaviour. No network, no subprocess, no
real credentials. Coverage: schema surface, projection and token budget, pagination
and truncation reporting, full-text ceiling and parent resolution, match recall
(pre-print/published pairs both returned), error message quality, resource template
validation including traversal attempts, config validation, CLI precedence, gateway
retry/caching, and an import-purity check that fails if importing the package touches
the network.
-
M3 —
format_citation,format_bibliography,export_items -
M4 — the four prompts (
literature_review,find_related_work,check_citations,summarize_reading), Logfire instrumentation -
M5 — write tools (
create_item,update_item_fields,add_item_tags,add_items_to_collection,create_note) with version-checked PATCH semantics. Deletion is out of scope permanently.