Skip to main content
Glama

Calibre MCP Server

by ispyridis
server.js24.1 kB
#!/usr/bin/env node /** * Calibre MCP Server - Node.js Version * A Windows-compatible MCP server for searching and reading Calibre ebook library */ const { spawn, exec } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); // Configuration const CONFIG = { // Default Calibre library path for Windows CALIBRE_LIBRARY: 'D:\\e-library', // Calibre executable paths (try common locations) CALIBRE_PATHS: [ 'calibredb', // If in PATH path.join('C:', 'Program Files', 'Calibre2', 'calibredb.exe'), path.join('C:', 'Program Files (x86)', 'Calibre2', 'calibredb.exe'), path.join(os.homedir(), 'AppData', 'Local', 'calibre-ebook', 'calibredb.exe') ], LOG_FILE: path.join(os.tmpdir(), 'calibre-mcp-requests.log'), TIMEOUT: 10000 // 10 seconds }; class CalibreMCPServer { constructor() { this.calibredbPath = null; this.initializeLogger(); this.findCalibreDB(); } initializeLogger() { try { fs.writeFileSync(CONFIG.LOG_FILE, ''); } catch (error) { // Ignore if can't create log file } } log(message) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}\n`; try { fs.appendFileSync(CONFIG.LOG_FILE, logMessage); } catch (error) { // Ignore logging errors } // Also log to stderr for debugging console.error(logMessage.trim()); } findCalibreDB() { for (const calibrePath of CONFIG.CALIBRE_PATHS) { if (this.testCalibreDB(calibrePath)) { this.calibredbPath = calibrePath; this.log(`Found calibredb at: ${calibrePath}`); return; } } this.log('Warning: calibredb not found in standard locations'); this.calibredbPath = 'calibredb'; // Hope it's in PATH } testCalibreDB(calibrePath) { try { const { execSync } = require('child_process'); // Test with --version (no library path needed) execSync(`"${calibrePath}" --version`, { timeout: 5000, stdio: 'ignore', windowsHide: true }); return true; } catch (error) { return false; } } async runCalibreCommand(args, timeout = CONFIG.TIMEOUT) { return new Promise((resolve, reject) => { const command = this.calibredbPath; // Only add library path for commands that need it (not --version) const needsLibraryPath = !args.includes('--version'); const fullArgs = needsLibraryPath ? ['--library-path', CONFIG.CALIBRE_LIBRARY, ...args] : args; this.log(`Running: ${command} ${fullArgs.join(' ')}`); const child = spawn(command, fullArgs, { stdio: 'pipe', windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stderr.on('data', (data) => { stderr += data.toString(); }); const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('Command timeout')); }, timeout); child.on('close', (code) => { clearTimeout(timer); if (code === 0) { // Filter out Calibre warning messages const filteredOutput = stdout .split('\n') .filter(line => !line.includes('Another calibre program')) .join('\n'); resolve(filteredOutput); } else { reject(new Error(`Command failed with code ${code}: ${stderr}`)); } }); child.on('error', (error) => { clearTimeout(timer); reject(error); }); }); } createEpubUrl(author, title, id, startLine = '', endLine = '') { const encAuthor = encodeURIComponent(author); const encTitle = encodeURIComponent(title); let url = `epub://${encAuthor}/${encTitle}@${id}`; if (startLine && endLine) { url += `#${startLine}:${endLine}`; } return url; } parseEpubUrl(url) { // Remove epub:// prefix url = url.replace(/^epub:\/\//, ''); // Extract book ID const idMatch = url.match(/@(\d+)/); if (!idMatch) { throw new Error('Invalid epub URL format'); } const bookId = idMatch[1]; // Extract line range (optional) let startLine = ''; let endLine = ''; const rangeMatch = url.match(/#(\d+):(\d+)$/); if (rangeMatch) { startLine = rangeMatch[1]; endLine = rangeMatch[2]; } return { bookId, startLine, endLine }; } async searchBooksMetadata(query, limit = 50) { try { // Get book IDs from search const searchResult = await this.runCalibreCommand(['search', '--limit', limit.toString(), query]); if (!searchResult.trim()) { return []; } // Convert comma-separated IDs to search query const bookIds = searchResult.trim(); const idQuery = `id:${bookIds.replace(/,/g, ' OR id:')}`; // Get detailed metadata const listResult = await this.runCalibreCommand([ 'list', '--fields', 'id,title,authors,series,tags,publisher,pubdate,formats,identifiers,comments', '--for-machine', '--search', idQuery ]); const books = JSON.parse(listResult || '[]'); // Process each book to add epub URLs and text availability return books.map(book => ({ id: book.id, title: book.title, authors: book.authors, series: book.series, tags: book.tags, publisher: book.publisher, published: book.pubdate, epub_url: this.createEpubUrl(book.authors, book.title, book.id), formats: book.formats ? book.formats.map(f => path.basename(f)) : [], full_formats: book.formats || [], has_text: book.formats ? book.formats.some(f => f.endsWith('.txt')) : false, description: book.comments ? book.comments.replace(/<[^>]+>/g, '').split('\n').slice(0, 2).join(' ').substring(0, 200) + '...' : null })); } catch (error) { this.log(`Metadata search failed: ${error.message}`); return []; } } async searchBooksFulltext(query, limit = 50) { try { // Run FTS search const ftsResult = await this.runCalibreCommand([ 'fts_search', '--output-format', 'json', '--do-not-match-on-related-words', query ]); const ftsResults = JSON.parse(ftsResult || '[]').slice(0, 20); if (ftsResults.length === 0) { this.log('No FTS results found'); return []; } this.log(`Found ${ftsResults.length} FTS results`); // Calculate balanced limits const sqrtLimit = Math.floor(Math.sqrt(limit) + 0.5); // Get unique book IDs const uniqueBookIds = [...new Set(ftsResults.map(r => r.book_id))].slice(0, sqrtLimit); // Get book metadata const idQuery = `id:${uniqueBookIds.join(' OR id:')}`; const listResult = await this.runCalibreCommand([ 'list', '--fields', 'id,title,authors,formats', '--for-machine', '--search', idQuery ]); const books = JSON.parse(listResult || '[]'); // Search for actual content in text files const results = []; const grepPattern = query.split(' ').join('|'); for (const book of books) { if (results.length >= limit) break; const txtPath = book.formats?.find(f => f.endsWith('.txt')); if (!txtPath || !fs.existsSync(txtPath)) continue; const bookMatches = await this.searchInTextFile( txtPath, grepPattern, Math.min(sqrtLimit, limit - results.length) ); for (const match of bookMatches) { const contextStart = Math.max(1, match.lineNum - 5); const contextEnd = match.lineNum + 5; results.push({ id: book.id, title: book.title, authors: book.authors, text: match.text, url: this.createEpubUrl(book.authors, book.title, book.id, contextStart, contextEnd), line_number: match.lineNum }); } } this.log(`Created ${results.length} final results`); return results; } catch (error) { this.log(`Full-text search failed: ${error.message}`); return []; } } async searchInTextFile(filePath, pattern, maxMatches) { return new Promise((resolve) => { try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); const matches = []; const regex = new RegExp(pattern, 'gi'); for (let i = 0; i < lines.length && matches.length < maxMatches; i++) { if (regex.test(lines[i])) { matches.push({ lineNum: i + 1, text: lines[i] }); } } resolve(matches); } catch (error) { resolve([]); } }); } parseHybridQuery(query) { const words = query.split(' '); const metadataFilters = []; const contentTerms = []; for (const word of words) { if (/^(author|title|tag|series|publisher|format|date|pubdate|rating|comments|identifiers):/.test(word)) { metadataFilters.push(word); } else { contentTerms.push(word); } } return { metadataFilters: metadataFilters.join(' '), contentTerms: contentTerms.join(' '), hasMetadata: metadataFilters.length > 0, hasContent: contentTerms.length > 0 }; } async searchUnified(query, limit = 50) { const parsed = this.parseHybridQuery(query); if (parsed.hasMetadata && parsed.hasContent) { // Hybrid search - not implemented in this version this.log('Hybrid search requested, falling back to metadata search'); return await this.searchBooksMetadata(query, limit); } else if (parsed.hasMetadata) { this.log(`Using metadata search for: ${query}`); return await this.searchBooksMetadata(query, limit); } else { this.log(`Using full-text search for: ${query}`); return await this.searchBooksFulltext(query, limit); } } async fetchByEpubUrl(url) { try { const { bookId, startLine, endLine } = this.parseEpubUrl(url); // Get book metadata const listResult = await this.runCalibreCommand([ 'list', '--fields', 'id,title,authors,formats', '--for-machine', '--search', `id:${bookId}` ]); const books = JSON.parse(listResult || '[]'); if (books.length === 0) { throw new Error('Book not found'); } const book = books[0]; const txtPath = book.formats?.find(f => f.endsWith('.txt')); if (!txtPath || !fs.existsSync(txtPath)) { throw new Error('No text format available for this book'); } // Extract content let content; if (startLine && endLine) { content = this.extractLineRange(txtPath, parseInt(startLine), parseInt(endLine)); } else { content = this.extractParagraphContext(txtPath, 1, 5); } return { book_id: book.id, title: book.title, authors: book.authors, content: content, url: url, line_range: { start: startLine ? parseInt(startLine) : null, end: endLine ? parseInt(endLine) : null } }; } catch (error) { throw error; } } extractLineRange(filePath, startLine, endLine) { try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); return lines.slice(startLine - 1, endLine).join('\n'); } catch (error) { return ''; } } extractParagraphContext(filePath, targetLine, contextParagraphs) { try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); return lines.slice(0, 50).join('\n'); // First 50 lines as default } catch (error) { return ''; } } formatDualResponse(searchResults, query, searchType = 'search') { const count = searchResults.length; if (count === 0) { return { content: [{ type: 'text', text: `No results found for: ${query}` }], results: [] }; } let contentText; let openaiResults; if (searchType === 'fulltext' || searchType === 'hybrid') { contentText = `Found ${count} content match(es) for '${query}':\n\n` + searchResults.map(r => `• ${r.title} by ${r.authors}\n Match: ${(r.text || '').substring(0, 150)}...\n URL: ${r.url}\n` ).join('\n'); openaiResults = searchResults.map(r => ({ id: r.id.toString(), title: r.title, text: r.text, url: r.url })); } else { contentText = `Found ${count} book(s) matching '${query}':\n\n` + searchResults.map(r => `• ${r.title} by ${r.authors}\n URL: ${r.epub_url}\n ${r.description ? 'Description: ' + r.description : ''}\n` ).join('\n'); openaiResults = searchResults.map(r => ({ id: r.id.toString(), title: r.title, text: r.description || `${r.title} by ${r.authors}`, url: r.epub_url })); } return { content: [{ type: 'text', text: contentText }], results: openaiResults }; } sendResponse(response) { console.log(JSON.stringify(response)); this.log(`Response: ${JSON.stringify(response)}`); } sendError(id, code, message) { this.sendResponse({ jsonrpc: '2.0', id: id, error: { code: code, message: message } }); } sendSuccess(id, result) { this.sendResponse({ jsonrpc: '2.0', id: id, result: result }); } handleInitialize(id) { const response = { protocolVersion: '2024-11-05', serverInfo: { name: 'calibre-mcp-nodejs', version: '2.0.0', description: 'Calibre ebook library search and content access server (Node.js Windows version)' }, capabilities: { tools: {}, resources: {}, prompts: {} } }; this.sendSuccess(id, response); } handleToolsList(id) { const tools = [ { name: 'search', description: 'Search the Calibre ebook library. Supports both full-text content search (default) and metadata search using field syntax.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query. For full-text: use natural language. For metadata: use field syntax (author:Name, title:"Title").' }, limit: { type: 'integer', description: 'Maximum number of results (default: 50)', default: 50 }, fuzzy_fallback: { type: 'string', description: 'Alternative search terms if exact query fails' } }, required: ['query'] } }, { name: 'fetch', description: 'Fetch specific content from a book using epub:// URL', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'epub:// URL from search results' } }, required: ['url'] } } ]; this.sendSuccess(id, { tools: tools }); } async handleToolsCall(id, toolName, args) { try { switch (toolName) { case 'search': const query = args.query; const limit = args.limit || 50; if (!query) { this.sendError(id, -32602, 'Missing required parameter: query'); return; } const results = await this.searchUnified(query, limit); // Determine search type const parsed = this.parseHybridQuery(query); let searchType; if (parsed.hasMetadata && parsed.hasContent) { searchType = 'hybrid'; } else if (parsed.hasMetadata) { searchType = 'metadata'; } else { searchType = 'fulltext'; } const mcpResult = this.formatDualResponse(results, query, searchType); this.sendSuccess(id, mcpResult); break; case 'fetch': const url = args.url; if (!url) { this.sendError(id, -32602, 'Missing required parameter: url'); return; } try { const fetchResult = await this.fetchByEpubUrl(url); const contentText = `Content from '${fetchResult.title}' by ${fetchResult.authors}:\n\n${fetchResult.content}`; const mcpResult = { content: [{ type: 'text', text: contentText }], book_id: fetchResult.book_id, title: fetchResult.title, authors: fetchResult.authors, url: fetchResult.url }; this.sendSuccess(id, mcpResult); } catch (error) { this.sendError(id, -32603, error.message); } break; default: this.sendError(id, -32601, `Unknown tool: ${toolName}`); } } catch (error) { this.sendError(id, -32603, error.message); } } async processRequest(request) { this.log(`Request: ${JSON.stringify(request)}`); const { method, id, params } = request; switch (method) { case 'initialize': this.handleInitialize(id); break; case 'tools/list': this.handleToolsList(id); break; case 'tools/call': const toolName = params?.name; const args = params?.arguments || {}; await this.handleToolsCall(id, toolName, args); break; case 'notifications/initialized': // Ignore break; case 'resources/list': this.sendSuccess(id, { resources: [] }); break; case 'prompts/list': this.sendSuccess(id, { prompts: [] }); break; default: this.sendError(id, -32601, `Method not found: ${method}`); } } start() { this.log('Calibre MCP Server (Node.js) started'); process.stdin.setEncoding('utf8'); let buffer = ''; process.stdin.on('data', (chunk) => { buffer += chunk; // Process complete lines const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (line.trim()) { try { const request = JSON.parse(line); this.processRequest(request); } catch (error) { this.log(`Parse error: ${error.message}`); this.sendError(null, -32700, 'Parse error'); } } } }); process.stdin.on('end', () => { this.log('Input stream ended'); process.exit(0); }); process.on('SIGINT', () => { this.log('Received SIGINT, shutting down'); process.exit(0); }); } } // Start the server if (require.main === module) { const server = new CalibreMCPServer(); server.start(); } module.exports = CalibreMCPServer;

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/ispyridis/calibre-mcp-nodejs'

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