# Directory Structure
```
├── .github
│ └── workflows
│ └── python-package.yml
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── Makefile
├── pyproject.toml
├── README.md
├── src
│ └── zotero_mcp
│ ├── __init__.py
│ ├── cli.py
│ └── client.py
├── tests
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_client.py
│ ├── test_item_operations.py
│ └── test_search.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.13
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # Ruff stuff:
171 | .ruff_cache/
172 |
173 | # PyPI configuration file
174 | .pypirc
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Model Context Protocol server for Zotero
2 |
3 | [](https://github.com/kujenga/zotero-mcp/actions)
4 | [](https://pypi.org/project/zotero-mcp/)
5 |
6 | This project is a python server that implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) for [Zotero](https://www.zotero.org/), giving you access to your Zotero library within AI assistants. It is intended to implement a small but maximally useful set of interactions with Zotero for use with [MCP clients](https://modelcontextprotocol.io/clients).
7 |
8 | <a href="https://glama.ai/mcp/servers/jknz38ntu4">
9 | <img width="380" height="200" src="https://glama.ai/mcp/servers/jknz38ntu4/badge" alt="Zotero Server MCP server" />
10 | </a>
11 |
12 | ## Features
13 |
14 | This MCP server provides the following tools:
15 |
16 | - `zotero_search_items`: Search for items in your Zotero library using a text query
17 | - `zotero_item_metadata`: Get detailed metadata information about a specific Zotero item
18 | - `zotero_item_fulltext`: Get the full text of a specific Zotero item (i.e. PDF contents)
19 |
20 | These can be discovered and accessed through any MCP client or through the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
21 |
22 | Each tool returns formatted text containing relevant information from your Zotero items, and AI assistants such as Claude can use them sequentially, searching for items then retrieving their metadata or text content.
23 |
24 | ## Installation
25 |
26 | This server can either run against either a [local API offered by the Zotero desktop application](https://groups.google.com/g/zotero-dev/c/ElvHhIFAXrY/m/fA7SKKwsAgAJ)) or through the [Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start). The local API can be a bit more responsive, but requires that the Zotero app be running on the same computer with the API enabled. To enable the local API, do the following steps:
27 |
28 | 1. Open Zotero and open "Zotero Settings"
29 | 1. Under the "Advanced" tab, check the box that says "Allow other applications on this computer to communicate with Zotero".
30 |
31 | > [!IMPORTANT]
32 | > For access to the `/fulltext` endpoint on the local API which allows retrieving the full content of items in your library, you'll need to install a [Zotero Beta Build](https://www.zotero.org/support/beta_builds) (as of 2025-03-30). Once 7.1 is released this will no longer be the case. See https://github.com/zotero/zotero/pull/5004 for more information. If you do not want to do this, use the Web API instead.
33 |
34 | To use the Zotero Web API, you'll need to create an API key and find your Library ID (usually your User ID) in your Zotero account settings here: <https://www.zotero.org/settings/keys>
35 |
36 | These are the available configuration options:
37 |
38 | - `ZOTERO_LOCAL=true`: Use the local Zotero API (default: false, see note below)
39 | - `ZOTERO_API_KEY`: Your Zotero API key (not required for the local API)
40 | - `ZOTERO_LIBRARY_ID`: Your Zotero library ID (your user ID for user libraries, not required for the local API)
41 | - `ZOTERO_LIBRARY_TYPE`: The type of library (user or group, default: user)
42 |
43 | ### [`uvx`](https://docs.astral.sh/uv/getting-started/installation/) with Local Zotero API
44 |
45 | To use this with Claude Desktop and a direct python install with [`uvx`](https://docs.astral.sh/uv/getting-started/installation/), add the following to the `mcpServers` configuration:
46 |
47 | ```json
48 | {
49 | "mcpServers": {
50 | "zotero": {
51 | "command": "uvx",
52 | "args": ["--upgrade", "zotero-mcp"],
53 | "env": {
54 | "ZOTERO_LOCAL": "true",
55 | "ZOTERO_API_KEY": "",
56 | "ZOTERO_LIBRARY_ID": ""
57 | }
58 | }
59 | }
60 | }
61 | ```
62 |
63 | The `--upgrade` flag is optional and will pull the latest version when new ones are available. If you don't have `uvx` installed you can use `pipx run` instead, or clone this repository locally and use the instructions in [Development](#development) below.
64 |
65 | ### Docker with Zotero Web API
66 |
67 | If you want to run this MCP server in a Docker container, you can use the following configuration, inserting your API key and library ID:
68 |
69 | ```json
70 | {
71 | "mcpServers": {
72 | "zotero": {
73 | "command": "docker",
74 | "args": [
75 | "run",
76 | "--rm",
77 | "-i",
78 | "-e", "ZOTERO_API_KEY=PLACEHOLDER",
79 | "-e", "ZOTERO_LIBRARY_ID=PLACEHOLDER",
80 | "ghcr.io/kujenga/zotero-mcp:main"
81 | ],
82 | }
83 | }
84 | }
85 | ```
86 |
87 | To update to a newer version, run `docker pull ghcr.io/kujenga/zotero-mcp:main`. It is also possible to use the docker-based installation to talk to the local Zotero API, but you'll need to modify the above command to ensure that there is network connectivity to the Zotero application's local API interface.
88 |
89 | ## Development
90 |
91 | Information on making changes and contributing to the project.
92 |
93 | 1. Clone this repository
94 | 1. Install dependencies with [uv](https://docs.astral.sh/uv/) by running: `uv sync`
95 | 1. Create a `.env` file in the project root with the environment variables above
96 |
97 | Start the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for local development:
98 |
99 | ```bash
100 | npx @modelcontextprotocol/inspector uv run zotero-mcp
101 | ```
102 |
103 | To test the local repository against Claude Desktop, run `echo $PWD/.venv/bin/zotero-mcp` in your shell within this directory, then set the following within your Claude Desktop configuration
104 | ```json
105 | {
106 | "mcpServers": {
107 | "zotero": {
108 | "command": "/path/to/zotero-mcp/.venv/bin/zotero-mcp"
109 | "env": {
110 | // Whatever configuration is desired.
111 | }
112 | }
113 | }
114 | }
115 | ```
116 |
117 | ### Running Tests
118 |
119 | To run the test suite:
120 |
121 | ```bash
122 | uv run pytest
123 | ```
124 |
125 | ### Docker Development
126 |
127 | Build the container image with this command:
128 |
129 | ```sh
130 | docker build . -t zotero-mcp:local
131 | ```
132 |
133 | To test the container with the MCP inspector, run the following command:
134 |
135 | ```sh
136 | npx @modelcontextprotocol/inspector \
137 | -e ZOTERO_API_KEY=$ZOTERO_API_KEY \
138 | -e ZOTERO_LIBRARY_ID=$ZOTERO_LIBRARY_ID \
139 | docker run --rm -i \
140 | --env ZOTERO_API_KEY \
141 | --env ZOTERO_LIBRARY_ID \
142 | zotero-mcp:local
143 | ```
144 |
145 | ## Relevant Documentation
146 |
147 | - https://modelcontextprotocol.io/tutorials/building-mcp-with-llms
148 | - https://github.com/modelcontextprotocol/python-sdk
149 | - https://pyzotero.readthedocs.io/en/latest/
150 | - https://www.zotero.org/support/dev/web_api/v3/start
151 | - https://modelcontextprotocol.io/llms-full.txt can be utilized by LLMs
152 |
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Test suite for zotero-mcp"""
2 |
```
--------------------------------------------------------------------------------
/src/zotero_mcp/cli.py:
--------------------------------------------------------------------------------
```python
1 | import argparse
2 |
3 | from zotero_mcp import mcp
4 |
5 |
6 | def main():
7 | parser = argparse.ArgumentParser(description="Zotero Model Contect Server")
8 | parser.add_argument(
9 | "--transport",
10 | choices=["stdio", "sse"],
11 | default="stdio",
12 | help="Transport to use",
13 | )
14 | args = parser.parse_args()
15 |
16 | mcp.run(args.transport)
17 |
18 |
19 | if __name__ == "__main__":
20 | main()
21 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM python:3.13-slim-bookworm
2 |
3 | # Install uv
4 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
5 |
6 | # Install application
7 | ADD README.md LICENSE pyproject.toml uv.lock src /app/
8 | WORKDIR /app
9 | ENV UV_FROZEN=true
10 | RUN uv sync
11 |
12 | # Check basic functionality
13 | RUN uv run zotero-mcp --help
14 |
15 | LABEL org.opencontainers.image.title="zotero-mcp"
16 | LABEL org.opencontainers.image.description="Model Context Protocol Server for Zotero"
17 | LABEL org.opencontainers.image.url="https://github.com/zotero/zotero-mcp"
18 | LABEL org.opencontainers.image.source="https://github.com/zotero/zotero-mcp"
19 | LABEL org.opencontainers.image.license="MIT"
20 |
21 | # Command to run the server
22 | ENTRYPOINT ["uv", "run", "--quiet", "zotero-mcp"]
23 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "zotero-mcp"
3 | version = "0.1.6"
4 | description = "Model Context Protocol server for Zotero"
5 | authors = [{ name = "Aaron Taylor", email = "[email protected]" }]
6 | readme = "README.md"
7 | license = { file = "LICENSE" }
8 | requires-python = ">=3.11"
9 | keywords = ["mcp", "zotero"]
10 | classifiers = [
11 | "Intended Audience :: Developers",
12 | "License :: OSI Approved :: MIT License",
13 | "Programming Language :: Python :: 3.11",
14 | "Programming Language :: Python :: 3.12",
15 | "Programming Language :: Python :: 3.13",
16 | "Programming Language :: Python :: 3 :: Only",
17 | "Operating System :: OS Independent",
18 | ]
19 | dependencies = [
20 | "mcp[cli]>=1.2.1",
21 | "pydantic>=2.10.6",
22 | "python-dotenv>=1.0.1",
23 | "pyzotero>=1.6.8",
24 | ]
25 |
26 | [project.scripts]
27 | zotero-mcp = "zotero_mcp.cli:main"
28 |
29 | [project.urls]
30 | Repository = "https://github.com/kujenga/zotero-mcp"
31 | Issues = "https://github.com/kujenga/zotero-mcp/issues"
32 |
33 | [build-system]
34 | requires = ["hatchling"]
35 | build-backend = "hatchling.build"
36 |
37 | [dependency-groups]
38 | dev = ["pytest>=8.3.4", "ruff>=0.9.4"]
39 |
```
--------------------------------------------------------------------------------
/tests/test_search.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for search functionality"""
2 |
3 | from typing import Any
4 |
5 | from zotero_mcp import search_items
6 |
7 |
8 | def test_search_items_basic(mock_zotero: Any, sample_item: dict[str, Any]) -> None:
9 | """Test basic search functionality"""
10 | mock_zotero.items.return_value = [sample_item]
11 |
12 | result = search_items("test")
13 |
14 | assert "Test Article" in result
15 | assert "**Key**: `ABCD1234`" in result
16 | assert "**Authors**: Doe, John; Smith, Jane" in result
17 | assert "This is a test abstract" in result
18 |
19 | # Verify search parameters
20 | mock_zotero.add_parameters.assert_called_once_with(
21 | q="test", qmode="titleCreatorYear", limit=10
22 | )
23 |
24 |
25 | def test_search_items_no_results(mock_zotero: Any) -> None:
26 | """Test search with no results"""
27 | mock_zotero.items.return_value = []
28 |
29 | result = search_items("nonexistent")
30 |
31 | assert "No items found" in result
32 |
33 |
34 | def test_search_items_custom_params(
35 | mock_zotero: Any, sample_item: dict[str, Any]
36 | ) -> None:
37 | """Test search with custom parameters"""
38 | mock_zotero.items.return_value = [sample_item]
39 |
40 | search_items("test", qmode="everything", limit=5)
41 |
42 | mock_zotero.add_parameters.assert_called_once_with(
43 | q="test", qmode="everything", limit=5
44 | )
45 |
```
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
```python
1 | """Pytest fixtures for zotero-mcp tests"""
2 |
3 | from typing import Any
4 | from unittest.mock import MagicMock
5 |
6 | import pytest
7 | from pyzotero import zotero
8 |
9 |
10 | @pytest.fixture
11 | def mock_zotero(monkeypatch) -> MagicMock:
12 | """Fixture that returns a mocked Zotero client"""
13 | mock = MagicMock(spec=zotero.Zotero)
14 |
15 | def mock_get_zotero_client():
16 | return mock
17 |
18 | monkeypatch.setattr("zotero_mcp.get_zotero_client", mock_get_zotero_client)
19 | return mock
20 |
21 |
22 | @pytest.fixture
23 | def sample_item() -> dict[str, Any]:
24 | """Fixture that returns a sample Zotero item"""
25 | return {
26 | "key": "ABCD1234",
27 | "data": {
28 | "key": "ABCD1234",
29 | "itemType": "journalArticle",
30 | "title": "Test Article",
31 | "date": "2024",
32 | "creators": [
33 | {"firstName": "John", "lastName": "Doe"},
34 | {"firstName": "Jane", "lastName": "Smith"},
35 | ],
36 | "abstractNote": "This is a test abstract",
37 | "tags": [{"tag": "test"}, {"tag": "article"}],
38 | "url": "https://example.com",
39 | "DOI": "10.1234/test",
40 | },
41 | "meta": {"numChildren": 2},
42 | }
43 |
44 |
45 | @pytest.fixture
46 | def sample_attachment() -> dict[str, Any]:
47 | """Fixture that returns a sample Zotero attachment item"""
48 | return {
49 | "key": "XYZ789",
50 | "data": {
51 | "key": "XYZ789",
52 | "itemType": "attachment",
53 | "contentType": "application/pdf",
54 | "md5": "123456789",
55 | },
56 | }
57 |
```
--------------------------------------------------------------------------------
/tests/test_item_operations.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for item metadata and fulltext operations"""
2 |
3 | from typing import Any
4 |
5 | from zotero_mcp import get_item_metadata, get_item_fulltext
6 |
7 |
8 | def test_get_item_metadata(mock_zotero: Any, sample_item: dict[str, Any]) -> None:
9 | """Test retrieving item metadata"""
10 | mock_zotero.item.return_value = sample_item
11 |
12 | result = get_item_metadata("ABCD1234")
13 |
14 | assert "## Test Article" in result
15 | assert "Item Key: `ABCD1234`" in result
16 | assert "Type: journalArticle" in result
17 | assert "Date: 2024" in result
18 | assert "Doe, John; Smith, Jane" in result
19 | assert "### Abstract" in result
20 | assert "This is a test abstract" in result
21 | assert "### Tags" in result
22 | assert "`test`" in result and "`article`" in result
23 | assert "URL: https://example.com" in result
24 | assert "DOI: 10.1234/test" in result
25 | assert "Number of notes/attachments: 2" in result
26 |
27 |
28 | def test_get_item_metadata_not_found(mock_zotero: Any) -> None:
29 | """Test retrieving metadata for nonexistent item"""
30 | mock_zotero.item.return_value = None
31 |
32 | result = get_item_metadata("NONEXISTENT")
33 |
34 | assert "No item found" in result
35 |
36 |
37 | def test_get_item_fulltext(
38 | mock_zotero: Any, sample_item: dict[str, Any], sample_attachment: dict[str, Any]
39 | ) -> None:
40 | """Test retrieving item fulltext"""
41 | mock_zotero.item.return_value = sample_item
42 | mock_zotero.children.return_value = [sample_attachment]
43 | mock_zotero.fulltext_item.return_value = {"content": "Sample full text content"}
44 |
45 | result = get_item_fulltext("ABCD1234")
46 |
47 | assert "Test Article" in result
48 | assert "Sample full text content" in result
49 | assert "XYZ789" in result # Attachment key
50 |
51 |
52 | def test_get_item_fulltext_no_attachment(
53 | mock_zotero: Any, sample_item: dict[str, Any]
54 | ) -> None:
55 | """Test retrieving fulltext when no attachment is available"""
56 | mock_zotero.item.return_value = sample_item
57 | mock_zotero.children.return_value = []
58 |
59 | result = get_item_fulltext("ABCD1234")
60 |
61 | assert "No suitable attachment found" in result
62 |
```
--------------------------------------------------------------------------------
/.github/workflows/python-package.yml:
--------------------------------------------------------------------------------
```yaml
1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3 |
4 | name: Python package
5 |
6 | env:
7 | IMAGE_REGISTRY: ghcr.io
8 | IMAGE_NAME: ${{ github.repository }}
9 |
10 | on:
11 | push:
12 | branches: ['main']
13 | pull_request:
14 | branches: ['main']
15 |
16 | jobs:
17 | build:
18 | runs-on: ubuntu-latest
19 | strategy:
20 | fail-fast: false
21 | matrix:
22 | python-version: ['3.11', '3.12', '3.13']
23 |
24 | steps:
25 | - uses: actions/checkout@v4
26 | - name: Set up Python ${{ matrix.python-version }}
27 | uses: actions/setup-python@v3
28 | with:
29 | python-version: ${{ matrix.python-version }}
30 | - name: Install uv
31 | uses: astral-sh/setup-uv@v5
32 | - name: Install dependencies
33 | run: uv sync --all-groups
34 | - name: Check format with ruff
35 | run: uv run ruff format --check
36 | - name: Check lints with ruff
37 | run: uv run ruff check
38 | - name: Test with pytest
39 | run: uv run pytest
40 |
41 | docker:
42 | runs-on: ubuntu-latest
43 | permissions:
44 | contents: read
45 | packages: write
46 | attestations: write
47 | id-token: write
48 |
49 | steps:
50 | - name: Checkout repository
51 | uses: actions/checkout@v4
52 | - name: Log in to the Container registry
53 | uses: docker/login-action@v3
54 | with:
55 | registry: ${{ env.IMAGE_REGISTRY }}
56 | username: ${{ github.actor }}
57 | password: ${{ secrets.GITHUB_TOKEN }}
58 | - name: Extract metadata (tags, labels) for Docker
59 | id: meta
60 | uses: docker/metadata-action@v5
61 | with:
62 | images: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}
63 | tags: |
64 | type=ref,event=branch,suffix=-{{sha}}
65 | type=ref,event=pr,suffix=-{{sha}}
66 | type=ref,event=branch
67 | type=ref,event=pr
68 | type=semver,pattern={{version}}
69 | type=semver,pattern={{major}}.{{minor}}
70 | type=semver,pattern={{major}}
71 | - name: Set up Docker Buildx
72 | uses: docker/setup-buildx-action@v3
73 | - name: Build and push Docker image
74 | id: push
75 | uses: docker/build-push-action@v6
76 | with:
77 | platforms: linux/amd64,linux/arm64
78 | context: .
79 | push: true
80 | tags: ${{ steps.meta.outputs.tags }}
81 | labels: ${{ steps.meta.outputs.labels }}
82 | - name: Generate artifact attestation
83 | uses: actions/attest-build-provenance@v2
84 | with:
85 | subject-name: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME}}
86 | subject-digest: ${{ steps.push.outputs.digest }}
87 | push-to-registry: true
88 |
```
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for Zotero client module"""
2 |
3 | import os
4 | from unittest.mock import patch
5 |
6 | import pytest
7 | from zotero_mcp.client import get_zotero_client
8 |
9 |
10 | @pytest.fixture
11 | def mock_env_vars():
12 | """Mock environment variables for testing"""
13 | with patch.dict(
14 | os.environ,
15 | {
16 | "ZOTERO_LIBRARY_ID": "1234567",
17 | "ZOTERO_LIBRARY_TYPE": "user",
18 | "ZOTERO_API_KEY": "abcdef123456",
19 | "ZOTERO_LOCAL": "",
20 | },
21 | clear=True,
22 | ):
23 | yield
24 |
25 |
26 | @pytest.fixture
27 | def mock_env_vars_local():
28 | """Mock environment variables for local mode"""
29 | with patch.dict(
30 | os.environ,
31 | {
32 | "ZOTERO_LIBRARY_ID": "",
33 | "ZOTERO_LIBRARY_TYPE": "user",
34 | "ZOTERO_API_KEY": "",
35 | "ZOTERO_LOCAL": "true",
36 | },
37 | clear=True,
38 | ):
39 | yield
40 |
41 |
42 | def test_get_zotero_client_with_api_key(mock_env_vars):
43 | """Test client initialization with API key"""
44 | with patch("zotero_mcp.client.zotero.Zotero") as mock_zotero:
45 | get_zotero_client()
46 | mock_zotero.assert_called_once_with(
47 | library_id="1234567",
48 | library_type="user",
49 | api_key="abcdef123456",
50 | local=False,
51 | )
52 |
53 |
54 | def test_get_zotero_client_missing_api_key():
55 | """Test client initialization with missing API key"""
56 | with patch.dict(
57 | os.environ,
58 | {
59 | "ZOTERO_LIBRARY_ID": "1234567",
60 | "ZOTERO_LIBRARY_TYPE": "user",
61 | "ZOTERO_API_KEY": "",
62 | "ZOTERO_LOCAL": "",
63 | },
64 | clear=True,
65 | ):
66 | with pytest.raises(ValueError) as excinfo:
67 | get_zotero_client()
68 | assert "Missing required environment variables" in str(excinfo.value)
69 |
70 |
71 | def test_get_zotero_client_local_mode(mock_env_vars_local):
72 | """Test client initialization in local mode"""
73 | with patch("zotero_mcp.client.zotero.Zotero") as mock_zotero:
74 | get_zotero_client()
75 | mock_zotero.assert_called_once_with(
76 | library_id="0",
77 | library_type="user",
78 | api_key=None,
79 | local=True,
80 | )
81 |
82 |
83 | def test_get_zotero_client_local_mode_with_library_id():
84 | """Test client initialization in local mode with custom library ID"""
85 | with patch.dict(
86 | os.environ,
87 | {
88 | "ZOTERO_LIBRARY_ID": "custom_id",
89 | "ZOTERO_LIBRARY_TYPE": "user",
90 | "ZOTERO_API_KEY": "",
91 | "ZOTERO_LOCAL": "true",
92 | },
93 | clear=True,
94 | ):
95 | with patch("zotero_mcp.client.zotero.Zotero") as mock_zotero:
96 | get_zotero_client()
97 | mock_zotero.assert_called_once_with(
98 | library_id="custom_id",
99 | library_type="user",
100 | api_key=None,
101 | local=True,
102 | )
103 |
```
--------------------------------------------------------------------------------
/src/zotero_mcp/client.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | from typing import Any
3 |
4 | from dotenv import load_dotenv
5 | from pydantic import BaseModel
6 | from pyzotero import zotero
7 |
8 |
9 | # Load environment variables
10 | load_dotenv()
11 |
12 |
13 | # Initialize Zotero client
14 | def get_zotero_client() -> zotero.Zotero:
15 | """Get authenticated Zotero client using environment variables"""
16 | library_id = os.getenv("ZOTERO_LIBRARY_ID")
17 | library_type = os.getenv("ZOTERO_LIBRARY_TYPE", "user")
18 | api_key = os.getenv("ZOTERO_API_KEY") or None
19 | local = os.getenv("ZOTERO_LOCAL", "").lower() in ["true", "yes", "1"]
20 | if local:
21 | if not library_id:
22 | # Indicates "current user" for the local API
23 | library_id = "0"
24 | elif not all([library_id, api_key]):
25 | raise ValueError(
26 | "Missing required environment variables. Please set ZOTERO_LIBRARY_ID and ZOTERO_API_KEY"
27 | )
28 |
29 | return zotero.Zotero(
30 | library_id=library_id,
31 | library_type=library_type,
32 | api_key=api_key,
33 | local=local,
34 | )
35 |
36 |
37 | class AttachmentDetails(BaseModel):
38 | key: str
39 | content_type: str
40 |
41 |
42 | def get_attachment_details(
43 | zot: zotero.Zotero,
44 | item: dict[str, Any],
45 | ) -> AttachmentDetails | None:
46 | """Get attachment ID and content type for a Zotero item"""
47 | data = item.get("data", {})
48 | item_type = data.get("itemType")
49 |
50 | # Direct attachment - check if it's a PDF or other supported type
51 | if item_type == "attachment":
52 | content_type = data.get("contentType")
53 | return AttachmentDetails(
54 | key=data.get("key"),
55 | content_type=content_type,
56 | )
57 |
58 | # For regular items, look for child attachments
59 | try:
60 | children: Any = zot.children(data.get("key", ""))
61 | # Group attachments by content type and size
62 | pdfs = []
63 | htmls = []
64 | others = []
65 |
66 | for child in children:
67 | child_data = child.get("data", {})
68 | if child_data.get("itemType") == "attachment":
69 | content_type = child_data.get("contentType")
70 | file_size = child_data.get("md5", "") # Use md5 as proxy for size
71 |
72 | if content_type == "application/pdf":
73 | pdfs.append((child_data.get("key"), content_type, file_size))
74 | elif content_type == "text/html":
75 | htmls.append((child_data.get("key"), content_type, file_size))
76 | else:
77 | others.append((child_data.get("key"), content_type, file_size))
78 |
79 | # Return first match in priority order
80 | if pdfs:
81 | pdfs.sort(key=lambda x: x[2], reverse=True)
82 | return AttachmentDetails(
83 | key=pdfs[0][0],
84 | content_type=pdfs[0][1],
85 | )
86 | if htmls:
87 | htmls.sort(key=lambda x: x[2], reverse=True)
88 | return AttachmentDetails(
89 | key=htmls[0][0],
90 | content_type=htmls[0][1],
91 | )
92 | if others:
93 | others.sort(key=lambda x: x[2], reverse=True)
94 | return AttachmentDetails(
95 | key=others[0][0],
96 | content_type=others[0][1],
97 | )
98 | except Exception:
99 | pass
100 |
101 | return None
102 |
```
--------------------------------------------------------------------------------
/src/zotero_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | from typing import Any, Literal
2 |
3 | from mcp.server.fastmcp import FastMCP
4 |
5 | from zotero_mcp.client import get_attachment_details, get_zotero_client
6 |
7 | # Create an MCP server
8 | mcp = FastMCP("Zotero")
9 |
10 |
11 | def format_item(item: dict[str, Any]) -> str:
12 | """Format a Zotero item's metadata as a readable string optimized for LLM consumption"""
13 | data = item["data"]
14 | item_key = item["key"]
15 | item_type = data.get("itemType", "unknown")
16 |
17 | # Special handling for notes
18 | if item_type == "note":
19 | # Get note content
20 | note_content = data.get("note", "")
21 | # Strip HTML tags for cleaner text (simple approach)
22 | note_content = (
23 | note_content.replace("<p>", "").replace("</p>", "\n").replace("<br>", "\n")
24 | )
25 | note_content = note_content.replace("<strong>", "**").replace("</strong>", "**")
26 | note_content = note_content.replace("<em>", "*").replace("</em>", "*")
27 |
28 | # Format note with clear sections
29 | formatted = [
30 | "## 📝 Note",
31 | f"Item Key: `{item_key}`",
32 | ]
33 |
34 | # Add parent item reference if available
35 | if parent_item := data.get("parentItem"):
36 | formatted.append(f"Parent Item: `{parent_item}`")
37 |
38 | # Add date if available
39 | if date := data.get("dateModified"):
40 | formatted.append(f"Last Modified: {date}")
41 |
42 | # Add tags with formatting for better visibility
43 | if tags := data.get("tags"):
44 | tag_list = [f"`{tag['tag']}`" for tag in tags]
45 | formatted.append(f"\n### Tags\n{', '.join(tag_list)}")
46 |
47 | # Add note content
48 | formatted.append(f"\n### Note Content\n{note_content}")
49 |
50 | return "\n".join(formatted)
51 |
52 | # Regular item handling (non-notes)
53 |
54 | # Basic metadata with key for easy reference
55 | formatted = [
56 | f"## {data.get('title', 'Untitled')}",
57 | f"Item Key: `{item_key}`",
58 | f"Type: {item_type}",
59 | f"Date: {data.get('date', 'No date')}",
60 | ]
61 |
62 | # Creators with role differentiation
63 | creators_by_role = {}
64 | for creator in data.get("creators", []):
65 | role = creator.get("creatorType", "contributor")
66 | name = ""
67 | if "firstName" in creator and "lastName" in creator:
68 | name = f"{creator['lastName']}, {creator['firstName']}"
69 | elif "name" in creator:
70 | name = creator["name"]
71 |
72 | if name:
73 | if role not in creators_by_role:
74 | creators_by_role[role] = []
75 | creators_by_role[role].append(name)
76 |
77 | for role, names in creators_by_role.items():
78 | role_display = role.capitalize() + ("s" if len(names) > 1 else "")
79 | formatted.append(f"{role_display}: {'; '.join(names)}")
80 |
81 | # Publication details
82 | if publication := data.get("publicationTitle"):
83 | formatted.append(f"Publication: {publication}")
84 | if volume := data.get("volume"):
85 | volume_info = f"Volume: {volume}"
86 | if issue := data.get("issue"):
87 | volume_info += f", Issue: {issue}"
88 | if pages := data.get("pages"):
89 | volume_info += f", Pages: {pages}"
90 | formatted.append(volume_info)
91 |
92 | # Abstract with clear section header
93 | if abstract := data.get("abstractNote"):
94 | formatted.append(f"\n### Abstract\n{abstract}")
95 |
96 | # Tags with formatting for better visibility
97 | if tags := data.get("tags"):
98 | tag_list = [f"`{tag['tag']}`" for tag in tags]
99 | formatted.append(f"\n### Tags\n{', '.join(tag_list)}")
100 |
101 | # URLs, DOIs, and identifiers grouped together
102 | identifiers = []
103 | if url := data.get("url"):
104 | identifiers.append(f"URL: {url}")
105 | if doi := data.get("DOI"):
106 | identifiers.append(f"DOI: {doi}")
107 | if isbn := data.get("ISBN"):
108 | identifiers.append(f"ISBN: {isbn}")
109 | if issn := data.get("ISSN"):
110 | identifiers.append(f"ISSN: {issn}")
111 |
112 | if identifiers:
113 | formatted.append("\n### Identifiers\n" + "\n".join(identifiers))
114 |
115 | # Notes and attachments
116 | if notes := item.get("meta", {}).get("numChildren", 0):
117 | formatted.append(
118 | f"\n### Additional Information\nNumber of notes/attachments: {notes}"
119 | )
120 |
121 | return "\n".join(formatted)
122 |
123 |
124 | @mcp.tool(
125 | name="zotero_item_metadata",
126 | description="Get metadata information about a specific Zotero item, given the item key.",
127 | )
128 | def get_item_metadata(item_key: str) -> str:
129 | """Get metadata information about a specific Zotero item"""
130 | zot = get_zotero_client()
131 |
132 | try:
133 | item: Any = zot.item(item_key)
134 | if not item:
135 | return f"No item found with key: {item_key}"
136 | return format_item(item)
137 | except Exception as e:
138 | return f"Error retrieving item metadata: {str(e)}"
139 |
140 |
141 | @mcp.tool(
142 | name="zotero_item_fulltext",
143 | description="Get the full text content of a Zotero item, given the item key of a parent item or specific attachment.",
144 | )
145 | def get_item_fulltext(item_key: str) -> str:
146 | """Get the full text content of a specific Zotero item"""
147 | zot = get_zotero_client()
148 |
149 | try:
150 | item: Any = zot.item(item_key)
151 | if not item:
152 | return f"No item found with key: {item_key}"
153 |
154 | # Fetch full-text content
155 | attachment = get_attachment_details(zot, item)
156 |
157 | # Prepare header with metadata
158 | header = format_item(item)
159 |
160 | # Add attachment information
161 | if attachment is not None:
162 | attachment_info = f"\n## Attachment Information\n- **Key**: `{attachment.key}`\n- **Type**: {attachment.content_type}"
163 |
164 | # Get the full text
165 | full_text_data: Any = zot.fulltext_item(attachment.key)
166 | if full_text_data and "content" in full_text_data:
167 | item_text = full_text_data["content"]
168 | # Calculate approximate word count
169 | word_count = len(item_text.split())
170 | attachment_info += f"\n- **Word Count**: ~{word_count}"
171 |
172 | # Format the content with markdown for structure
173 | full_text = f"\n\n## Document Content\n\n{item_text}"
174 | else:
175 | # Clear error message when text extraction isn't possible
176 | full_text = "\n\n## Document Content\n\n[⚠️ Attachment is available but text extraction is not possible. The document may be scanned as images or have other restrictions that prevent text extraction.]"
177 | else:
178 | attachment_info = "\n\n## Attachment Information\n[❌ No suitable attachment found for full text extraction. This item may not have any attached files or they may not be in a supported format.]"
179 | full_text = ""
180 |
181 | # Combine all sections
182 | return f"{header}{attachment_info}{full_text}"
183 |
184 | except Exception as e:
185 | return f"Error retrieving item full text: {str(e)}"
186 |
187 |
188 | @mcp.tool(
189 | name="zotero_search_items",
190 | # More detail can be added if useful: https://www.zotero.org/support/dev/web_api/v3/basics#searching
191 | description="Search for items in your Zotero library, given a query string, query mode (titleCreatorYear or everything), and optional tag search (supports boolean searches). Returned results can be looked up with zotero_item_fulltext or zotero_item_metadata.",
192 | )
193 | def search_items(
194 | query: str,
195 | qmode: Literal["titleCreatorYear", "everything"] | None = "titleCreatorYear",
196 | tag: str | None = None,
197 | limit: int | None = 10,
198 | ) -> str:
199 | """Search for items in your Zotero library"""
200 | zot = get_zotero_client()
201 |
202 | # Search using the q parameter
203 | params = {"q": query, "qmode": qmode, "limit": limit}
204 | if tag:
205 | params["tag"] = tag
206 |
207 | zot.add_parameters(**params)
208 | # n.b. types for this return do not work, it's a parsed JSON object
209 | results: Any = zot.items()
210 |
211 | if not results:
212 | return "No items found matching your query."
213 |
214 | # Header with search info
215 | header = [
216 | f"# Search Results for: '{query}'",
217 | f"Found {len(results)} items." + (f" Using tag filter: {tag}" if tag else ""),
218 | "Use item keys with zotero_item_metadata or zotero_item_fulltext for more details.\n",
219 | ]
220 |
221 | # Format results
222 | formatted_results = []
223 | for i, item in enumerate(results):
224 | data = item["data"]
225 | item_key = item.get("key", "")
226 | item_type = data.get("itemType", "unknown")
227 |
228 | # Special handling for notes
229 | if item_type == "note":
230 | # Get note content
231 | note_content = data.get("note", "")
232 | # Strip HTML tags for cleaner text (simple approach)
233 | note_content = (
234 | note_content.replace("<p>", "")
235 | .replace("</p>", "\n")
236 | .replace("<br>", "\n")
237 | )
238 | note_content = note_content.replace("<strong>", "**").replace(
239 | "</strong>", "**"
240 | )
241 | note_content = note_content.replace("<em>", "*").replace("</em>", "*")
242 |
243 | # Extract a title from the first line if possible, otherwise use first few words
244 | title_preview = ""
245 | if note_content:
246 | lines = note_content.strip().split("\n")
247 | first_line = lines[0].strip()
248 | if first_line:
249 | # Use first line if it's reasonably short, otherwise use first few words
250 | if len(first_line) <= 50:
251 | title_preview = first_line
252 | else:
253 | words = first_line.split()
254 | title_preview = " ".join(words[:5]) + "..."
255 |
256 | # Create a good title for the note
257 | note_title = title_preview if title_preview else "Note"
258 |
259 | # Get a preview of the note content (truncated)
260 | preview = note_content.strip()
261 | if len(preview) > 150:
262 | preview = preview[:147] + "..."
263 |
264 | # Format the note entry
265 | entry = [
266 | f"## {i + 1}. 📝 {note_title}",
267 | f"**Type**: Note | **Key**: `{item_key}`",
268 | f"\n{preview}",
269 | ]
270 |
271 | # Add parent item reference if available
272 | if parent_item := data.get("parentItem"):
273 | entry.insert(2, f"**Parent Item**: `{parent_item}`")
274 |
275 | # Add tags if present (limited to first 5)
276 | if tags := data.get("tags"):
277 | tag_list = [f"`{tag['tag']}`" for tag in tags[:5]]
278 | if len(tags) > 5:
279 | tag_list.append("...")
280 | entry.append(f"\n**Tags**: {' '.join(tag_list)}")
281 |
282 | formatted_results.append("\n".join(entry))
283 | continue
284 |
285 | # Regular item processing (non-notes)
286 | title = data.get("title", "Untitled")
287 | date = data.get("date", "")
288 |
289 | # Format primary creators (limited to first 3)
290 | creators = []
291 | for creator in data.get("creators", [])[:3]:
292 | if "firstName" in creator and "lastName" in creator:
293 | creators.append(f"{creator['lastName']}, {creator['firstName']}")
294 | elif "name" in creator:
295 | creators.append(creator["name"])
296 |
297 | if len(data.get("creators", [])) > 3:
298 | creators.append("et al.")
299 |
300 | creator_str = "; ".join(creators) if creators else "No authors"
301 |
302 | # Get publication or source info
303 | source = ""
304 | if pub := data.get("publicationTitle"):
305 | source = pub
306 | elif book := data.get("bookTitle"):
307 | source = f"In: {book}"
308 | elif publisher := data.get("publisher"):
309 | source = f"{publisher}"
310 |
311 | # Get a brief abstract (truncated if too long)
312 | abstract = data.get("abstractNote", "")
313 | if len(abstract) > 150:
314 | abstract = abstract[:147] + "..."
315 |
316 | # Build formatted entry with markdown for better structure
317 | entry = [
318 | f"## {i + 1}. {title}",
319 | f"**Type**: {item_type} | **Date**: {date} | **Key**: `{item_key}`",
320 | f"**Authors**: {creator_str}",
321 | ]
322 |
323 | if source:
324 | entry.append(f"**Source**: {source}")
325 |
326 | if abstract:
327 | entry.append(f"\n{abstract}")
328 |
329 | # Add tags if present (limited to first 5)
330 | if tags := data.get("tags"):
331 | tag_list = [f"`{tag['tag']}`" for tag in tags[:5]]
332 | if len(tags) > 5:
333 | tag_list.append("...")
334 | entry.append(f"\n**Tags**: {' '.join(tag_list)}")
335 |
336 | formatted_results.append("\n".join(entry))
337 |
338 | return "\n\n".join(header + formatted_results)
339 |
```