#
tokens: 2887/50000 7/7 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .github
│   └── workflows
│       └── python-package.yml
├── .gitignore
├── .python-version
├── LICENSE
├── main.py
├── models.py
├── pyproject.toml
├── README.md
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
3.11

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Knowledge docs
kb/

# Env
.env

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Reader MCP Server

<a href="https://glama.ai/mcp/servers/@xinthink/reader-mcp-server">
  <img width="380" height="200" src="https://glama.ai/mcp/servers/@xinthink/reader-mcp-server/badge" alt="Reader MCP Server" />
</a>

## Overview
A Model Context Protocol (MCP) server that seamlessly integrates with your [Readwise Reader](https://readwise.io/reader_api) library. This server enables MCP-compatible clients like Claude and VS Code to interact with your Reader library, providing capabilities for document listing, retrieval, and updates. It serves as a bridge between MCP clients and your personal knowledge repository in Readwise Reader.

## Components

### Tools

- `list_documents`
  - List documents from Reader with flexible filtering and pagination.
  - **Input:**
    - `location` (string, optional): Folder to filter by. One of `new`, `later`, `shortlist`, `archive`, `feed`.
    - `updatedAfter` (string, optional): Only return documents updated after this ISO8601 timestamp.
    - `withContent` (boolean, optional): If true, include HTML content in results (default: false).
    - `pageCursor` (string, optional): Pagination cursor for fetching the next page.
  - **Returns:**
    - JSON object with a list of documents, each including metadata and (optionally) content, plus pagination info.

## Usage with MCP Clients

### Claude Desktop / VS Code / Other MCP Clients
To use this server with Claude Desktop, VS Code, or any MCP-compatible client, add the following configuration to your client settings (e.g., `claude_desktop_config.json` or `.vscode/mcp.json`):

#### uv (local server)
```json
{
  "mcpServers": {
    "reader": {
      "command": "uv",
      "args": [
        "--directory",
        "/absolute/path/to/your/reader/server",
        "run",
        "main.py"
      ],
      "env": {
        "ACCESS_TOKEN": "your_readwise_access_token"
      }
    }
  }
}
```
- Replace `/absolute/path/to/your/reader/server` with the actual path to this project directory.
- Replace `your_readwise_access_token` with your actual Readwise Reader API access token.
- Alternatively, you can specify the `ACCESS_TOKEN` in an `.env` file located in the project directory.

---
For more information, see the [Readwise Reader API documentation](https://readwise.io/reader_api) and [MCP documentation](https://modelcontextprotocol.io/).

```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[project]
name = "reader-mcp-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "dotenv>=0.9.9",
    "httpx>=0.28.1",
    "mcp[cli]>=1.4.1",
]

```

--------------------------------------------------------------------------------
/.github/workflows/python-package.yml:
--------------------------------------------------------------------------------

```yaml
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python package

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11"]

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v3
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        python -m pip install flake8 pytest
        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
    - name: Lint with flake8
      run: |
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Test with pytest
      run: |
        pytest

```

--------------------------------------------------------------------------------
/models.py:
--------------------------------------------------------------------------------

```python
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Any


@dataclass
class ReaderDocument:
    """
    Document object in Reader.
    """
    # Required document identifiers
    id: str
    url: str

    # Document details
    title: str
    source_url: Optional[str] = None
    author: Optional[str] = None
    source: Optional[str] = None
    category: Optional[str] = None
    location: Optional[str] = None
    tags: Dict[str, Any] = field(default_factory=dict)
    site_name: Optional[str] = None
    word_count: Optional[int] = None
    notes: Optional[str] = None
    published_date: Optional[str] = None
    summary: Optional[str] = None
    html_content: Optional[str] = None
    image_url: Optional[str] = None
    parent_id: Optional[str] = None

    # Reading state
    reading_progress: float = 0.0
    first_opened_at: Optional[datetime] = None
    last_opened_at: Optional[datetime] = None

    # Timestamps
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None
    saved_at: Optional[datetime] = None
    last_moved_at: Optional[datetime] = None

    @classmethod
    def from_dict(cls, doc: Dict[str, Any]) -> "ReaderDocument":
        """Create a ReaderDocument from a dictionary representation"""
        return cls(
            id=doc.get('id', ''),
            url=doc.get('url', ''),
            title=doc.get('title', 'Untitled'),
            source_url=doc.get('source_url'),
            author=doc.get('author'),
            source=doc.get('source'),
            category=doc.get('category'),
            location=doc.get('location'),
            tags=doc.get('tags', {}),
            site_name=doc.get('site_name'),
            word_count=doc.get('word_count'),
            notes=doc.get('notes'),
            published_date=doc.get('published_date'),
            summary=doc.get('summary'),
            html_content=doc.get('html_content'),
            image_url=doc.get('image_url'),
            parent_id=doc.get('parent_id'),
            reading_progress=doc.get('reading_progress', 0.0),
            first_opened_at=doc.get('first_opened_at'),
            last_opened_at=doc.get('last_opened_at'),
            created_at=doc.get('created_at'),
            updated_at=doc.get('updated_at'),
            saved_at=doc.get('saved_at'),
            last_moved_at=doc.get('last_moved_at')
        )


@dataclass
class ListDocumentResponse:
    """Response of the document list API"""
    count: int
    results: List[ReaderDocument]
    nextPageCursor: Optional[str] = None

```

--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------

```python
#!/usr/bin/env python3
"""
Reader API MCP Server

This MCP server connects to the Readwise Reader API and exposes resources to retrieve document lists
based on specified time ranges, locations, or types.
"""

import os
import httpx
import logging
from dotenv import load_dotenv
from typing import Dict, Any, Optional, Union, cast, Literal
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server.fastmcp import FastMCP
from pydantic import Field

from models import ListDocumentResponse


# Set up logging
logger = logging.getLogger("reader-mcp-server")

# Reader API endpoints
READER_API_BASE_URL = "https://readwise.io/api/v3"
VALID_LOCATIONS = {'new', 'later', 'shortlist', 'archive', 'feed'}


@dataclass
class ReaderContext:
    """Reader API Context"""
    access_token: str
    client: httpx.AsyncClient


@asynccontextmanager
async def reader_lifespan(_: FastMCP):
    """Manage the lifecycle of Reader API client"""
    # Get access token from environment variables
    load_dotenv()
    access_token = os.environ.get("ACCESS_TOKEN")
    if not access_token:
        logger.error("ACCESS_TOKEN environment variable is not set")
        raise ValueError("ACCESS_TOKEN environment variable is not set")

    # Create HTTP client
    async with httpx.AsyncClient(
        base_url=READER_API_BASE_URL,
        headers={"Authorization": f"Token {access_token}"},
        timeout=30.0
    ) as client:
        # Provide context
        yield ReaderContext(access_token=access_token, client=client)


# Create MCP server
mcp = FastMCP(
    "reader-api",
    lifespan=reader_lifespan,
    dependencies=["httpx"]
)


def get_reader_context() -> ReaderContext:
    """Get Reader API context"""
    ctx = mcp.get_context()
    return cast(ReaderContext, ctx.request_context.lifespan_context)


def validate_list_params(location: Optional[Literal['new', 'later', 'shortlist', 'archive', 'feed']] = None,
                         after: Optional[str] = None,
                         with_content: Optional[bool] = False,
                         page_cursor: Optional[str] = None) -> Dict[str, Any]:
    """
    Validate and filter document list parameters.
    Args:
        location: The location parameter to validate (only supports 'new', 'later', 'shortlist', 'archive', 'feed')
        after: The timestamp parameter to validate
        with_content: Whether to include html_content
        page_cursor: Pagination cursor
    Returns:
        Dict containing valid parameters
    """

    params = {}
    if location in VALID_LOCATIONS:
        params['location'] = location
    else:
        logger.warning(f"Invalid `location`: '{location}', parameter will be ignored")
    try:
        if after and 'T' in after and (after.endswith('Z') or '+' in after):
            params['updatedAfter'] = after
        elif after:
            logger.warning(f"Invalid ISO 8601 datetime: {after}, parameter will be ignored")
    except (TypeError, ValueError):
        logger.warning(f"Invalid datetime format: {after}, parameter will be ignored")
    if with_content:
        params['withHtmlContent'] = with_content
    if page_cursor:
        params['pageCursor'] = page_cursor
    return params

@mcp.tool()
async def list_documents(
        location: Optional[Literal['new', 'later', 'shortlist', 'archive', 'feed']] = Field(
            default=None,
            description="The folder where the document is located, supports 'new', 'later', 'shortlist', 'archive', 'feed'"),
        updatedAfter: Optional[str] = Field(default=None, description="Filter by update time (ISO8601)"),
        withContent: Optional[bool] = Field(default=False, description="Whether to include HTML content"),
        pageCursor: Optional[str] = Field(default=None, description="Pagination cursor"),
    ) -> ListDocumentResponse:
    """
    Get the document list via the Reader API.
    Args:
        location: The folder where the document is located, supports 'new', 'later', 'shortlist', 'archive', 'feed' (optional)
        updatedAfter: Filter by update time (optional, ISO8601)
        withContent: Whether to include HTML content (optional, default false)
        pageCursor: Pagination cursor (optional)
    Returns:
        Document list JSON
    """
    ctx = get_reader_context()
    logger.info(f"tool list_documents: location={location}, updatedAfter={updatedAfter}, withContent={withContent}, pageCursor={pageCursor}")
    try:
        params = validate_list_params(location, updatedAfter, withContent, pageCursor)
        response = await ctx.client.get("/list/", params=params)
        response.raise_for_status()
        data = response.json()
        return data
    except Exception as e:
        logger.error(f"Error in tool list_documents: {str(e)}")
        raise


if __name__ == "__main__":
    # Run server
    mcp.run()

```