├── .gitignore ├── LICENSE ├── README.md ├── env.example ├── package-lock.json ├── package.json ├── src ├── handlers │ ├── document-handlers.ts │ ├── index.ts │ └── tag-search-handlers.ts ├── index.ts ├── readwise-client.ts ├── tools │ └── tool-definitions.ts ├── types.ts └── utils │ ├── client-init.ts │ └── content-converter.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Environment variables 8 | .env 9 | 10 | # Logs 11 | *.log 12 | npm-debug.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage/ 22 | 23 | # OS generated files 24 | .DS_Store 25 | .DS_Store? 26 | ._* 27 | .Spotlight-V100 28 | .Trashes 29 | ehthumbs.db 30 | Thumbs.db 31 | 32 | # IDE files 33 | .vscode/ 34 | .idea/ 35 | *.swp 36 | *.swo 37 | 38 | # TypeScript cache 39 | *.tsbuildinfo 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Optional stylelint cache 48 | .stylelintcache 49 | 50 | # Microbundle cache 51 | .rpt2_cache/ 52 | .rts2_cache_cjs/ 53 | .rts2_cache_es/ 54 | .rts2_cache_umd/ 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variable files 66 | .env.development.local 67 | .env.test.local 68 | .env.production.local 69 | .env.local 70 | 71 | # parcel-bundler cache (https://parceljs.org/) 72 | .cache 73 | .parcel-cache 74 | 75 | # Next.js build output 76 | .next 77 | out 78 | 79 | # Nuxt.js build / generate output 80 | .nuxt 81 | dist 82 | 83 | # Gatsby files 84 | .cache/ 85 | public 86 | 87 | # Vuepress build output 88 | .vuepress/dist 89 | 90 | # Serverless directories 91 | .serverless/ 92 | 93 | # FuseBox cache 94 | .fusebox/ 95 | 96 | # DynamoDB Local files 97 | .dynamodb/ 98 | 99 | # TernJS port file 100 | .tern-port 101 | 102 | # Stores VSCode versions used for testing VSCode extensions 103 | .vscode-test 104 | 105 | # yarn v2 106 | .yarn/cache 107 | .yarn/unplugged 108 | .yarn/build-state.yml 109 | .yarn/install-state.gz 110 | .pnp.* 111 | 112 | # Build outputs 113 | build/ 114 | lib/ 115 | *.js 116 | *.js.map 117 | *.d.ts 118 | 119 | # IDE files 120 | .vscode/ 121 | .idea/ 122 | *.swp 123 | *.swo 124 | *~ 125 | 126 | # Temporary files 127 | tmp/ 128 | temp/ 129 | 130 | .cursorrules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 edricgsh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Readwise Reader MCP Server 2 | 3 | A Model Context Protocol (MCP) server for the Readwise Reader API, built with TypeScript and the official Claude SDK. 4 | 5 | ## Features 6 | 7 | - **Secure Authentication**: Uses environment variables for token storage 8 | - **Document Management**: Save, list, update, and delete documents with complete metadata 9 | - **Tag Management**: List and filter by tags 10 | - **Rich Filtering**: Filter documents by location, category, tags, and more 11 | - **Pagination Support**: Handle large document collections 12 | - **LLM-Friendly Content**: HTML content automatically converted to clean text using r.jina.ai 13 | - **Complete Data Access**: Returns full document information including content, metadata, and timestamps 14 | 15 | ## API Documentation 16 | 17 | For detailed information about the Readwise Reader API endpoints, parameters, and examples, please refer to the official API documentation: 18 | 19 | **📖 [Readwise Reader API Documentation](https://readwise.io/reader_api)** 20 | 21 | This MCP server implements all the core endpoints described in the official documentation. 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm install 27 | npm run build 28 | ``` 29 | 30 | ## Configuration 31 | 32 | ### With Claude Desktop 33 | 34 | 1. Build the MCP server: 35 | ```bash 36 | npm install 37 | npm run build 38 | ``` 39 | 40 | 2. Get your Readwise access token from: https://readwise.io/access_token 41 | 42 | 3. Add the server to your Claude Desktop configuration. Open your Claude Desktop settings and add this to your MCP servers configuration: 43 | 44 | **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` 45 | **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` 46 | 47 | ```json 48 | { 49 | "mcpServers": { 50 | "readwise-reader": { 51 | "command": "node", 52 | "args": ["/path/to/your/reader_readwise_mcp/dist/index.js"], 53 | "env": { 54 | "READWISE_TOKEN": "your_readwise_access_token_here" 55 | } 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | Replace: 62 | - `/path/to/your/reader_readwise_mcp` with the actual path to this project directory 63 | - `your_readwise_access_token_here` with your actual Readwise access token 64 | 65 | 4. Restart Claude Desktop 66 | 67 | 68 | ## Available Tools 69 | 70 | ### `readwise_save_document` 71 | Save a document (URL or HTML content) to Readwise Reader. 72 | 73 | **Parameters:** 74 | - `url` (required): URL of the document to save 75 | - `html` (optional): HTML content of the document 76 | - `tags` (optional): Array of tags to add 77 | - `location` (optional): Location to save (`new`, `later`, `shortlist`, `archive`, `feed`) 78 | - `category` (optional): Document category (`article`, `book`, `tweet`, `pdf`, `email`, `youtube`, `podcast`) 79 | 80 | ### `readwise_list_documents` 81 | List documents from Readwise Reader with optional filtering. Returns complete document information including metadata and LLM-friendly text content. 82 | 83 | **Parameters:** 84 | - `id` (optional): Filter by specific document ID 85 | - `updatedAfter` (optional): Filter documents updated after this date (ISO 8601) 86 | - `location` (optional): Filter by document location 87 | - `category` (optional): Filter by document category 88 | - `tag` (optional): Filter by tag name 89 | - `pageCursor` (optional): Page cursor for pagination 90 | - `withHtmlContent` (optional): ⚠️ **PERFORMANCE WARNING**: Include HTML content in the response. This significantly slows down the API. Only use when explicitly requested by the user or when raw HTML is specifically needed for the task. 91 | - `withFullContent` (optional): ⚠️ **PERFORMANCE WARNING**: Include full converted text content in the response. This significantly slows down the API as it fetches and processes each document's content. Only use when explicitly requested by the user or when document content is specifically needed for analysis/reading. Default: false for performance. 92 | 93 | **Returns:** 94 | Complete document objects with all available fields: 95 | - `id`, `title`, `author`, `url`, `source_url`, `summary` 96 | - `published_date`, `image_url`, `location`, `category` 97 | - `tags`, `created_at`, `updated_at` 98 | - `content`: LLM-friendly text content (converted from source_url or url via r.jina.ai) 99 | 100 | ### `readwise_update_document` 101 | Update a document in Readwise Reader. 102 | 103 | **Parameters:** 104 | - `id` (required): Document ID to update 105 | - `title` (optional): New title 106 | - `author` (optional): New author 107 | - `summary` (optional): New summary 108 | - `published_date` (optional): New published date (ISO 8601) 109 | - `image_url` (optional): New image URL 110 | - `location` (optional): New location 111 | - `category` (optional): New category 112 | 113 | ### `readwise_delete_document` 114 | Delete a document from Readwise Reader. 115 | 116 | **Parameters:** 117 | - `id` (required): Document ID to delete 118 | 119 | ### `readwise_list_tags` 120 | List all tags from Readwise Reader. 121 | 122 | **Parameters:** None 123 | 124 | ### `readwise_topic_search` 125 | Search documents in Readwise Reader by topic using regex matching on title, summary, notes, and tags. 126 | 127 | **Parameters:** 128 | - `searchTerms` (required): Array of search terms to match against document content (case-insensitive regex matching) 129 | 130 | **Returns:** 131 | Search results with matching documents including: 132 | - Search terms used 133 | - Total number of matches 134 | - Complete document objects with all available metadata (same fields as `readwise_list_documents`) 135 | 136 | ## Authentication 137 | 138 | The server requires a Readwise access token to be provided via the `READWISE_TOKEN` environment variable. This token is used to authenticate all API requests to Readwise Reader. 139 | 140 | **Security Note**: The token is stored in your MCP configuration and never exposed through Claude or the tools interface. 141 | 142 | ## Rate Limits 143 | 144 | - Default: 20 requests/minute 145 | - Document CREATE/UPDATE: 50 requests/minute 146 | - 429 responses include "Retry-After" header 147 | 148 | ## License 149 | 150 | MIT -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Readwise API Token 2 | # Get your token from: https://readwise.io/access_token 3 | READWISE_TOKEN=your_readwise_token_here -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readwise-reader-mcp", 3 | "version": "1.0.0", 4 | "description": "MCP server for Readwise Reader API", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc", 9 | "dev": "tsx src/index.ts", 10 | "start": "node dist/index.js", 11 | "test": "jest", 12 | "test:watch": "jest --watch", 13 | "test:manual": "tsx manual-test.ts" 14 | }, 15 | "dependencies": { 16 | "@modelcontextprotocol/sdk": "^1.0.0", 17 | "dotenv": "^16.3.0", 18 | "node-html-parser": "^7.0.1" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.5.0", 22 | "@types/node": "^20.0.0", 23 | "jest": "^29.5.0", 24 | "ts-jest": "^29.1.0", 25 | "tsx": "^4.0.0", 26 | "typescript": "^5.0.0" 27 | }, 28 | "engines": { 29 | "node": ">=18" 30 | }, 31 | "jest": { 32 | "preset": "ts-jest/presets/default-esm", 33 | "extensionsToTreatAsEsm": [ 34 | ".ts" 35 | ], 36 | "globals": { 37 | "ts-jest": { 38 | "useESM": true 39 | } 40 | }, 41 | "testMatch": [ 42 | "**/__tests__/**/*.test.ts" 43 | ], 44 | "collectCoverageFrom": [ 45 | "src/**/*.ts", 46 | "!src/**/*.d.ts" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/handlers/document-handlers.ts: -------------------------------------------------------------------------------- 1 | import { CreateDocumentRequest, UpdateDocumentRequest, ListDocumentsParams } from '../types.js'; 2 | import { initializeClient } from '../utils/client-init.js'; 3 | import { convertUrlToText, extractTextFromHtml } from '../utils/content-converter.js'; 4 | 5 | export async function handleSaveDocument(args: any) { 6 | const client = initializeClient(); 7 | const data = args as unknown as CreateDocumentRequest; 8 | const response = await client.createDocument(data); 9 | 10 | let responseText = `Document saved successfully!\nID: ${response.data.id}\nTitle: ${response.data.title || 'Untitled'}\nURL: ${response.data.url}\nLocation: ${response.data.location}`; 11 | 12 | if (response.messages && response.messages.length > 0) { 13 | responseText += '\n\nMessages:\n' + response.messages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 14 | } 15 | 16 | return { 17 | content: [ 18 | { 19 | type: 'text', 20 | text: responseText, 21 | }, 22 | ], 23 | }; 24 | } 25 | 26 | export async function handleListDocuments(args: any) { 27 | const client = initializeClient(); 28 | const params = args as ListDocumentsParams; 29 | 30 | // If withFullContent is true, we also need HTML content 31 | if (params.withFullContent === true) { 32 | params.withHtmlContent = true; 33 | } 34 | 35 | let response; 36 | let clientSideFiltered = false; 37 | 38 | // If addedAfter is specified, we need to fetch all documents and filter client-side 39 | if (params.addedAfter) { 40 | clientSideFiltered = true; 41 | const addedAfterDate = new Date(params.addedAfter); 42 | 43 | // Create params without addedAfter for the API call 44 | const apiParams = { ...params }; 45 | delete apiParams.addedAfter; 46 | 47 | // Fetch all documents if no other pagination is specified 48 | if (!apiParams.pageCursor && !apiParams.limit) { 49 | const allDocuments: any[] = []; 50 | let nextPageCursor: string | undefined; 51 | 52 | do { 53 | const fetchParams = { ...apiParams }; 54 | if (nextPageCursor) { 55 | fetchParams.pageCursor = nextPageCursor; 56 | } 57 | 58 | const pageResponse = await client.listDocuments(fetchParams); 59 | allDocuments.push(...pageResponse.data.results); 60 | nextPageCursor = pageResponse.data.nextPageCursor; 61 | } while (nextPageCursor); 62 | 63 | // Filter documents by addedAfter date 64 | const filteredDocuments = allDocuments.filter(doc => { 65 | if (!doc.saved_at) return false; 66 | const savedDate = new Date(doc.saved_at); 67 | return savedDate > addedAfterDate; 68 | }); 69 | 70 | response = { 71 | data: { 72 | count: filteredDocuments.length, 73 | nextPageCursor: undefined, 74 | results: filteredDocuments 75 | }, 76 | messages: [] 77 | }; 78 | } else { 79 | // If pagination is specified, just do a regular API call and filter the current page 80 | response = await client.listDocuments(apiParams); 81 | const filteredDocuments = response.data.results.filter(doc => { 82 | if (!doc.saved_at) return false; 83 | const savedDate = new Date(doc.saved_at); 84 | return savedDate > addedAfterDate; 85 | }); 86 | 87 | response.data.results = filteredDocuments; 88 | response.data.count = filteredDocuments.length; 89 | } 90 | } else { 91 | response = await client.listDocuments(params); 92 | } 93 | 94 | // Convert content to LLM-friendly text for documents only if withFullContent is explicitly true 95 | const shouldIncludeContent = params.withFullContent === true; // Default to false for performance 96 | const documentsWithText = await Promise.all( 97 | response.data.results.map(async (doc) => { 98 | let content = ''; 99 | if (shouldIncludeContent) { 100 | // Try to use HTML content first (from Readwise), fallback to URL fetching 101 | if (doc.html_content) { 102 | // Use HTML content from Readwise for non-jina content types 103 | const shouldUseJina = !doc.category || doc.category === 'article' || doc.category === 'pdf'; 104 | if (shouldUseJina) { 105 | const urlToConvert = doc.source_url || doc.url; 106 | if (urlToConvert) { 107 | content = await convertUrlToText(urlToConvert, doc.category); 108 | } 109 | } else { 110 | content = extractTextFromHtml(doc.html_content); 111 | } 112 | } else { 113 | // Fallback to URL fetching if no HTML content available 114 | const urlToConvert = doc.source_url || doc.url; 115 | if (urlToConvert) { 116 | content = await convertUrlToText(urlToConvert, doc.category); 117 | } 118 | } 119 | } 120 | 121 | const result: any = { 122 | id: doc.id, 123 | url: doc.url, 124 | title: doc.title, 125 | author: doc.author, 126 | source: doc.source, 127 | category: doc.category, 128 | location: doc.location, 129 | tags: doc.tags, 130 | site_name: doc.site_name, 131 | word_count: doc.word_count, 132 | created_at: doc.created_at, 133 | updated_at: doc.updated_at, 134 | published_date: doc.published_date, 135 | summary: doc.summary, 136 | image_url: doc.image_url, 137 | source_url: doc.source_url, 138 | notes: doc.notes, 139 | parent_id: doc.parent_id, 140 | reading_progress: doc.reading_progress, 141 | first_opened_at: doc.first_opened_at, 142 | last_opened_at: doc.last_opened_at, 143 | saved_at: doc.saved_at, 144 | last_moved_at: doc.last_moved_at, 145 | }; 146 | 147 | if (shouldIncludeContent) { 148 | result.content = content; // LLM-friendly text content instead of raw HTML 149 | } 150 | 151 | if (params.withHtmlContent && doc.html_content) { 152 | result.html_content = doc.html_content; 153 | } 154 | 155 | return result; 156 | }) 157 | ); 158 | 159 | let responseText = JSON.stringify({ 160 | count: response.data.count, 161 | nextPageCursor: response.data.nextPageCursor, 162 | documents: documentsWithText 163 | }, null, 2); 164 | 165 | let allMessages = response.messages || []; 166 | 167 | // Add message about client-side filtering if it was performed 168 | if (clientSideFiltered) { 169 | allMessages.push({ 170 | type: 'info', 171 | content: 'Documents were filtered client-side based on the addedAfter date. All documents were fetched from the API first, then filtered by their saved_at date.' 172 | }); 173 | } 174 | 175 | if (allMessages.length > 0) { 176 | responseText += '\n\nMessages:\n' + allMessages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 177 | } 178 | 179 | return { 180 | content: [ 181 | { 182 | type: 'text', 183 | text: responseText, 184 | }, 185 | ], 186 | }; 187 | } 188 | 189 | export async function handleUpdateDocument(args: any) { 190 | const client = initializeClient(); 191 | const { id, ...updateData } = args as unknown as { id: string } & UpdateDocumentRequest; 192 | const response = await client.updateDocument(id, updateData); 193 | 194 | let responseText = `Document updated successfully!\nID: ${response.data.id}\nReader URL: ${response.data.url}`; 195 | 196 | if (response.messages && response.messages.length > 0) { 197 | responseText += '\n\nMessages:\n' + response.messages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 198 | } 199 | 200 | return { 201 | content: [ 202 | { 203 | type: 'text', 204 | text: responseText, 205 | }, 206 | ], 207 | }; 208 | } 209 | 210 | export async function handleDeleteDocument(args: any) { 211 | const client = initializeClient(); 212 | const { id } = args as { id: string }; 213 | const response = await client.deleteDocument(id); 214 | 215 | let responseText = `Document ${id} deleted successfully!`; 216 | 217 | if (response.messages && response.messages.length > 0) { 218 | responseText += '\n\nMessages:\n' + response.messages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 219 | } 220 | 221 | return { 222 | content: [ 223 | { 224 | type: 'text', 225 | text: responseText, 226 | }, 227 | ], 228 | }; 229 | } -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | handleSaveDocument, 3 | handleListDocuments, 4 | handleUpdateDocument, 5 | handleDeleteDocument 6 | } from './document-handlers.js'; 7 | import { handleListTags, handleTopicSearch } from './tag-search-handlers.js'; 8 | 9 | export async function handleToolCall(name: string, args: any) { 10 | switch (name) { 11 | case 'readwise_save_document': 12 | return handleSaveDocument(args); 13 | 14 | case 'readwise_list_documents': 15 | return handleListDocuments(args); 16 | 17 | case 'readwise_update_document': 18 | return handleUpdateDocument(args); 19 | 20 | case 'readwise_delete_document': 21 | return handleDeleteDocument(args); 22 | 23 | case 'readwise_list_tags': 24 | return handleListTags(args); 25 | 26 | case 'readwise_topic_search': 27 | return handleTopicSearch(args); 28 | 29 | default: 30 | throw new Error(`Unknown tool: ${name}`); 31 | } 32 | } -------------------------------------------------------------------------------- /src/handlers/tag-search-handlers.ts: -------------------------------------------------------------------------------- 1 | import { initializeClient } from '../utils/client-init.js'; 2 | 3 | export async function handleListTags(args: any) { 4 | const client = initializeClient(); 5 | const response = await client.listTags(); 6 | const tagsText = response.data.map((tag: any) => `- ${tag.name}`).join('\n'); 7 | 8 | let responseText = `Available tags:\n${tagsText}`; 9 | 10 | if (response.messages && response.messages.length > 0) { 11 | responseText += '\n\nMessages:\n' + response.messages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 12 | } 13 | 14 | return { 15 | content: [ 16 | { 17 | type: 'text', 18 | text: responseText, 19 | }, 20 | ], 21 | }; 22 | } 23 | 24 | export async function handleTopicSearch(args: any) { 25 | const client = initializeClient(); 26 | const { searchTerms } = args as { searchTerms: string[] }; 27 | 28 | const response = await client.searchDocumentsByTopic(searchTerms); 29 | 30 | const searchResults = { 31 | searchTerms, 32 | totalMatches: response.data.length, 33 | documents: response.data.map((doc: any) => ({ 34 | id: doc.id, 35 | url: doc.url, 36 | title: doc.title, 37 | author: doc.author, 38 | source: doc.source, 39 | category: doc.category, 40 | location: doc.location, 41 | tags: doc.tags, 42 | site_name: doc.site_name, 43 | word_count: doc.word_count, 44 | created_at: doc.created_at, 45 | updated_at: doc.updated_at, 46 | published_date: doc.published_date, 47 | summary: doc.summary, 48 | image_url: doc.image_url, 49 | source_url: doc.source_url, 50 | notes: doc.notes, 51 | reading_progress: doc.reading_progress, 52 | first_opened_at: doc.first_opened_at, 53 | last_opened_at: doc.last_opened_at, 54 | saved_at: doc.saved_at, 55 | last_moved_at: doc.last_moved_at, 56 | })) 57 | }; 58 | 59 | let responseText = JSON.stringify(searchResults, null, 2); 60 | 61 | if (response.messages && response.messages.length > 0) { 62 | responseText += '\n\nMessages:\n' + response.messages.map(msg => `${msg.type.toUpperCase()}: ${msg.content}`).join('\n'); 63 | } 64 | 65 | return { 66 | content: [ 67 | { 68 | type: 'text', 69 | text: responseText, 70 | }, 71 | ], 72 | }; 73 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | import { tools } from './tools/tool-definitions.js'; 10 | import { handleToolCall } from './handlers/index.js'; 11 | 12 | const server = new Server( 13 | { 14 | name: 'readwise-reader-mcp', 15 | version: '1.0.0', 16 | }, 17 | { 18 | capabilities: { 19 | tools: {}, 20 | }, 21 | } 22 | ); 23 | 24 | server.setRequestHandler(ListToolsRequestSchema, async () => { 25 | return { tools }; 26 | }); 27 | 28 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 29 | const { name, arguments: args } = request.params; 30 | 31 | try { 32 | return await handleToolCall(name, args); 33 | } catch (error) { 34 | return { 35 | content: [ 36 | { 37 | type: 'text', 38 | text: `Error: ${error instanceof Error ? error.message : String(error)}`, 39 | }, 40 | ], 41 | isError: true, 42 | }; 43 | } 44 | }); 45 | 46 | async function main() { 47 | const transport = new StdioServerTransport(); 48 | await server.connect(transport); 49 | } 50 | 51 | main().catch((error) => { 52 | console.error('Server error:', error); 53 | process.exit(1); 54 | }); -------------------------------------------------------------------------------- /src/readwise-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadwiseDocument, 3 | CreateDocumentRequest, 4 | UpdateDocumentRequest, 5 | ListDocumentsParams, 6 | ListDocumentsResponse, 7 | ReadwiseTag, 8 | ReadwiseConfig, 9 | APIResponse, 10 | APIMessage 11 | } from './types.js'; 12 | 13 | export class ReadwiseClient { 14 | private readonly baseUrl = 'https://readwise.io/api/v3'; 15 | private readonly authUrl = 'https://readwise.io/api/v2/auth/'; 16 | private readonly token: string; 17 | 18 | constructor(config: ReadwiseConfig) { 19 | this.token = config.token; 20 | } 21 | 22 | private async makeRequest( 23 | endpoint: string, 24 | options: RequestInit = {} 25 | ): Promise { 26 | const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`; 27 | 28 | const response = await fetch(url, { 29 | ...options, 30 | headers: { 31 | 'Authorization': `Token ${this.token}`, 32 | 'Content-Type': 'application/json', 33 | ...options.headers, 34 | }, 35 | }); 36 | 37 | if (!response.ok) { 38 | if (response.status === 429) { 39 | const retryAfter = response.headers.get('Retry-After'); 40 | const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; 41 | throw new Error(`RATE_LIMIT:${retryAfterSeconds}`); 42 | } 43 | 44 | const errorText = await response.text(); 45 | throw new Error(`Readwise API error: ${response.status} ${response.statusText} - ${errorText}`); 46 | } 47 | 48 | return response.json(); 49 | } 50 | 51 | private createResponse(data: T, messages?: APIMessage[]): APIResponse { 52 | return { data, messages }; 53 | } 54 | 55 | private createInfoMessage(content: string): APIMessage { 56 | return { type: 'info', content }; 57 | } 58 | 59 | private createErrorMessage(content: string): APIMessage { 60 | return { type: 'error', content }; 61 | } 62 | 63 | async validateAuth(): Promise> { 64 | try { 65 | const result = await this.makeRequest<{ detail: string }>(this.authUrl); 66 | return this.createResponse(result); 67 | } catch (error) { 68 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 69 | const seconds = parseInt(error.message.split(':')[1], 10); 70 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 71 | } 72 | throw error; 73 | } 74 | } 75 | 76 | async createDocument(data: CreateDocumentRequest): Promise> { 77 | try { 78 | const result = await this.makeRequest('/save/', { 79 | method: 'POST', 80 | body: JSON.stringify(data), 81 | }); 82 | return this.createResponse(result); 83 | } catch (error) { 84 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 85 | const seconds = parseInt(error.message.split(':')[1], 10); 86 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 87 | } 88 | throw error; 89 | } 90 | } 91 | 92 | async listDocuments(params: ListDocumentsParams = {}): Promise> { 93 | try { 94 | // If withFullContent is requested, first check the document count 95 | if (params.withFullContent) { 96 | const countParams = { ...params }; 97 | delete countParams.withFullContent; 98 | delete countParams.withHtmlContent; // Also remove HTML content for the count check 99 | 100 | const countSearchParams = new URLSearchParams(); 101 | Object.entries(countParams).forEach(([key, value]) => { 102 | if (value !== undefined) { 103 | countSearchParams.append(key, String(value)); 104 | } 105 | }); 106 | 107 | const countQuery = countSearchParams.toString(); 108 | const countEndpoint = `/list/${countQuery ? `?${countQuery}` : ''}`; 109 | 110 | const countResponse = await this.makeRequest(countEndpoint); 111 | 112 | if (countResponse.count > 5) { 113 | // Get first 5 documents with full content 114 | const limitedParams = { ...params, limit: 5 }; 115 | const searchParams = new URLSearchParams(); 116 | 117 | Object.entries(limitedParams).forEach(([key, value]) => { 118 | if (value !== undefined) { 119 | searchParams.append(key, String(value)); 120 | } 121 | }); 122 | 123 | const query = searchParams.toString(); 124 | const endpoint = `/list/${query ? `?${query}` : ''}`; 125 | 126 | const result = await this.makeRequest(endpoint); 127 | 128 | let message: APIMessage; 129 | if (countResponse.count <= 20) { 130 | message = this.createInfoMessage( 131 | `Found ${countResponse.count} documents, but only returning the first 5 due to full content request. ` + 132 | `To get the remaining ${countResponse.count - 5} documents with full content, ` + 133 | `you can fetch them individually by their IDs using the update/read document API.` 134 | ); 135 | } else { 136 | message = this.createErrorMessage( 137 | `Found ${countResponse.count} documents, but only returning the first 5 due to full content request. ` + 138 | `Getting full content for more than 20 documents is not supported due to performance limitations.` 139 | ); 140 | } 141 | 142 | return this.createResponse(result, [message]); 143 | } 144 | } 145 | 146 | const searchParams = new URLSearchParams(); 147 | 148 | Object.entries(params).forEach(([key, value]) => { 149 | if (value !== undefined) { 150 | searchParams.append(key, String(value)); 151 | } 152 | }); 153 | 154 | const query = searchParams.toString(); 155 | const endpoint = `/list/${query ? `?${query}` : ''}`; 156 | 157 | const result = await this.makeRequest(endpoint); 158 | return this.createResponse(result); 159 | } catch (error) { 160 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 161 | const seconds = parseInt(error.message.split(':')[1], 10); 162 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 163 | } 164 | throw error; 165 | } 166 | } 167 | 168 | async updateDocument(id: string, data: UpdateDocumentRequest): Promise> { 169 | try { 170 | const result = await this.makeRequest(`/update/${id}/`, { 171 | method: 'PATCH', 172 | body: JSON.stringify(data), 173 | }); 174 | return this.createResponse(result); 175 | } catch (error) { 176 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 177 | const seconds = parseInt(error.message.split(':')[1], 10); 178 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 179 | } 180 | throw error; 181 | } 182 | } 183 | 184 | async deleteDocument(id: string): Promise> { 185 | try { 186 | await this.makeRequest(`/delete/${id}/`, { 187 | method: 'DELETE', 188 | }); 189 | return this.createResponse(undefined); 190 | } catch (error) { 191 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 192 | const seconds = parseInt(error.message.split(':')[1], 10); 193 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 194 | } 195 | throw error; 196 | } 197 | } 198 | 199 | async listTags(): Promise> { 200 | try { 201 | const result = await this.makeRequest('/tags/'); 202 | return this.createResponse(result); 203 | } catch (error) { 204 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 205 | const seconds = parseInt(error.message.split(':')[1], 10); 206 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 207 | } 208 | throw error; 209 | } 210 | } 211 | 212 | async searchDocumentsByTopic(searchTerms: string[]): Promise> { 213 | try { 214 | // Fetch all documents without full content for performance 215 | const allDocuments: ReadwiseDocument[] = []; 216 | let nextPageCursor: string | undefined; 217 | 218 | do { 219 | const params: ListDocumentsParams = { 220 | withFullContent: false, 221 | withHtmlContent: false, 222 | }; 223 | 224 | if (nextPageCursor) { 225 | params.pageCursor = nextPageCursor; 226 | } 227 | 228 | const response = await this.listDocuments(params); 229 | allDocuments.push(...response.data.results); 230 | nextPageCursor = response.data.nextPageCursor; 231 | } while (nextPageCursor); 232 | 233 | // Create regex patterns from search terms (case-insensitive) 234 | const regexPatterns = searchTerms.map(term => 235 | new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') 236 | ); 237 | 238 | // Filter documents that match any of the search terms 239 | const matchingDocuments = allDocuments.filter(doc => { 240 | // Extract searchable text fields 241 | const searchableFields = [ 242 | doc.title || '', 243 | doc.summary || '', 244 | doc.notes || '', 245 | // Handle tags - they can be string array or object 246 | Array.isArray(doc.tags) ? doc.tags.join(' ') : '', 247 | ]; 248 | 249 | const searchableText = searchableFields.join(' ').toLowerCase(); 250 | 251 | // Check if any regex pattern matches 252 | return regexPatterns.some(pattern => pattern.test(searchableText)); 253 | }); 254 | 255 | return this.createResponse(matchingDocuments); 256 | } catch (error) { 257 | if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { 258 | const seconds = parseInt(error.message.split(':')[1], 10); 259 | throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); 260 | } 261 | throw error; 262 | } 263 | } 264 | } -------------------------------------------------------------------------------- /src/tools/tool-definitions.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | 3 | export const tools: Tool[] = [ 4 | { 5 | name: 'readwise_save_document', 6 | description: 'Save a document (URL or HTML content) to Readwise Reader', 7 | inputSchema: { 8 | type: 'object', 9 | properties: { 10 | url: { 11 | type: 'string', 12 | description: 'URL of the document to save', 13 | }, 14 | html: { 15 | type: 'string', 16 | description: 'HTML content of the document (optional)', 17 | }, 18 | tags: { 19 | type: 'array', 20 | items: { type: 'string' }, 21 | description: 'Tags to add to the document', 22 | }, 23 | location: { 24 | type: 'string', 25 | enum: ['new', 'later', 'shortlist', 'archive', 'feed'], 26 | description: 'Location to save the document (default: new)', 27 | }, 28 | category: { 29 | type: 'string', 30 | enum: ['article', 'book', 'tweet', 'pdf', 'email', 'youtube', 'podcast'], 31 | description: 'Category of the document (auto-detected if not specified)', 32 | }, 33 | }, 34 | required: ['url'], 35 | additionalProperties: false, 36 | }, 37 | }, 38 | { 39 | name: 'readwise_list_documents', 40 | description: 'List documents from Readwise Reader with optional filtering', 41 | inputSchema: { 42 | type: 'object', 43 | properties: { 44 | id: { 45 | type: 'string', 46 | description: 'Filter by specific document ID', 47 | }, 48 | updatedAfter: { 49 | type: 'string', 50 | description: 'Filter documents updated after this date (ISO 8601)', 51 | }, 52 | addedAfter: { 53 | type: 'string', 54 | description: 'Filter documents added after this date (ISO 8601). Note: This will fetch all documents first and then filter client-side.', 55 | }, 56 | location: { 57 | type: 'string', 58 | enum: ['new', 'later', 'shortlist', 'archive', 'feed'], 59 | description: 'Filter by document location', 60 | }, 61 | category: { 62 | type: 'string', 63 | enum: ['article', 'book', 'tweet', 'pdf', 'email', 'youtube', 'podcast'], 64 | description: 'Filter by document category', 65 | }, 66 | tag: { 67 | type: 'string', 68 | description: 'Filter by tag name', 69 | }, 70 | pageCursor: { 71 | type: 'string', 72 | description: 'Page cursor for pagination', 73 | }, 74 | withHtmlContent: { 75 | type: 'boolean', 76 | description: '⚠️ PERFORMANCE WARNING: Include HTML content in the response. This significantly slows down the API. Only use when explicitly requested by the user or when raw HTML is specifically needed for the task.', 77 | }, 78 | withFullContent: { 79 | type: 'boolean', 80 | description: '⚠️ PERFORMANCE WARNING: Include full converted text content in the response. This significantly slows down the API as it fetches and processes each document\'s content. Only use when explicitly requested by the user or when document content is specifically needed for analysis/reading. Default: false for performance.', 81 | }, 82 | }, 83 | additionalProperties: false, 84 | }, 85 | }, 86 | { 87 | name: 'readwise_update_document', 88 | description: 'Update a document in Readwise Reader', 89 | inputSchema: { 90 | type: 'object', 91 | properties: { 92 | id: { 93 | type: 'string', 94 | description: 'Document ID to update', 95 | }, 96 | title: { 97 | type: 'string', 98 | description: 'New title for the document', 99 | }, 100 | author: { 101 | type: 'string', 102 | description: 'New author for the document', 103 | }, 104 | summary: { 105 | type: 'string', 106 | description: 'New summary for the document', 107 | }, 108 | published_date: { 109 | type: 'string', 110 | description: 'New published date (ISO 8601)', 111 | }, 112 | image_url: { 113 | type: 'string', 114 | description: 'New image URL for the document', 115 | }, 116 | location: { 117 | type: 'string', 118 | enum: ['new', 'later', 'shortlist', 'archive', 'feed'], 119 | description: 'New location for the document', 120 | }, 121 | category: { 122 | type: 'string', 123 | enum: ['article', 'book', 'tweet', 'pdf', 'email', 'youtube', 'podcast'], 124 | description: 'New category for the document', 125 | }, 126 | }, 127 | required: ['id'], 128 | additionalProperties: false, 129 | }, 130 | }, 131 | { 132 | name: 'readwise_delete_document', 133 | description: 'Delete a document from Readwise Reader', 134 | inputSchema: { 135 | type: 'object', 136 | properties: { 137 | id: { 138 | type: 'string', 139 | description: 'Document ID to delete', 140 | }, 141 | }, 142 | required: ['id'], 143 | additionalProperties: false, 144 | }, 145 | }, 146 | { 147 | name: 'readwise_list_tags', 148 | description: 'List all tags from Readwise Reader', 149 | inputSchema: { 150 | type: 'object', 151 | properties: {}, 152 | additionalProperties: false, 153 | }, 154 | }, 155 | { 156 | name: 'readwise_topic_search', 157 | description: 'Search documents in Readwise Reader by topic using regex matching on title, summary, notes, and tags', 158 | inputSchema: { 159 | type: 'object', 160 | properties: { 161 | searchTerms: { 162 | type: 'array', 163 | items: { type: 'string' }, 164 | description: 'List of search terms to match against document content (case-insensitive regex matching)', 165 | minItems: 1, 166 | }, 167 | }, 168 | required: ['searchTerms'], 169 | additionalProperties: false, 170 | }, 171 | }, 172 | ]; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ReadwiseDocument { 2 | id: string; 3 | url: string; 4 | source_url?: string; 5 | title?: string; 6 | author?: string; 7 | source?: string; 8 | summary?: string; 9 | published_date?: string | number; 10 | image_url?: string; 11 | location: 'new' | 'later' | 'shortlist' | 'archive' | 'feed'; 12 | category?: 'article' | 'book' | 'tweet' | 'pdf' | 'email' | 'youtube' | 'podcast' | 'video'; 13 | tags?: string[] | object; 14 | site_name?: string; 15 | word_count?: number | null; 16 | created_at: string; 17 | updated_at: string; 18 | notes?: string; 19 | parent_id?: string | null; 20 | reading_progress?: number; 21 | first_opened_at?: string | null; 22 | last_opened_at?: string | null; 23 | saved_at?: string; 24 | last_moved_at?: string; 25 | html_content?: string; 26 | } 27 | 28 | export interface CreateDocumentRequest { 29 | url: string; 30 | html?: string; 31 | tags?: string[]; 32 | location?: 'new' | 'later' | 'shortlist' | 'archive' | 'feed'; 33 | category?: 'article' | 'book' | 'tweet' | 'pdf' | 'email' | 'youtube' | 'podcast'; 34 | } 35 | 36 | export interface UpdateDocumentRequest { 37 | title?: string; 38 | author?: string; 39 | summary?: string; 40 | published_date?: string; 41 | image_url?: string; 42 | location?: 'new' | 'later' | 'shortlist' | 'archive' | 'feed'; 43 | category?: 'article' | 'book' | 'tweet' | 'pdf' | 'email' | 'youtube' | 'podcast'; 44 | } 45 | 46 | export interface ListDocumentsParams { 47 | id?: string; 48 | updatedAfter?: string; 49 | addedAfter?: string; 50 | location?: 'new' | 'later' | 'shortlist' | 'archive' | 'feed'; 51 | category?: 'article' | 'book' | 'tweet' | 'pdf' | 'email' | 'youtube' | 'podcast'; 52 | tag?: string; 53 | pageCursor?: string; 54 | withHtmlContent?: boolean; 55 | withFullContent?: boolean; 56 | limit?: number; 57 | } 58 | 59 | export interface ListDocumentsResponse { 60 | count: number; 61 | nextPageCursor?: string; 62 | results: ReadwiseDocument[]; 63 | } 64 | 65 | export interface ReadwiseTag { 66 | id: string; 67 | name: string; 68 | } 69 | 70 | export interface ReadwiseConfig { 71 | token: string; 72 | } 73 | 74 | export interface APIMessage { 75 | type: 'info' | 'warning' | 'error'; 76 | content: string; 77 | } 78 | 79 | export interface APIResponse { 80 | data: T; 81 | messages?: APIMessage[]; 82 | } -------------------------------------------------------------------------------- /src/utils/client-init.ts: -------------------------------------------------------------------------------- 1 | import { ReadwiseClient } from '../readwise-client.js'; 2 | 3 | let readwiseClient: ReadwiseClient | null = null; 4 | 5 | // Initialize the client with token from environment or config 6 | export function initializeClient(): ReadwiseClient { 7 | if (readwiseClient) { 8 | return readwiseClient; 9 | } 10 | 11 | const token = process.env.READWISE_TOKEN; 12 | if (!token) { 13 | throw new Error('Readwise access token not provided. Please set READWISE_TOKEN in your MCP configuration or environment variables. You can get your token from https://readwise.io/access_token'); 14 | } 15 | 16 | readwiseClient = new ReadwiseClient({ token }); 17 | return readwiseClient; 18 | } -------------------------------------------------------------------------------- /src/utils/content-converter.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'node-html-parser'; 2 | 3 | // Convert URL content using jina.ai 4 | export async function convertWithJina(url: string): Promise { 5 | const jinaUrl = `https://r.jina.ai/${url}`; 6 | 7 | const response = await fetch(jinaUrl, { 8 | headers: { 9 | 'Accept': 'text/plain', 10 | 'User-Agent': 'Readwise-MCP-Server/1.0.0' 11 | } 12 | }); 13 | 14 | if (!response.ok) { 15 | throw new Error(`Jina conversion failed: ${response.status}`); 16 | } 17 | 18 | return response.text(); 19 | } 20 | 21 | // Extract text content from HTML string 22 | export function extractTextFromHtml(htmlContent: string): string { 23 | if (!htmlContent?.trim()) { 24 | return ''; 25 | } 26 | 27 | const root = parse(htmlContent); 28 | 29 | // Remove non-content elements 30 | root.querySelectorAll('script, style, nav, header, footer').forEach(el => el.remove()); 31 | 32 | // Get title and body text 33 | const title = root.querySelector('title')?.text?.trim() || ''; 34 | const bodyText = root.querySelector('body')?.text || root.text || ''; 35 | 36 | // Clean up whitespace 37 | const cleanText = bodyText.replace(/\s+/g, ' ').trim(); 38 | 39 | return title ? `${title}\n\n${cleanText}` : cleanText; 40 | } 41 | 42 | // Convert URL content to LLM-friendly text 43 | export async function convertUrlToText(url: string, category?: string): Promise { 44 | if (!url?.trim()) { 45 | return ''; 46 | } 47 | 48 | try { 49 | // Use jina for articles and PDFs, lightweight HTML parsing for others 50 | const shouldUseJina = !category || category === 'article' || category === 'pdf'; 51 | 52 | if (shouldUseJina) { 53 | return await convertWithJina(url); 54 | } else { 55 | // For non-article/pdf content, we'll rely on HTML content from Readwise 56 | // This function is now mainly used as a fallback 57 | const response = await fetch(url, { 58 | headers: { 59 | 'User-Agent': 'Readwise-MCP-Server/1.0.0', 60 | 'Accept': 'text/html,application/xhtml+xml' 61 | } 62 | }); 63 | 64 | if (!response.ok) { 65 | throw new Error(`HTML fetch failed: ${response.status}`); 66 | } 67 | 68 | const html = await response.text(); 69 | return extractTextFromHtml(html); 70 | } 71 | } catch (error) { 72 | console.warn('Error converting URL to text:', error); 73 | return '[Content unavailable - conversion error]'; 74 | } 75 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist", 12 | "rootDir": "./src", 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } --------------------------------------------------------------------------------