# 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()
```