Metadata-Version: 2.5
Name: pydantic-zotero-mcp
Version: 0.1.1
Summary: MCP server exposing a Zotero library — search, metadata, notes, and PDF full text — to AI agents
Project-URL: Homepage, https://github.com/jmlon/pydantic-zotero-mcp
Project-URL: Repository, https://github.com/jmlon/pydantic-zotero-mcp
Project-URL: Issues, https://github.com/jmlon/pydantic-zotero-mcp/issues
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: anyio<5,>=4.13
Requires-Dist: fastmcp<4,>=3.4.7
Requires-Dist: pydantic-settings<3,>=2.14
Requires-Dist: pydantic<3,>=2.11
Requires-Dist: pyzotero<2,>=1.13.7
Description-Content-Type: text/markdown

# 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](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](#not-yet-implemented).

## Install

### As a tool (`pipx`)

Installs the `zotero-mcp` command into its own isolated environment:

```bash
pipx install pydantic-zotero-mcp        # or: pipx install /path/to/checkout
zotero-mcp --help
```

### Into another project's environment

```bash
uv add pydantic-zotero-mcp              # or: uv pip install pydantic-zotero-mcp
```

### For development on this server

```bash
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 check
```

## Configure

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.

```bash
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.

## 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:

```bash
# 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 --local
```

From a checkout, without installing, `python -m zotero_mcp` still works:

```bash
uv run python -m zotero_mcp
```

Starting 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:

```python
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 model
```

Importing `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:

```toml
[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 |
|---|---|
| `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`.

## 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:

1. **No module-level `mcp` object.** PRD 7.2 asked for both a module-level
   `mcp = create_server()` and no import-time side effects. Those conflict: building
   the server validates config, so a module-level instance raises `ImportError` on any
   machine without Zotero env vars, and breaks the in-memory path it was meant to
   support. Only `create_server()` / `build_default_server()` exist.
2. **Write tools will be registered conditionally, not `enabled=False`.** PRD 5.5
   specified `@mcp.tool(enabled=False)`, but FastMCP 3.x has no `enabled` kwarg, and a
   disabled-but-listed tool still costs context. When M5 lands, write tools will simply
   not be registered unless `ZOTERO_ALLOW_WRITES=true`.

   This server targets **FastMCP 3.x**. Two 3.x specifics shape the code here: `enabled` is gone from
   the decorators, and `result.data` is a generated pydantic model while
   `result.structured_content` is the plain dict — the tests assert on the latter, which
   also verifies null-omission on the wire.
3. **`has_fulltext` is three-valued.** PRD 6 typed it `bool`, but determining it for a
   parent item requires a separate children request per item, which would make a
   25-item search 26 requests. It is `False` when an item has no children at all,
   `True`/`False` for attachments and after `get_item(include_children=True)`, and
   `null` (omitted) when undetermined. `ItemSummary.num_children` gives the cheap
   signal.
4. **`find_item_by_identifier` returns `CitationMatch`, not `ItemSummary | None`.**
   Follows from D3 — the old signature made exactly the identity call that decision
   moved to the client.
5. **`matched_on` gained `key` and `identifier`** beyond the PRD's four values, to
   distinguish an exact key hit from a weak search hit.
6. **`list_recent_items(since_days=...)` filters locally.** Zotero has no server-side
   date filter, so a narrow window can return fewer items than `limit`; the response
   `hint` says when that happened.

## Tests

```bash
uv run pytest      # 80 passed
```

The 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_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.
