Skip to main content
Glama
IAmAlexander

Readwise MCP Server

by IAmAlexander
smithery.ts26.3 kB
#!/usr/bin/env node /** * Smithery-compatible entry point for Readwise MCP Server * * This file provides a simplified entry point that works with Smithery's TypeScript runtime. * Smithery handles all HTTP/transport setup automatically, so we just need to: * 1. Export a configSchema (using Zod) * 2. Export a default function that creates and returns the MCP server * 3. Register all tools with the server */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { z as zod } from "zod"; // Import API client and tools import { ReadwiseClient } from './api/client.js'; import { ReadwiseAPI } from './api/readwise-api.js'; // Import all tools import { GetHighlightsTool } from './tools/get-highlights.js'; import { GetBooksTool } from './tools/get-books.js'; import { GetDocumentsTool } from './tools/get-documents.js'; import { SearchHighlightsTool } from './tools/search-highlights.js'; import { GetTagsTool } from './tools/get-tags.js'; import { DocumentTagsTool } from './tools/document-tags.js'; import { BulkTagsTool } from './tools/bulk-tags.js'; import { GetReadingProgressTool } from './tools/get-reading-progress.js'; import { UpdateReadingProgressTool } from './tools/update-reading-progress.js'; import { GetReadingListTool } from './tools/get-reading-list.js'; import { CreateHighlightTool } from './tools/create-highlight.js'; import { UpdateHighlightTool } from './tools/update-highlight.js'; import { DeleteHighlightTool } from './tools/delete-highlight.js'; import { CreateNoteTool } from './tools/create-note.js'; import { AdvancedSearchTool } from './tools/advanced-search.js'; import { SearchByTagTool } from './tools/search-by-tag.js'; import { SearchByDateTool } from './tools/search-by-date.js'; import { GetVideosTool } from './tools/get-videos.js'; import { GetVideoTool } from './tools/get-video.js'; import { CreateVideoHighlightTool } from './tools/create-video-highlight.js'; import { GetVideoHighlightsTool } from './tools/get-video-highlights.js'; import { UpdateVideoPositionTool } from './tools/update-video-position.js'; import { GetVideoPositionTool } from './tools/get-video-position.js'; import { SaveDocumentTool } from './tools/save-document.js'; import { UpdateDocumentTool } from './tools/update-document.js'; import { DeleteDocumentTool } from './tools/delete-document.js'; import { GetRecentContentTool } from './tools/get-recent-content.js'; import { BulkSaveDocumentsTool } from './tools/bulk-save-documents.js'; import { BulkUpdateDocumentsTool } from './tools/bulk-update-documents.js'; import { BulkDeleteDocumentsTool } from './tools/bulk-delete-documents.js'; // Import prompts import { ReadwiseHighlightPrompt } from './prompts/highlight-prompt.js'; import { ReadwiseSearchPrompt } from './prompts/search-prompt.js'; // Import logger interface and create a simple console logger import type { Logger } from './utils/logger-interface.js'; // Import response converter utility import { toMCPResponse } from './utils/response.js'; // Simple console logger for Smithery import { LogLevel } from './utils/logger-interface.js'; const consoleLogger: Logger = { level: LogLevel.INFO, transport: console.log, timestamps: true, colors: false, debug: (message: string, context?: unknown) => console.log('[DEBUG]', message, context || ''), info: (message: string, context?: unknown) => console.log('[INFO]', message, context || ''), warn: (message: string, context?: unknown) => console.warn('[WARN]', message, context || ''), error: (message: string, context?: unknown) => console.error('[ERROR]', message, context || ''), }; // Configuration schema for Smithery export const configSchema = z.object({ readwiseApiKey: z.string().optional().describe("Your Readwise API access token. Get it from https://readwise.io/access_token"), debug: z.boolean().default(false).describe("Enable verbose debug logging to troubleshoot issues") }); // Zod parameter schemas for all tools // These provide parameter descriptions to Smithery for better UX const toolSchemas: Record<string, Record<string, z.ZodType>> = { // Core tools get_highlights: { book_id: z.string().optional().describe("Filter highlights by book ID"), page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page (1-100)"), search: z.string().optional().describe("Search term to filter highlights"), }, get_books: { page: z.number().optional().describe("Page number to retrieve"), page_size: z.number().optional().describe("Number of items per page (max 100)"), }, get_documents: { page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page"), }, search_highlights: { query: z.string().describe("The search query to find highlights"), limit: z.number().optional().describe("Maximum number of results to return"), }, get_tags: { // No parameters - returns all tags }, document_tags: { document_id: z.string().describe("The ID of the document"), operation: z.enum(['get', 'update', 'add', 'remove']).describe("The operation to perform"), tags: z.array(z.string()).optional().describe("Tags to set (for update operation)"), tag: z.string().optional().describe("Tag to add/remove (for add/remove operations)"), }, bulk_tags: { document_ids: z.array(z.string()).describe("IDs of the documents to tag"), tags: z.array(z.string()).describe("Tags to add to all specified documents"), replace_existing: z.boolean().optional().describe("Replace existing tags (true) or append (false)"), confirmation: z.string().describe('Must be "I confirm these tag changes" to proceed'), }, get_reading_progress: { document_id: z.string().describe("The ID of the document to get reading progress for"), }, update_reading_progress: { document_id: z.string().describe("The ID of the document to update"), status: z.enum(['not_started', 'in_progress', 'completed']).describe("Reading status"), percentage: z.number().optional().describe("Reading progress percentage (0-100)"), current_page: z.number().optional().describe("Current page number"), total_pages: z.number().optional().describe("Total number of pages"), last_read_at: z.string().optional().describe("Timestamp of when last read (ISO format)"), }, get_reading_list: { status: z.enum(['not_started', 'in_progress', 'completed']).optional().describe("Filter by reading status"), category: z.string().optional().describe("Filter by document category"), page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page"), }, // Highlight management create_highlight: { text: z.string().describe("The text to highlight"), book_id: z.string().describe("The ID of the book to create the highlight in"), note: z.string().optional().describe("Note to add to the highlight"), location: z.number().optional().describe("Location in the book (e.g. page number)"), location_type: z.string().optional().describe("Type of location (e.g. page, chapter)"), color: z.string().optional().describe("Color for the highlight"), tags: z.array(z.string()).optional().describe("Tags to add to the highlight"), }, update_highlight: { highlight_id: z.string().describe("The ID of the highlight to update"), text: z.string().optional().describe("New text for the highlight"), note: z.string().optional().describe("Note to add to the highlight"), location: z.number().optional().describe("Location in the book"), location_type: z.string().optional().describe("Type of location"), color: z.string().optional().describe("Color for the highlight"), tags: z.array(z.string()).optional().describe("Tags for the highlight"), }, delete_highlight: { highlight_id: z.string().describe("The ID of the highlight to delete"), confirmation: z.string().describe('Type "DELETE" to confirm deletion'), }, create_note: { highlight_id: z.string().describe("The ID of the highlight to add a note to"), note: z.string().describe("The note text to add"), }, // Search tools advanced_search: { query: z.string().optional().describe("Search query"), book_ids: z.array(z.string()).optional().describe("List of book IDs to filter by"), tags: z.array(z.string()).optional().describe("List of tags to filter by"), categories: z.array(z.string()).optional().describe("List of categories to filter by"), date_range: z.object({ start: z.string().optional().describe("Start date in ISO format"), end: z.string().optional().describe("End date in ISO format"), }).optional().describe("Date range filter"), location_range: z.object({ start: z.number().optional().describe("Start location"), end: z.number().optional().describe("End location"), }).optional().describe("Location range filter"), has_note: z.boolean().optional().describe("Filter highlights that have notes"), sort_by: z.enum(['created_at', 'updated_at', 'highlighted_at', 'location']).optional().describe("Field to sort by"), sort_order: z.enum(['asc', 'desc']).optional().describe("Sort order"), page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page"), }, search_by_tag: { tags: z.array(z.string()).describe("List of tags to search for"), match_all: z.boolean().optional().describe("Match all tags (AND) or any tag (OR)"), page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page"), }, search_by_date: { start_date: z.string().optional().describe("Start date in ISO format (e.g. 2024-01-01)"), end_date: z.string().optional().describe("End date in ISO format (e.g. 2024-12-31)"), date_field: z.enum(['created_at', 'updated_at', 'highlighted_at']).optional().describe("Which date field to search on"), page: z.number().optional().describe("Page number for pagination"), page_size: z.number().optional().describe("Number of results per page"), }, // Video tools get_videos: { limit: z.number().optional().describe("Maximum number of videos to return (1-100)"), pageCursor: z.string().optional().describe("Cursor for pagination"), tags: z.array(z.string()).optional().describe("Filter videos by tags"), platform: z.string().optional().describe("Filter videos by platform"), }, get_video: { document_id: z.string().describe("The Readwise document ID for the video"), }, create_video_highlight: { document_id: z.string().describe("The ID of the video"), text: z.string().describe("The text of the highlight"), timestamp: z.string().describe("Timestamp where the highlight occurs (e.g. 14:35)"), note: z.string().optional().describe("Note about the highlight"), }, get_video_highlights: { document_id: z.string().describe("The ID of the video"), }, update_video_position: { document_id: z.string().describe("The ID of the video"), position: z.number().describe("Current playback position in seconds"), duration: z.number().describe("Total duration of the video in seconds"), }, get_video_position: { document_id: z.string().describe("The ID of the video"), }, // Document management tools save_document: { url: z.string().describe("The URL of the content to save"), title: z.string().optional().describe("Title override for the document"), author: z.string().optional().describe("Author override for the document"), html: z.string().optional().describe("HTML content if not scraping from URL"), tags: z.array(z.string()).optional().describe("Tags to apply to the saved content"), summary: z.string().optional().describe("Summary of the content"), notes: z.string().optional().describe("Notes about the content"), location: z.enum(['new', 'later', 'archive', 'feed']).optional().describe("Where to save the content"), category: z.string().optional().describe("Category for the document (e.g. article, email)"), published_date: z.string().optional().describe("Published date in ISO 8601 format"), image_url: z.string().optional().describe("Cover image URL"), }, update_document: { document_id: z.string().describe("The ID of the document to update"), title: z.string().optional().describe("New title for the document"), author: z.string().optional().describe("New author for the document"), summary: z.string().optional().describe("New summary for the document"), published_date: z.string().optional().describe("New published date in ISO 8601 format"), image_url: z.string().optional().describe("New cover image URL"), location: z.enum(['new', 'later', 'archive', 'feed']).optional().describe("New location"), category: z.string().optional().describe("New category"), tags: z.array(z.string()).optional().describe("New tags for the document"), }, delete_document: { document_id: z.string().describe("The ID of the document to delete"), confirmation: z.string().describe('Type "I confirm deletion" to confirm'), }, get_recent_content: { limit: z.number().optional().describe("Number of recent items to retrieve (default: 10, max: 50)"), content_type: z.enum(['books', 'highlights', 'all']).optional().describe("Type of content to retrieve (default: all)"), }, // Bulk document operation tools bulk_save_documents: { items: z.array(z.object({ url: z.string().describe("The URL of the content to save"), title: z.string().optional().describe("Title override"), author: z.string().optional().describe("Author override"), html: z.string().optional().describe("HTML content"), tags: z.array(z.string()).optional().describe("Tags"), summary: z.string().optional().describe("Summary"), notes: z.string().optional().describe("Notes"), location: z.enum(['new', 'later', 'archive', 'feed']).optional().describe("Location"), })).describe("Array of documents to save"), confirmation: z.string().describe('Type "I confirm saving these items" to confirm'), }, bulk_update_documents: { updates: z.array(z.object({ document_id: z.string().describe("The ID of the document to update"), title: z.string().optional().describe("New title"), author: z.string().optional().describe("New author"), summary: z.string().optional().describe("New summary"), tags: z.array(z.string()).optional().describe("New tags"), location: z.enum(['new', 'later', 'archive', 'feed']).optional().describe("New location"), category: z.string().optional().describe("New category"), })).describe("Array of document updates"), confirmation: z.string().describe('Type "I confirm these updates" to confirm'), }, bulk_delete_documents: { document_ids: z.array(z.string()).describe("Array of document IDs to delete"), confirmation: z.string().describe('Type "I confirm deletion of these documents" to confirm'), }, }; // Zod parameter schemas for prompts const promptSchemas: Record<string, Record<string, z.ZodType>> = { readwise_highlight: { book_id: z.string().optional().describe("The ID of the book to get highlights from"), page: z.number().optional().describe("The page number of results to get"), page_size: z.number().optional().describe("The number of results per page (max 100)"), search: z.string().optional().describe("Search term to filter highlights"), context: z.string().optional().describe("Additional context to include in the prompt"), task: z.enum(['summarize', 'analyze', 'connect', 'question']).optional().describe("The task to perform with the highlights"), }, readwise_search: { query: z.string().describe("Search query to find highlights"), limit: z.number().optional().describe("Maximum number of results to return"), context: z.string().optional().describe("Additional context to include in the prompt"), }, }; // Export stateless flag for MCP (Smithery requirement) export const stateless = true; /** * Create and configure the Readwise MCP server * This is the default export that Smithery will call */ export default function ({ config }: { config: z.infer<typeof configSchema> }) { try { const apiKey = config.readwiseApiKey || ''; if (config.debug) { console.log('Starting Readwise MCP Server in debug mode'); console.log(`API key provided: ${apiKey ? 'Yes' : 'No (lazy loading enabled)'}`); } // Create API client (allow empty API key for lazy loading) const apiClient = new ReadwiseClient({ apiKey: apiKey || '', }); const api = new ReadwiseAPI(apiClient); // Create MCP server const server = new McpServer({ name: "readwise-mcp", title: "Readwise", version: "1.0.0", }); // Register all tools const tools = [ // Core tools new GetHighlightsTool(api, consoleLogger), new GetBooksTool(api, consoleLogger), new GetDocumentsTool(api, consoleLogger), new SearchHighlightsTool(api, consoleLogger), new GetTagsTool(api, consoleLogger), new DocumentTagsTool(api, consoleLogger), new BulkTagsTool(api, consoleLogger), new GetReadingProgressTool(api, consoleLogger), new UpdateReadingProgressTool(api, consoleLogger), new GetReadingListTool(api, consoleLogger), // Highlight management new CreateHighlightTool(api, consoleLogger), new UpdateHighlightTool(api, consoleLogger), new DeleteHighlightTool(api, consoleLogger), new CreateNoteTool(api, consoleLogger), // Search tools new AdvancedSearchTool(api, consoleLogger), new SearchByTagTool(api, consoleLogger), new SearchByDateTool(api, consoleLogger), // Video tools new GetVideosTool(api, consoleLogger), new GetVideoTool(api, consoleLogger), new CreateVideoHighlightTool(api, consoleLogger), new GetVideoHighlightsTool(api, consoleLogger), new UpdateVideoPositionTool(api, consoleLogger), new GetVideoPositionTool(api, consoleLogger), // Document management tools new SaveDocumentTool(api, consoleLogger), new UpdateDocumentTool(api, consoleLogger), new DeleteDocumentTool(api, consoleLogger), new GetRecentContentTool(api, consoleLogger), // Bulk document operation tools new BulkSaveDocumentsTool(api, consoleLogger), new BulkUpdateDocumentsTool(api, consoleLogger), new BulkDeleteDocumentsTool(api, consoleLogger), ]; // Register each tool with the server using server.tool() method for (const tool of tools) { try { server.tool( tool.name, tool.description, toolSchemas[tool.name] || {}, // Use Zod schemas for parameter descriptions { readOnlyHint: true, // Most Readwise operations are read-only destructiveHint: false, idempotentHint: true }, async (args: any) => { try { const toolResult = await tool.execute(args || {}); // Extract the actual result from MCPToolResult wrapper const actualResult = toolResult && typeof toolResult === 'object' && 'result' in toolResult ? (toolResult as any).result : toolResult; // Convert to MCP content format (like Exa does) // Tools must return { content: [{ type: "text", text: "..." }] } format const mcpResponse = toMCPResponse(actualResult); // Return just the content format expected by SDK return { content: mcpResponse.content.map(item => { // Ensure type is exactly what SDK expects if (item.type === 'text') { return { type: 'text' as const, text: item.text || '' }; } // For other types, return as-is (cast to satisfy type checker) return item as any; }) }; } catch (error) { if (config.debug) { console.error(`Error executing tool ${tool.name}:`, error); } // Return error in MCP format return { content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }] }; } } ); } catch (toolError) { throw toolError; } } // Register MCP Resources // These provide data access to LLM clients server.resource( "books", "readwise://books", { description: "List of books in your Readwise library", mimeType: "application/json" }, async () => { try { const books = await api.getBooks({ page_size: 50 }); return { contents: [{ uri: "readwise://books", mimeType: "application/json", text: JSON.stringify(books.results.map(b => ({ id: b.id, title: b.title, author: b.author, category: b.category, highlights_count: b.highlights_count })), null, 2) }] }; } catch (error) { return { contents: [{ uri: "readwise://books", mimeType: "text/plain", text: `Error fetching books: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); server.resource( "recent-highlights", "readwise://highlights/recent", { description: "Recent highlights from your Readwise library", mimeType: "application/json" }, async () => { try { const highlights = await api.getHighlights({ page_size: 20 }); return { contents: [{ uri: "readwise://highlights/recent", mimeType: "application/json", text: JSON.stringify(highlights.results.map(h => ({ id: h.id, text: h.text, note: h.note, book_id: h.book_id, created_at: h.created_at })), null, 2) }] }; } catch (error) { return { contents: [{ uri: "readwise://highlights/recent", mimeType: "text/plain", text: `Error fetching highlights: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); server.resource( "tags", "readwise://tags", { description: "List of all tags in your Readwise library", mimeType: "application/json" }, async () => { try { const tags = await api.getTags(); return { contents: [{ uri: "readwise://tags", mimeType: "application/json", text: JSON.stringify(tags, null, 2) }] }; } catch (error) { return { contents: [{ uri: "readwise://tags", mimeType: "text/plain", text: `Error fetching tags: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // Register prompts using server.prompt() method const highlightPrompt = new ReadwiseHighlightPrompt(api, consoleLogger); const searchPrompt = new ReadwiseSearchPrompt(api, consoleLogger); // Register highlight prompt with Zod schemas for parameter descriptions server.prompt( highlightPrompt.name, highlightPrompt.description, promptSchemas[highlightPrompt.name] || {}, async (args: any) => { const result = await highlightPrompt.execute(args || {}); // Convert MCPResponse to prompt format with messages array // Extract text from content array (usually first item) const firstContent = result.content && result.content.length > 0 ? result.content[0] : null; // Ensure we have a text content item const textContent = firstContent && firstContent.type === 'text' && firstContent.text ? { type: 'text' as const, text: firstContent.text || '' } : { type: 'text' as const, text: '' }; return { messages: [ { role: 'user' as const, content: textContent } ] }; } ); // Register search prompt with Zod schemas for parameter descriptions server.prompt( searchPrompt.name, searchPrompt.description, promptSchemas[searchPrompt.name] || {}, async (args: any) => { const result = await searchPrompt.execute(args || {}); // Convert MCPResponse to prompt format with messages array // Extract text from content array (usually first item) const firstContent = result.content && result.content.length > 0 ? result.content[0] : null; // Ensure we have a text content item const textContent = firstContent && firstContent.type === 'text' && firstContent.text ? { type: 'text' as const, text: firstContent.text || '' } : { type: 'text' as const, text: '' }; return { messages: [ { role: 'user' as const, content: textContent } ] }; } ); if (config.debug) { console.log(`Registered ${tools.length} tools and 2 prompts`); } // Return the server object (Smithery handles transport) return server.server; } catch (error) { console.error(`Server initialization error: ${error instanceof Error ? error.message : String(error)}`); throw error; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/IAmAlexander/readwise-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server