Skip to main content
Glama

Readwise Reader MCP Server

by edricgsh
readwise-client.ts9.48 kB
import { ReadwiseDocument, CreateDocumentRequest, UpdateDocumentRequest, ListDocumentsParams, ListDocumentsResponse, ReadwiseTag, ReadwiseConfig, APIResponse, APIMessage } from './types.js'; export class ReadwiseClient { private readonly baseUrl = 'https://readwise.io/api/v3'; private readonly authUrl = 'https://readwise.io/api/v2/auth/'; private readonly token: string; constructor(config: ReadwiseConfig) { this.token = config.token; } private async makeRequest<T>( endpoint: string, options: RequestInit = {} ): Promise<T> { const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'Authorization': `Token ${this.token}`, 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; throw new Error(`RATE_LIMIT:${retryAfterSeconds}`); } const errorText = await response.text(); throw new Error(`Readwise API error: ${response.status} ${response.statusText} - ${errorText}`); } return response.json(); } private createResponse<T>(data: T, messages?: APIMessage[]): APIResponse<T> { return { data, messages }; } private createInfoMessage(content: string): APIMessage { return { type: 'info', content }; } private createErrorMessage(content: string): APIMessage { return { type: 'error', content }; } async validateAuth(): Promise<APIResponse<{ detail: string }>> { try { const result = await this.makeRequest<{ detail: string }>(this.authUrl); return this.createResponse(result); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async createDocument(data: CreateDocumentRequest): Promise<APIResponse<ReadwiseDocument>> { try { const result = await this.makeRequest<ReadwiseDocument>('/save/', { method: 'POST', body: JSON.stringify(data), }); return this.createResponse(result); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async listDocuments(params: ListDocumentsParams = {}): Promise<APIResponse<ListDocumentsResponse>> { try { // If withFullContent is requested, first check the document count if (params.withFullContent) { const countParams = { ...params }; delete countParams.withFullContent; delete countParams.withHtmlContent; // Also remove HTML content for the count check const countSearchParams = new URLSearchParams(); Object.entries(countParams).forEach(([key, value]) => { if (value !== undefined) { countSearchParams.append(key, String(value)); } }); const countQuery = countSearchParams.toString(); const countEndpoint = `/list/${countQuery ? `?${countQuery}` : ''}`; const countResponse = await this.makeRequest<ListDocumentsResponse>(countEndpoint); if (countResponse.count > 5) { // Get first 5 documents with full content const limitedParams = { ...params, limit: 5 }; const searchParams = new URLSearchParams(); Object.entries(limitedParams).forEach(([key, value]) => { if (value !== undefined) { searchParams.append(key, String(value)); } }); const query = searchParams.toString(); const endpoint = `/list/${query ? `?${query}` : ''}`; const result = await this.makeRequest<ListDocumentsResponse>(endpoint); let message: APIMessage; if (countResponse.count <= 20) { message = this.createInfoMessage( `Found ${countResponse.count} documents, but only returning the first 5 due to full content request. ` + `To get the remaining ${countResponse.count - 5} documents with full content, ` + `you can fetch them individually by their IDs using the update/read document API.` ); } else { message = this.createErrorMessage( `Found ${countResponse.count} documents, but only returning the first 5 due to full content request. ` + `Getting full content for more than 20 documents is not supported due to performance limitations.` ); } return this.createResponse(result, [message]); } } const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { searchParams.append(key, String(value)); } }); const query = searchParams.toString(); const endpoint = `/list/${query ? `?${query}` : ''}`; const result = await this.makeRequest<ListDocumentsResponse>(endpoint); return this.createResponse(result); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async updateDocument(id: string, data: UpdateDocumentRequest): Promise<APIResponse<ReadwiseDocument>> { try { const result = await this.makeRequest<ReadwiseDocument>(`/update/${id}/`, { method: 'PATCH', body: JSON.stringify(data), }); return this.createResponse(result); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async deleteDocument(id: string): Promise<APIResponse<void>> { try { await this.makeRequest(`/delete/${id}/`, { method: 'DELETE', }); return this.createResponse(undefined); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async listTags(): Promise<APIResponse<ReadwiseTag[]>> { try { const result = await this.makeRequest<ReadwiseTag[]>('/tags/'); return this.createResponse(result); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } async searchDocumentsByTopic(searchTerms: string[]): Promise<APIResponse<ReadwiseDocument[]>> { try { // Fetch all documents without full content for performance const allDocuments: ReadwiseDocument[] = []; let nextPageCursor: string | undefined; do { const params: ListDocumentsParams = { withFullContent: false, withHtmlContent: false, }; if (nextPageCursor) { params.pageCursor = nextPageCursor; } const response = await this.listDocuments(params); allDocuments.push(...response.data.results); nextPageCursor = response.data.nextPageCursor; } while (nextPageCursor); // Create regex patterns from search terms (case-insensitive) const regexPatterns = searchTerms.map(term => new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') ); // Filter documents that match any of the search terms const matchingDocuments = allDocuments.filter(doc => { // Extract searchable text fields const searchableFields = [ doc.title || '', doc.summary || '', doc.notes || '', // Handle tags - they can be string array or object Array.isArray(doc.tags) ? doc.tags.join(' ') : '', ]; const searchableText = searchableFields.join(' ').toLowerCase(); // Check if any regex pattern matches return regexPatterns.some(pattern => pattern.test(searchableText)); }); return this.createResponse(matchingDocuments); } catch (error) { if (error instanceof Error && error.message.startsWith('RATE_LIMIT:')) { const seconds = parseInt(error.message.split(':')[1], 10); throw new Error(`Rate limit exceeded. Too many requests. Please retry after ${seconds} seconds.`); } throw error; } } }

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/edricgsh/Readwise-Reader-MCP'

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