server.js•24.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;