pydantic-zotero-mcp
pydantic-zotero-mcp
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.
Install
As a tool (pipx)
Installs the zotero-mcp command into its own isolated environment:
pipx install pydantic-zotero-mcp # or: pipx install /path/to/checkout
zotero-mcp --helpInto another project's environment
uv add pydantic-zotero-mcp # or: uv pip install pydantic-zotero-mcpFor development on this server
git 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 checkConfigure
Get 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 groupVariable | Default | Purpose |
| — | Web API key (required unless |
| — | Numeric user or group ID |
|
|
|
|
| Read the Zotero 7 desktop API instead: no key, no rate limit, read-only |
|
| Reserved for M5; no write tools exist yet |
|
| Default full-text ceiling; per-call |
|
| Reserved for M3 |
|
| Upstream request cap (Zotero asks for ≤ 4) |
|
|
|
|
| HTTP bind address |
|
| HTTP port |
|
| HTTP mount path |
| — | Bearer token; required for HTTP |
CLI flags override environment variables.
Run
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.
In-memory (embedded in an agent process)
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.
Discovery via entry point
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.
Tools
Tool | Purpose |
| Size, mode, permissions. Cheap orientation call — use it first |
| Primary entry point. |
| Recently added items, newest first |
| "Do I already have this?" by DOI, ISBN, arXiv ID, or key |
| Full metadata; |
| Attachments and notes, with |
| The researcher's own notes, HTML stripped |
| Indexed attachment text; resolves parent → attachment |
| Nested collection tree |
| Items in one collection |
| 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.
Design notes
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.
Deviations from the PRD
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.
Tests
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.
Not yet implemented
M3 —
format_citation,format_bibliography,export_itemsM4 — the four prompts (
literature_review,find_related_work,check_citations,summarize_reading), Logfire instrumentationM5 — 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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmlon/pydantic-zotero-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server