Skip to main content
Glama

Neolibrarian MCP

by pshap
calibre_mcp_server.py17.5 kB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Calibre Library MCP Server Modern MCP server exposing Calibre library tools and a few read-only resources. Uses the FastMCP interface from the official MCP Python SDK with a lifespan initializer for clean dependency management. """ import logging import os from dataclasses import dataclass from typing import Any, Optional, Dict, List from collections.abc import AsyncIterator from contextlib import asynccontextmanager from mcp.server.fastmcp import FastMCP, Context from calibre_client import CalibreClient # Configure structured logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ logging.StreamHandler(), ] ) logger = logging.getLogger("calibre-mcp-server") @dataclass class AppContext: """Typed lifespan context for request handlers.""" client: CalibreClient @asynccontextmanager async def app_lifespan(_server: FastMCP) -> AsyncIterator[AppContext]: """Initialize Calibre client once and clean up on shutdown.""" logger.info("Starting Calibre MCP server...") # Determine config path relative to this file try: script_dir = os.path.dirname(os.path.abspath(__file__)) logger.debug(f"Script directory: {script_dir}") except NameError: script_dir = os.getcwd() logger.debug(f"Using current working directory: {script_dir}") # Allow override via environment variable for easy external configuration env_cfg = os.environ.get("CALIBRE_CONFIG_PATH") config_path = ( os.path.expanduser(os.path.expandvars(env_cfg)) if env_cfg and env_cfg.strip() else os.path.join(script_dir, "config.json") ) logger.info(f"Loading configuration from: {config_path}") # Initialize client client: Optional[CalibreClient] = None try: client = CalibreClient(config_path) logger.info("Calibre client initialized successfully") yield AppContext(client=client) except Exception as e: logger.error(f"Failed to initialize Calibre client: {e}") raise finally: # No explicit cleanup required for CalibreClient currently logger.info("Calibre MCP server shutting down") # Create FastMCP server with lifespan lifecycle mcp = FastMCP("Calibre Library Server", lifespan=app_lifespan) # Helpful alias for typed context; generic parameters are managed by the SDK CalCtx = Context def get_client(ctx: CalCtx) -> CalibreClient: """Helper function to get client from context.""" return ctx.request_context.lifespan_context.client @mcp.tool() def ping(ctx: CalCtx) -> Dict[str, Any]: """Test connection to Calibre library.""" return get_client(ctx).ping() @mcp.tool() def get_total_books(ctx: CalCtx) -> Dict[str, Any]: """Get total number of books in the library.""" return get_client(ctx).get_total_books() @mcp.tool() def search_by_author(author_name: str, ctx: CalCtx) -> Dict[str, Any]: """Search for books by author name (case-insensitive, partial match). Args: author_name: Author name to search for """ if not author_name.strip(): return {"status": "error", "message": "Author name cannot be empty"} return get_client(ctx).search_by_author(author_name.strip()) @mcp.tool() def search_by_title(title: str, ctx: CalCtx) -> Dict[str, Any]: """Search for books by title (case-insensitive, partial match). Args: title: Book title to search for """ if not title.strip(): return {"status": "error", "message": "Title cannot be empty"} return get_client(ctx).search_by_title(title.strip()) @mcp.tool() def get_book_details(book_id: int, ctx: CalCtx) -> Dict[str, Any]: """Get detailed metadata for a specific book by book_id. Args: book_id: The book ID to get details for """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} return get_client(ctx).get_book_details(book_id) @mcp.tool() def get_book_formats(book_id: int, ctx: CalCtx) -> Dict[str, Any]: """Get all available formats (EPUB, PDF, TXT, MOBI) for a book. Args: book_id: The book ID to get formats for """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} return get_client(ctx).get_book_formats(book_id) @mcp.tool() def get_book_content( book_id: int, ctx: CalCtx, preferred_format: Optional[str] = None, max_length: int = 50000, ) -> Dict[str, Any]: """Extract full text content from a book file. Args: book_id: The book ID to extract content from preferred_format: Preferred format (EPUB, PDF, TXT, MOBI) max_length: Maximum content length in characters (default: 50000) """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} if max_length <= 0 or max_length > 500000: return {"status": "error", "message": "max_length must be between 1 and 500000"} if preferred_format and preferred_format.upper() not in ["EPUB", "PDF", "TXT", "MOBI"]: return {"status": "error", "message": "preferred_format must be one of: EPUB, PDF, TXT, MOBI"} return get_client(ctx).get_book_content(book_id, preferred_format, max_length) @mcp.tool() def get_book_sample( book_id: int, ctx: CalCtx, sample_type: str = "beginning", sample_size: int = 5000, ) -> Dict[str, Any]: """Get a sample of book content for analysis. Args: book_id: The book ID to sample from sample_type: Type of sample to extract (beginning, end, middle, overview) sample_size: Sample size in characters (default: 5000) """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} if sample_size <= 0 or sample_size > 50000: return {"status": "error", "message": "sample_size must be between 1 and 50000"} valid_types = ["beginning", "end", "middle", "overview"] if sample_type not in valid_types: return {"status": "error", "message": f"sample_type must be one of: {', '.join(valid_types)}"} return get_client(ctx).get_book_sample(book_id, sample_type, sample_size) @mcp.tool() def analyze_book_content( book_id: int, ctx: CalCtx, analysis_type: str = "summary", max_length: int = 5000, ) -> Dict[str, Any]: """Get book content prepared for specific types of LLM analysis. Args: book_id: The book ID to analyze analysis_type: Type of analysis to prepare content for (summary, beginning, themes, characters, quotes) max_length: Maximum content length for analysis (default: 5000) """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} if max_length <= 0 or max_length > 50000: return {"status": "error", "message": "max_length must be between 1 and 50000"} valid_types = ["summary", "beginning", "themes", "characters", "quotes"] if analysis_type not in valid_types: return {"status": "error", "message": f"analysis_type must be one of: {', '.join(valid_types)}"} return get_client(ctx).analyze_book_content(book_id, analysis_type, max_length) @mcp.tool() def search_content( book_id: int, query: str, ctx: CalCtx, case_sensitive: bool = False, ) -> Dict[str, Any]: """Search for text within a book's content. Args: book_id: The book ID to search in query: Text to search for case_sensitive: Whether the search should be case sensitive (default: False) """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} if not query.strip(): return {"status": "error", "message": "Search query cannot be empty"} return get_client(ctx).search_content(book_id, query.strip(), case_sensitive) @mcp.tool() def search_multiple_books( book_ids: List[int], query: str, ctx: CalCtx, case_sensitive: bool = False, ) -> Dict[str, Any]: """Search for text within multiple books' content. Args: book_ids: List of book IDs to search in query: Text to search for case_sensitive: Whether the search should be case sensitive (default: False) """ if not book_ids or not isinstance(book_ids, list): return {"status": "error", "message": "book_ids must be a non-empty list"} if len(book_ids) > 100: return {"status": "error", "message": "Too many books (max 100)"} invalid_ids = [bid for bid in book_ids if not isinstance(bid, int) or bid <= 0] if invalid_ids: return {"status": "error", "message": f"Invalid book IDs: {invalid_ids}"} if not query.strip(): return {"status": "error", "message": "Search query cannot be empty"} return get_client(ctx).search_multiple_books(book_ids, query.strip(), case_sensitive) @mcp.tool() def unified_search( ctx: CalCtx, query: Optional[str] = None, author: Optional[str] = None, title: Optional[str] = None, series: Optional[str] = None, formats: Optional[List[str]] = None, date_start: Optional[str] = None, date_end: Optional[str] = None, offset: int = 0, limit: int = 20, sort_by: str = "title", fuzzy: bool = False ) -> Dict[str, Any]: """Unified search with comprehensive filters and pagination. Args: query: General search term (searches both title and author) author: Filter by author name title: Filter by title series: Filter by series name formats: List of formats to filter by (e.g., ["EPUB", "PDF"]) date_start: Start date for publication date range (YYYY-MM-DD) date_end: End date for publication date range (YYYY-MM-DD) offset: Number of results to skip (default: 0) limit: Maximum results to return (default: 20, max: 100) sort_by: Sort order: title, author, date, or series (default: title) fuzzy: Enable fuzzy matching for typos (default: False) """ if limit > 100: limit = 100 filters = {} if author: filters["author"] = author.strip() if title: filters["title"] = title.strip() if series: filters["series"] = series.strip() if formats: filters["formats"] = formats if date_start or date_end: date_range = {} if date_start: date_range["start"] = date_start if date_end: date_range["end"] = date_end filters["date_range"] = date_range pagination = {"offset": offset, "limit": limit} options = {"sort_by": sort_by, "fuzzy_matching": fuzzy} return get_client(ctx).unified_search( query=query.strip() if query else "", filters=filters, pagination=pagination, options=options ) @mcp.tool() def get_books_batch(book_ids: List[int], ctx: CalCtx) -> Dict[str, Any]: """Get detailed metadata for multiple books in a single request. Args: book_ids: List of book IDs to retrieve (max 100) """ if not book_ids or not isinstance(book_ids, list): return {"status": "error", "message": "book_ids must be a non-empty list"} if len(book_ids) > 100: return {"status": "error", "message": "Maximum 100 books per batch request"} invalid_ids = [bid for bid in book_ids if not isinstance(bid, int) or bid <= 0] if invalid_ids: return {"status": "error", "message": f"Invalid book IDs: {invalid_ids}"} return get_client(ctx).get_books_batch(book_ids) @mcp.tool() def get_random_books( ctx: CalCtx, count: int = 10, formats: Optional[List[str]] = None, series_only: bool = False ) -> Dict[str, Any]: """Get random books for discovery. Args: count: Number of random books to return (default: 10, max: 50) formats: Only include books with these formats (e.g., ["EPUB", "PDF"]) series_only: Only include books that are part of a series (default: False) """ if count <= 0 or count > 50: count = min(max(count, 1), 50) filters = {} if formats: filters["formats"] = formats if series_only: filters["series_only"] = True return get_client(ctx).get_random_books(count, filters) @mcp.tool() def full_text_search( query: str, ctx: CalCtx, case_sensitive: bool = False, max_results: int = 50, context_chars: int = 200 ) -> Dict[str, Any]: """Search for text content across all books using full-text search database. Args: query: Text to search for across all book content case_sensitive: Whether the search should be case sensitive (default: False) max_results: Maximum number of results to return (default: 50, max: 200) context_chars: Number of characters to include around matches for context (default: 200) """ if not query.strip(): return {"status": "error", "message": "Search query cannot be empty"} if max_results <= 0 or max_results > 200: max_results = min(max(max_results, 1), 200) if context_chars < 50 or context_chars > 1000: context_chars = min(max(context_chars, 50), 1000) try: result = get_client(ctx).full_text_search(query.strip(), case_sensitive, max_results) # Add search parameters to response for transparency if result.get("status") == "success": result["search_parameters"] = { "query": query.strip(), "case_sensitive": case_sensitive, "max_results": max_results, "context_chars": context_chars, "results_truncated": len(result.get("results", [])) >= max_results } return result except Exception as e: return { "status": "error", "message": f"Full-text search failed: {str(e)}", "error_type": "search_error" } @mcp.tool() def full_text_search_book( book_id: int, query: str, ctx: CalCtx, case_sensitive: bool = False, context_chars: int = 200 ) -> Dict[str, Any]: """Search for text content within a specific book using full-text search database. Args: book_id: The book ID to search within query: Text to search for case_sensitive: Whether the search should be case sensitive (default: False) context_chars: Number of characters to include around matches for context (default: 200) """ if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} if not query.strip(): return {"status": "error", "message": "Search query cannot be empty"} if context_chars < 50 or context_chars > 1000: context_chars = min(max(context_chars, 50), 1000) try: result = get_client(ctx).full_text_search_book(book_id, query.strip(), case_sensitive) # Add search parameters to response for transparency if result.get("status") == "success": result["search_parameters"] = { "book_id": book_id, "query": query.strip(), "case_sensitive": case_sensitive, "context_chars": context_chars } return result except Exception as e: return { "status": "error", "message": f"Book full-text search failed: {str(e)}", "error_type": "search_error" } @mcp.tool() def get_full_text_search_stats(ctx: CalCtx) -> Dict[str, Any]: """Get statistics about the full-text search database. Returns information about indexed books, formats, and search capabilities. """ try: result = get_client(ctx).get_full_text_search_stats() if result.get("status") == "success": result["capabilities"] = { "global_search": True, "book_specific_search": True, "case_sensitive_search": True, "context_extraction": True } return result except Exception as e: return { "status": "error", "message": f"Failed to get FTS statistics: {str(e)}", "error_type": "stats_error" } # --- Resources: lightweight, read-only context surfaces --- @mcp.resource("calibre://stats", mime_type="application/json") def resource_stats() -> Dict[str, Any]: """Library stats overview as JSON.""" ctx = mcp.get_context() client = get_client(ctx) ping = client.ping() total = client.get_total_books() return { "status": ping.get("status", "unknown"), "library_path": ping.get("library_path", "unknown"), "books": total.get("total", total), "fts_db": ping.get("checks", {}).get("fts_db", "unknown"), } @mcp.resource("calibre://book/{book_id}/details", mime_type="application/json") def resource_book_details(book_id: int) -> Dict[str, Any]: """JSON details for a specific book.""" if book_id <= 0: return {"status": "error", "message": "Book ID must be positive"} ctx = mcp.get_context() return get_client(ctx).get_book_details(book_id) if __name__ == "__main__": # Explicitly use stdio transport for MCP hosts like Claude Desktop mcp.run(transport="stdio")

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/pshap/mcp-neolibrarian'

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