Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)

Turn any PDF folder into a searchable MCP server with semantic search.
Turn any PDF folder into a searchable MCP server with semantic, hybrid, or keyword search.

## Installation

Expand Down Expand Up @@ -144,6 +144,9 @@ pdf2mcp automatically detects image-only pages in PDFs and falls back to Tessera
| `pdf2mcp ingest` | Parse PDFs, chunk, embed, and store in vector DB |
| `pdf2mcp serve` | Start the MCP server (HTTP by default) |
| `pdf2mcp config` | Print ready-to-paste config for MCP clients |
| `pdf2mcp stats` | Display index statistics (doc count, chunks, DB size) |
| `pdf2mcp search <query>` | Search the index from the command line |
| `pdf2mcp delete <filename>` | Delete a document from the index |

### Common Flags

Expand Down Expand Up @@ -175,6 +178,18 @@ pdf2mcp config --client claude-desktop --transport stdio
# Interactive setup wizard
pdf2mcp init -i ./my-project
pdf2mcp init --interactive

# View index statistics
pdf2mcp stats

# Search the index from CLI
pdf2mcp search "safety requirements"
pdf2mcp search "torque settings" --filename manual.pdf
pdf2mcp search "installation" -n 10

# Delete a document from the index
pdf2mcp delete old-manual.pdf
pdf2mcp delete old-manual.pdf -y # skip confirmation
```

## Client Configuration
Expand Down Expand Up @@ -232,18 +247,41 @@ These configure the server process. MCP clients never need these.
| `PDF2MCP_SERVER_TRANSPORT` | `streamable-http` | Transport protocol |
| `PDF2MCP_SERVER_HOST` | `127.0.0.1` | Host to bind to |
| `PDF2MCP_SERVER_PORT` | `8000` | Port to bind to |
| `PDF2MCP_SEARCH_MODE` | `semantic` | Search mode: `semantic`, `hybrid`, or `keyword` |
| `PDF2MCP_OCR_ENABLED` | `true` | Enable OCR for scanned/image-only pages |
| `PDF2MCP_OCR_LANGUAGE` | `eng` | Tesseract language code |
| `PDF2MCP_OCR_DPI` | `300` | DPI for OCR rendering |

## Search Modes

pdf2mcp supports three search modes, controlled by the `PDF2MCP_SEARCH_MODE` environment variable:

| Mode | Description | When to use |
|------|-------------|-------------|
| `semantic` (default) | Pure vector similarity search | General natural-language queries |
| `keyword` | Full-text search (no embeddings needed) | Exact terms, acronyms, error codes |
| `hybrid` | Combines vector + full-text search | Best of both worlds |

To switch modes, set `PDF2MCP_SEARCH_MODE` in your `.env` and re-ingest:

```bash
# In .env
PDF2MCP_SEARCH_MODE=hybrid

# Re-ingest to build the FTS index
pdf2mcp ingest --force
```

Hybrid and keyword modes automatically create a full-text search index. If you switch modes without re-ingesting, the FTS index is created lazily on the first query.

## MCP Tools

The server exposes six tools:

| Tool | Description |
|------|-------------|
| `search_docs(query)` | Semantic search across **all** ingested PDFs |
| `search_in_doc(query, filename)` | Semantic search scoped to a **single** document |
| `search_docs(query)` | Search across **all** ingested PDFs |
| `search_in_doc(query, filename)` | Search scoped to a **single** document |
| `list_docs()` | List all ingested documents with chunk counts |
| `get_sections(filename)` | Get section headings for a specific document |
| `read_page(filename, page)` | Read the full content of a specific page |
Expand All @@ -256,6 +294,20 @@ The server exposes six tools:
3. **`read_section`** or **`read_page`** — read specific content
4. **`search_docs`** or **`search_in_doc`** — find information by query

## MCP Prompts

The server provides five prompts that guide LLMs through multi-tool workflows:

| Prompt | Args | Description |
|--------|------|-------------|
| `summarize_document` | `filename` | Read all sections and synthesize a summary |
| `compare_documents` | `filename1, filename2` | Side-by-side comparison of two documents |
| `extract_key_findings` | `filename` | Extract conclusions, recommendations, and key findings |
| `deep_dive` | `filename, topic` | Exhaustive analysis of a specific topic |
| `document_overview` | `filename` | Structured table of contents with brief descriptions |

Prompts return step-by-step instructions that reference the existing tools, enabling LLMs to perform complex multi-step document analysis automatically.

## MCP Resources

| Resource URI | Description |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pdf2mcp"
version = "0.5.0"
version = "0.6.0"
description = "Turn any PDF folder into a searchable MCP server"
readme = "README.md"
license = { text = "MIT" }
Expand Down
176 changes: 176 additions & 0 deletions src/pdf2mcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
# PDF2MCP_SERVER_HOST=127.0.0.1
# PDF2MCP_SERVER_PORT=8000

# Optional: Search mode (semantic, hybrid, or keyword)
# PDF2MCP_SEARCH_MODE=semantic

# Optional: OCR settings (for scanned/image-only PDFs — requires Tesseract)
# PDF2MCP_OCR_ENABLED=true
# PDF2MCP_OCR_LANGUAGE=eng
Expand Down Expand Up @@ -168,6 +171,134 @@ def cmd_config(args: argparse.Namespace) -> None:
)


def cmd_delete(args: argparse.Namespace) -> None:
"""Delete a document from the index."""
setup_logging(args.verbose)
logger = logging.getLogger("pdf2mcp")

settings = _load_settings()

from pdf2mcp.store import (
delete_by_source,
delete_ingestion_metadata,
get_db,
get_ingested_files,
invalidate_table_cache,
)

db = get_db(settings)
ingested = get_ingested_files(db)

filename = args.filename
if filename not in ingested:
logger.error("File '%s' not found in index", filename)
sys.exit(1)

if not args.yes:
answer = input(f"Delete '{filename}' from index? [y/N] ")
if answer.lower() not in ("y", "yes"):
print("Cancelled.", file=sys.stderr)
return

delete_by_source(db, filename)
delete_ingestion_metadata(db, filename)
invalidate_table_cache()
logger.info("Deleted '%s' from index", filename)


def _format_bytes(size: int) -> str:
"""Format a byte count into a human-readable string."""
for unit in ("B", "KB", "MB", "GB"):
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024 # type: ignore[assignment]
return f"{size:.1f} TB"


def cmd_stats(args: argparse.Namespace) -> None:
"""Display index statistics."""
setup_logging(args.verbose)

settings = _load_settings()

from rich.console import Console
from rich.table import Table

from pdf2mcp.search import list_ingested_documents
from pdf2mcp.store import DOCUMENTS_TABLE, get_db, table_exists

console = Console(stderr=True)
db = get_db(settings)
docs = list_ingested_documents(settings)
total_chunks = sum(doc.get("chunk_count", 0) for doc in docs)

# Compute average chunk size
avg_chunk_size = 0
if table_exists(db, DOCUMENTS_TABLE) and total_chunks > 0:
table = db.open_table(DOCUMENTS_TABLE)
arrow_table = table.to_arrow()
texts = arrow_table.column("text").to_pylist()
avg_chunk_size = sum(len(t) for t in texts) // len(texts) if texts else 0

# Compute DB size on disk
db_path = settings.data_dir / "lancedb"
db_size = 0
if db_path.exists():
for f in db_path.rglob("*"):
if f.is_file():
db_size += f.stat().st_size

# Summary table
summary = Table(title="pdf2mcp Index Statistics", show_header=False)
summary.add_column("Key", style="bold")
summary.add_column("Value")
summary.add_row("Documents", str(len(docs)))
summary.add_row("Total chunks", str(total_chunks))
summary.add_row("Avg chunk size", f"{avg_chunk_size} chars")
summary.add_row("Embedding model", settings.embedding_model)
summary.add_row("Search mode", getattr(settings, "search_mode", "semantic"))
summary.add_row("Database size", _format_bytes(db_size))
summary.add_row("Docs directory", str(settings.docs_dir))
summary.add_row("Data directory", str(settings.data_dir))
console.print(summary)

# Per-document table
if docs:
doc_table = Table(title="Ingested Documents")
doc_table.add_column("Filename")
doc_table.add_column("Chunks", justify="right")
doc_table.add_column("Hash")
for doc in docs:
doc_table.add_row(
doc["filename"],
str(doc["chunk_count"]),
doc.get("file_hash", "?")[:12],
)
console.print(doc_table)


def cmd_search(args: argparse.Namespace) -> None:
"""Search the index from the command line."""
setup_logging(args.verbose)

settings = _load_settings()

from pdf2mcp.search import (
format_results,
search_documents,
search_in_document,
)

if args.filename:
results = search_in_document(
args.query, args.filename, settings, num_results=args.num_results
)
else:
results = search_documents(args.query, settings, num_results=args.num_results)

print(format_results(results), file=sys.stderr)


def cmd_init(args: argparse.Namespace) -> None:
"""Scaffold a working directory for pdf2mcp."""
if getattr(args, "interactive", False):
Expand Down Expand Up @@ -324,6 +455,45 @@ def main() -> None:
help="Print config for a specific client only",
)

# delete subcommand
delete_parser = subparsers.add_parser(
"delete", help="Delete a document from the index"
)
delete_parser.add_argument("filename", help="PDF filename to delete")
delete_parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging"
)
delete_parser.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
)

# stats subcommand
stats_parser = subparsers.add_parser("stats", help="Display index statistics")
stats_parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging"
)

# search subcommand
search_parser = subparsers.add_parser(
"search", help="Search the index from the command line"
)
search_parser.add_argument("query", help="Search query")
search_parser.add_argument(
"-n",
"--num-results",
type=int,
default=5,
help="Number of results to return (default: 5)",
)
search_parser.add_argument(
"--filename",
default=None,
help="Restrict search to a specific document",
)
search_parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging"
)

# init subcommand
init_parser = subparsers.add_parser(
"init", help="Scaffold a working directory for pdf2mcp"
Expand Down Expand Up @@ -351,6 +521,12 @@ def main() -> None:
cmd_config(args)
elif args.command == "init":
cmd_init(args)
elif args.command == "delete":
cmd_delete(args)
elif args.command == "stats":
cmd_stats(args)
elif args.command == "search":
cmd_search(args)
else:
parser.print_help(sys.stderr)
sys.exit(1)
10 changes: 10 additions & 0 deletions src/pdf2mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ def _validate_ocr_language(cls, v: str) -> str:

# Search
default_num_results: int = 5
search_mode: str = "semantic"

@field_validator("search_mode")
@classmethod
def _validate_search_mode(cls, v: str) -> str:
allowed = {"semantic", "hybrid", "keyword"}
if v not in allowed:
msg = f"search_mode must be one of {allowed}, got '{v}'"
raise ValueError(msg)
return v

# Bind address
server_name: str = "pdf-docs"
Expand Down
3 changes: 3 additions & 0 deletions src/pdf2mcp/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pdf2mcp.progress import IngestionProgress
from pdf2mcp.store import (
clear_database,
create_fts_index,
create_vector_index,
delete_by_source,
get_db,
Expand Down Expand Up @@ -77,6 +78,8 @@ def run_ingestion(
# Create vector index for faster ANN search if enough rows exist
if ingested_count > 0:
create_vector_index(db)
if settings.search_mode in ("hybrid", "keyword"):
create_fts_index(db)

logger.info(
"Ingestion complete: %d ingested, %d skipped",
Expand Down
Loading
Loading