Skip to main content
Glama

Readwise MCP Server

by IAmAlexander
server.ts26.5 kB
// Third-party imports import express from 'express'; import type { Express, Request, Response } from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; import { createServer } from 'http'; import type { Server as HttpServer } from 'http'; // MCP SDK imports - need .js extension for runtime imports import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; // Local type imports - no .js extension import type { MCPRequest, MCPResponse, ErrorResponse, ErrorType, TransportType } from './types/index.js'; import type { ValidationResult, ValidationError } from './types/validation.js'; // Local implementation imports - need .js extension import { ReadwiseClient } from './api/client.js'; import { ReadwiseAPI } from './api/readwise-api.js'; import { BaseMCPTool } from './mcp/registry/base-tool.js'; import { BaseMCPPrompt } from './mcp/registry/base-prompt.js'; import { ToolRegistry } from './mcp/registry/tool-registry.js'; import { PromptRegistry } from './mcp/registry/prompt-registry.js'; import type { Logger } from './utils/logger-interface.js'; import { getConfig } from './utils/config.js'; // Tool imports - need .js extension import { GetBooksTool } from './tools/get-books.js'; import { GetHighlightsTool } from './tools/get-highlights.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'; // Prompt imports - need .js extension import { ReadwiseHighlightPrompt } from './prompts/highlight-prompt.js'; import { ReadwiseSearchPrompt } from './prompts/search-prompt.js'; /** * Readwise MCP Server implementation */ export class ReadwiseMCPServer { private app: Express; private server: HttpServer; private mcpServer: MCPServer; private port: number; private apiClient: ReadwiseClient; private api: ReadwiseAPI; private toolRegistry: ToolRegistry; private promptRegistry: PromptRegistry; private logger: Logger; private transportType: TransportType; private startTime: number; /** * Create a new Readwise MCP server * @param apiKey - Readwise API key * @param port - Port to listen on (default: 3000) * @param logger - Logger instance * @param transport - Transport type (default: stdio) */ constructor( apiKey: string, port: number = 3000, logger: Logger, transport: TransportType = 'stdio', baseUrl?: string ) { // Check if running under MCP Inspector const isMCPInspector = process.env.MCP_INSPECTOR === 'true' || process.argv.includes('--mcp-inspector') || process.env.NODE_ENV === 'mcp-inspector'; // When running under inspector: // - Use port 3000 (required for inspector's proxy) // - Force SSE transport this.port = isMCPInspector ? 3000 : port; this.transportType = isMCPInspector ? 'sse' : transport; this.logger = logger; this.startTime = Date.now(); // Initialize API client this.apiClient = new ReadwiseClient({ apiKey, baseUrl }); this.api = new ReadwiseAPI(this.apiClient); // Initialize registries this.toolRegistry = new ToolRegistry(this.logger); this.promptRegistry = new PromptRegistry(this.logger); // Initialize Express app this.app = express(); this.app.use(bodyParser.json()); this.app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true })); this.server = createServer(this.app); // Initialize MCP Server this.mcpServer = new MCPServer({ name: "readwise-mcp", version: "1.0.0" }, { capabilities: { tools: this.toolRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}), prompts: this.promptRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}) } }); // Register tools this.registerTools(); // Register prompts this.registerPrompts(); } /** * Register MCP tools */ private registerTools(): void { this.logger.debug('Registering tools'); // Create tool instances const getHighlightsTool = new GetHighlightsTool(this.api, this.logger); const getBooksTool = new GetBooksTool(this.api, this.logger); const getDocumentsTool = new GetDocumentsTool(this.api, this.logger); const searchHighlightsTool = new SearchHighlightsTool(this.api, this.logger); const getTagsTool = new GetTagsTool(this.api, this.logger); const documentTagsTool = new DocumentTagsTool(this.api, this.logger); const bulkTagsTool = new BulkTagsTool(this.api, this.logger); const getReadingProgressTool = new GetReadingProgressTool(this.api, this.logger); const updateReadingProgressTool = new UpdateReadingProgressTool(this.api, this.logger); const getReadingListTool = new GetReadingListTool(this.api, this.logger); const createHighlightTool = new CreateHighlightTool(this.api, this.logger); const updateHighlightTool = new UpdateHighlightTool(this.api, this.logger); const deleteHighlightTool = new DeleteHighlightTool(this.api, this.logger); const createNoteTool = new CreateNoteTool(this.api, this.logger); const advancedSearchTool = new AdvancedSearchTool(this.api, this.logger); const searchByTagTool = new SearchByTagTool(this.api, this.logger); const searchByDateTool = new SearchByDateTool(this.api, this.logger); // Video tools const getVideosTool = new GetVideosTool(this.api, this.logger); const getVideoTool = new GetVideoTool(this.api, this.logger); const createVideoHighlightTool = new CreateVideoHighlightTool(this.api, this.logger); const getVideoHighlightsTool = new GetVideoHighlightsTool(this.api, this.logger); const updateVideoPositionTool = new UpdateVideoPositionTool(this.api, this.logger); const getVideoPositionTool = new GetVideoPositionTool(this.api, this.logger); // Register tools this.toolRegistry.register(getHighlightsTool); this.toolRegistry.register(getBooksTool); this.toolRegistry.register(getDocumentsTool); this.toolRegistry.register(searchHighlightsTool); this.toolRegistry.register(getTagsTool); this.toolRegistry.register(documentTagsTool); this.toolRegistry.register(bulkTagsTool); this.toolRegistry.register(getReadingProgressTool); this.toolRegistry.register(updateReadingProgressTool); this.toolRegistry.register(getReadingListTool); this.toolRegistry.register(createHighlightTool); this.toolRegistry.register(updateHighlightTool); this.toolRegistry.register(deleteHighlightTool); this.toolRegistry.register(createNoteTool); this.toolRegistry.register(advancedSearchTool); this.toolRegistry.register(searchByTagTool); this.toolRegistry.register(searchByDateTool); this.toolRegistry.register(getVideosTool); this.toolRegistry.register(getVideoTool); this.toolRegistry.register(createVideoHighlightTool); this.toolRegistry.register(getVideoHighlightsTool); this.toolRegistry.register(updateVideoPositionTool); this.toolRegistry.register(getVideoPositionTool); this.logger.info(`Registered ${this.toolRegistry.getNames().length} tools`); } /** * Register MCP prompts */ private registerPrompts(): void { this.logger.debug('Registering prompts'); // Create prompts const highlightPrompt = new ReadwiseHighlightPrompt(this.api, this.logger); const searchPrompt = new ReadwiseSearchPrompt(this.api, this.logger); // Register prompts this.promptRegistry.register(highlightPrompt); this.promptRegistry.register(searchPrompt); this.logger.info(`Registered ${this.promptRegistry.getNames().length} prompts`); } /** * Start the server */ async start(): Promise<void> { return new Promise<void>((resolve) => { this.logger.debug('Starting HTTP server...'); // Start the HTTP server this.server.listen(this.port, () => { this.logger.info(`Server started on port ${this.port} with ${this.transportType} transport`); this.logger.info(`Startup time: ${Date.now() - this.startTime}ms`); this.logger.debug('Setting up routes...'); // Add routes this.setupRoutes(); this.logger.debug('Routes configured'); // If using stdio transport, set up stdin handler if (this.transportType === 'stdio') { this.logger.debug('Setting up stdio transport...'); this.setupStdioTransport(); this.logger.debug('Stdio transport configured'); } else if (this.transportType === 'sse') { this.logger.debug('Setting up SSE transport...'); this.setupSSETransport(); this.logger.debug('SSE transport configured'); } this.logger.info('Server initialization complete'); resolve(); }); }); } /** * Stop the server */ async stop(): Promise<void> { return new Promise<void>((resolve, reject) => { this.server.close((err) => { if (err) { this.logger.error('Error stopping server', err); reject(err); } else { this.logger.info('Server stopped'); resolve(); } }); }); } /** * Set up routes for the server */ private setupRoutes(): void { this.logger.debug('Setting up routes'); // Health check endpoint this.app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', uptime: process.uptime(), transport: this.transportType, tools: this.toolRegistry.getNames(), prompts: this.promptRegistry.getNames() }); }); // Capabilities endpoint this.app.get('/capabilities', (_req: Request, res: Response) => { res.json({ version: '1.0.0', transports: ['sse'], tools: this.toolRegistry.getNames().map(name => { const tool = this.toolRegistry.get(name); return { name, description: tool?.description || '', parameters: tool?.parameters || {} }; }), prompts: this.promptRegistry.getNames().map(name => { const prompt = this.promptRegistry.get(name); return { name, description: prompt?.description || '', parameters: prompt?.parameters || {} }; }) }); }); } /** * Set up stdio transport */ private setupStdioTransport(): void { this.logger.debug('Setting up stdio transport'); process.stdin.setEncoding('utf8'); process.stdin.on('data', (data: Buffer) => { try { const input = data.toString().trim(); if (!input) return; // Parse the request const request = JSON.parse(input) as MCPRequest; // Handle the request this.handleMCPRequest(request, (response) => { // Write the response to stdout process.stdout.write(JSON.stringify(response) + '\n'); }); } catch (error) { this.logger.error('Error handling stdin data', error); // Write error response to stdout const errorResponse: ErrorResponse = { error: { type: 'transport' as ErrorType, details: { code: 'invalid_request', message: error instanceof Error ? error.message : 'Invalid request' } }, request_id: 'unknown' // Unknown request_id for parsing errors }; process.stdout.write(JSON.stringify(errorResponse) + '\n'); } }); this.logger.info('Listening for requests on stdin'); } /** * Validate that a request follows the MCP protocol format * @param request - The request to validate * @returns True if the request is valid, false otherwise */ private validateMCPRequest(request: any): { valid: boolean; error?: string } { // Check if request is an object if (!request || typeof request !== 'object') { return { valid: false, error: 'Request must be a JSON object' }; } // Check if request has required fields if (!('type' in request)) { return { valid: false, error: 'Missing required field: type' }; } if (!('name' in request)) { return { valid: false, error: 'Missing required field: name' }; } if (!('request_id' in request)) { return { valid: false, error: 'Missing required field: request_id' }; } // Validate request type if (request.type !== 'tool_call' && request.type !== 'prompt_call') { return { valid: false, error: `Invalid request type: ${request.type}. Must be 'tool_call' or 'prompt_call'` }; } // Validate request name if (typeof request.name !== 'string' || request.name.trim() === '') { return { valid: false, error: 'Invalid request name: must be a non-empty string' }; } // Validate request_id if (typeof request.request_id !== 'string' || request.request_id.trim() === '') { return { valid: false, error: 'Invalid request_id: must be a non-empty string' }; } // Validate parameters if (!('parameters' in request) || typeof request.parameters !== 'object') { return { valid: false, error: 'Missing or invalid parameters: must be an object' }; } return { valid: true }; } /** * Handle an MCP request * @param request - The MCP request * @param callback - Callback function to receive the response */ public handleMCPRequest(request: MCPRequest, callback: (response: MCPResponse | ErrorResponse) => void): void { // Validate the request format const validation = this.validateMCPRequest(request); if (!validation.valid) { this.logger.warn('Invalid MCP request format', { error: validation.error, request }); callback({ error: { type: 'transport', details: { code: 'invalid_request', message: validation.error || 'Invalid request format' } }, request_id: (request as any)?.request_id || 'unknown' }); return; } const requestType = (request as any).type; const requestName = (request as any).name; const requestId = (request as any).request_id; this.logger.debug('Handling MCP request', { type: requestType, name: requestName, request_id: requestId }); // Handle different request types if (requestType === 'tool_call') { this.handleToolCall(request as MCPRequest & { type: 'tool_call' }, callback); } else if (requestType === 'prompt_call') { this.handlePromptCall(request as MCPRequest & { type: 'prompt_call' }, callback); } else { this.logger.warn(`Unknown request type: ${requestType}`); // Return error callback({ error: { type: 'transport', details: { code: 'invalid_request_type', message: `Unknown request type: ${requestType}` } }, request_id: requestId }); } } /** * Handle a tool call * @param request - The tool call request * @param callback - Callback function to receive the response */ private handleToolCall( request: MCPRequest & { type: 'tool_call' }, callback: (response: MCPResponse | ErrorResponse) => void ): void { const { name, parameters, request_id } = request; // Get the tool const tool = this.toolRegistry.get(name); if (!tool) { this.logger.warn(`Tool not found: ${name}`); // Return error callback({ error: { type: 'transport', details: { code: 'tool_not_found', message: `Tool not found: ${name}` } }, request_id }); return; } // Validate parameters const validationResult = tool.validate ? tool.validate(parameters) : { valid: true, success: true, errors: [] }; if (!validationResult.valid) { this.logger.warn(`Invalid parameters for tool ${name}`, { errors: validationResult.errors } as any); const errorResponse: ErrorResponse = { error: { type: 'validation' as ErrorType, details: { code: 'invalid_parameters', message: 'Invalid parameters', errors: validationResult.errors.map(e => `${e.field}: ${e.message}`) } }, request_id: request.request_id }; callback(errorResponse); return; } // Execute the tool (typeof (tool as any).executeAsMCP === 'function' ? (tool as any).executeAsMCP(parameters) : tool.execute(parameters)) .then((result: any) => { this.logger.debug(`Tool ${name} execution successful`); const response: MCPResponse = (result as any).content ? (result as any) : { content: [{ type: 'text', text: JSON.stringify(result) }] }; callback({ ...response, request_id: request.request_id }); }) .catch((error: unknown) => { this.logger.error(`Tool ${name} execution error`, error as any); const errorResponse: ErrorResponse = { error: { type: 'execution' as ErrorType, details: { code: 'tool_error', message: error instanceof Error ? error.message : 'Unknown error' } }, request_id: request.request_id }; callback(errorResponse); }); } /** * Handle a prompt call * @param request - The prompt call request * @param callback - Callback function to receive the response */ private handlePromptCall( request: MCPRequest & { type: 'prompt_call' }, callback: (response: MCPResponse | ErrorResponse) => void ): void { const { name, parameters, request_id } = request; // Get the prompt const prompt = this.promptRegistry.get(name); if (!prompt) { this.logger.warn(`Prompt not found: ${name}`); // Return error callback({ error: { type: 'transport', details: { code: 'prompt_not_found', message: `Prompt not found: ${name}` } }, request_id }); return; } // Validate parameters const validationResult = prompt.validate ? prompt.validate(parameters) : { valid: true, success: true, errors: [] }; if (!validationResult.valid) { this.logger.warn(`Invalid parameters for prompt ${name}`, { errors: validationResult.errors } as any); const errorResponse: ErrorResponse = { error: { type: 'validation' as ErrorType, details: { code: 'invalid_parameters', message: 'Invalid parameters', errors: validationResult.errors.map(e => `${e.field}: ${e.message}`) } }, request_id: request.request_id }; callback(errorResponse); return; } // Execute the prompt prompt.execute(parameters) .then((result: any) => { this.logger.debug(`Prompt ${name} execution successful`); const response: MCPResponse = (result as any).content ? (result as any) : { content: [{ type: 'text', text: JSON.stringify(result) }] }; callback({ ...response, request_id: request.request_id }); }) .catch((error: unknown) => { this.logger.error(`Prompt ${name} execution error`, error as any); const errorResponse: ErrorResponse = { error: { type: 'execution' as ErrorType, details: { code: 'prompt_error', message: error instanceof Error ? error.message : 'Unknown error' } }, request_id: request.request_id }; callback(errorResponse); }); } /** * Set up SSE transport */ private setupSSETransport(): void { this.logger.debug('Setting up SSE transport'); // SSE endpoint for server-to-client streaming this.app.get('/sse', async (req: Request, res: Response) => { try { this.logger.debug('New SSE connection request', { query: req.query, headers: req.headers }); // Set SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); res.flushHeaders(); // Create transport instance for this connection const transport = new SSEServerTransport('/sse', res); // Set up transport handlers transport.onmessage = async (message) => { this.logger.debug('Received message:', message); if (message && typeof message === 'object' && 'method' in message && 'id' in message) { // Convert JSON-RPC to MCP request const mcpRequest: MCPRequest = { type: 'tool_call', name: message.method, parameters: message.params || {}, request_id: String(message.id) }; // Handle the message through MCP server this.handleMCPRequest(mcpRequest, async (response) => { // Convert MCP response to JSON-RPC const jsonRpcResponse = { jsonrpc: '2.0' as const, id: message.id, ...(('error' in response) ? { error: { code: -32000, message: response.error.details.message, data: response.error } } : { result: response.result } ) }; await transport.send(jsonRpcResponse); }); } }; transport.onerror = (error) => { this.logger.error('Transport error:', error); if (!res.writableEnded) { res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`); } }; transport.onclose = () => { this.logger.debug('Transport closed'); if (!res.writableEnded) { res.write('event: close\ndata: {}\n\n'); res.end(); } }; // Start the transport and connect to MCP server await transport.start(); await this.mcpServer.connect(transport); this.logger.info('SSE transport connected to MCP server'); // Send initial connection event with capabilities const connectionEvent = { jsonrpc: '2.0', method: 'connection_established', params: { server_info: { name: 'readwise-mcp', version: '1.0.0', capabilities: { transports: ['sse'], tools: this.toolRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}), prompts: this.promptRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}) } } } }; res.write(`data: ${JSON.stringify(connectionEvent)}\n\n`); // Handle client disconnect req.on('close', () => { this.logger.debug('Client disconnected'); transport.close().catch(err => { this.logger.error('Error closing transport:', err); }); }); // Keep connection alive with heartbeats const keepAliveInterval = setInterval(() => { if (!res.writableEnded) { res.write('event: ping\ndata: {}\n\n'); } }, 30000); // Clean up interval on disconnect req.on('close', () => { clearInterval(keepAliveInterval); }); } catch (error) { this.logger.error('Error in SSE endpoint:', error); // Only send error response if headers haven't been sent if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: error instanceof Error ? error.message : String(error) } }); } } }); // Message handling endpoint for client-to-server communication this.app.post('/messages', express.json(), async (req: Request, res: Response) => { try { const transport = new SSEServerTransport('/messages', res); await transport.handlePostMessage(req, res); } catch (error) { this.logger.error('Error handling message:', error); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error', data: error instanceof Error ? error.message : String(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/IAmAlexander/readwise-mcp'

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