Add MCP server code
This commit is contained in:
commit
5e89bc2b26
16 files changed
+1803
No files matched your search
@@ -0,0 +1,38 @@
|
||||
"""Shared fixtures for pdf-mcp tests."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ASSETS = Path(__file__).parent.parent / "assets"
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Generate test PDFs before the test session if they don't exist."""
|
||||
expected = ["basic.pdf", "diagrams.pdf", "nested_toc.pdf", "search_targets.pdf"]
|
||||
if not all((ASSETS / f).exists() for f in expected):
|
||||
subprocess.check_call(
|
||||
[sys.executable, str(ASSETS / "generate.py")],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_pdf() -> str:
|
||||
return str(ASSETS / "basic.pdf")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def diagrams_pdf() -> str:
|
||||
return str(ASSETS / "diagrams.pdf")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nested_toc_pdf() -> str:
|
||||
return str(ASSETS / "nested_toc.pdf")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_pdf() -> str:
|
||||
return str(ASSETS / "search_targets.pdf")
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import Image
|
||||
|
||||
from pdf_mcp.server import get_page_image
|
||||
|
||||
|
||||
class TestGetPageImageBase64:
|
||||
def test_returns_image_object(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1)
|
||||
assert isinstance(result, Image)
|
||||
|
||||
def test_image_has_data(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_png_signature(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1)
|
||||
# PNG files start with the 8-byte signature
|
||||
assert result.data[:4] == b"\x89PNG"
|
||||
|
||||
def test_different_pages(self, basic_pdf):
|
||||
img1 = get_page_image(basic_pdf, page=1)
|
||||
img2 = get_page_image(basic_pdf, page=2)
|
||||
assert img1.data != img2.data
|
||||
|
||||
|
||||
class TestGetPageImageFile:
|
||||
def test_returns_string_with_path(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1, output="file")
|
||||
assert isinstance(result, str)
|
||||
assert "Image saved to" in result
|
||||
|
||||
def test_file_exists(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1, output="file")
|
||||
# Extract path from "Image saved to /path/to/file.png (WxHpx)"
|
||||
path = result.split("Image saved to ")[1].split(" (")[0]
|
||||
assert Path(path).exists()
|
||||
os.unlink(path)
|
||||
|
||||
def test_file_is_valid_png(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1, output="file")
|
||||
path = result.split("Image saved to ")[1].split(" (")[0]
|
||||
with open(path, "rb") as f:
|
||||
assert f.read(4) == b"\x89PNG"
|
||||
os.unlink(path)
|
||||
|
||||
def test_file_includes_dimensions(self, basic_pdf):
|
||||
result = get_page_image(basic_pdf, page=1, output="file")
|
||||
# Should contain something like "(1240x1754px)"
|
||||
assert "px)" in result
|
||||
|
||||
|
||||
class TestGetPageImageDpi:
|
||||
def test_higher_dpi_larger_file(self, basic_pdf):
|
||||
low = get_page_image(basic_pdf, page=1, dpi=72)
|
||||
high = get_page_image(basic_pdf, page=1, dpi=300)
|
||||
assert len(high.data) > len(low.data)
|
||||
|
||||
def test_default_dpi(self, basic_pdf):
|
||||
default = get_page_image(basic_pdf, page=1)
|
||||
explicit_150 = get_page_image(basic_pdf, page=1, dpi=150)
|
||||
assert len(default.data) == len(explicit_150.data)
|
||||
|
||||
|
||||
class TestGetPageImageDiagrams:
|
||||
def test_diagrams_renders(self, diagrams_pdf):
|
||||
result = get_page_image(diagrams_pdf, page=1)
|
||||
assert isinstance(result, Image)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_diagrams_file_output(self, diagrams_pdf):
|
||||
result = get_page_image(diagrams_pdf, page=1, output="file")
|
||||
path = result.split("Image saved to ")[1].split(" (")[0]
|
||||
assert Path(path).exists()
|
||||
assert Path(path).stat().st_size > 0
|
||||
os.unlink(path)
|
||||
@@ -0,0 +1,121 @@
|
||||
from pdf_mcp.server import get_page_text
|
||||
|
||||
|
||||
class TestGetPageTextJson:
|
||||
def test_default_format_is_json(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], dict)
|
||||
|
||||
def test_json_has_page_number(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=2)
|
||||
assert result[0]["page_number"] == 2
|
||||
|
||||
def test_json_has_blocks(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1)
|
||||
assert "blocks" in result[0]
|
||||
assert len(result[0]["blocks"]) > 0
|
||||
|
||||
def test_json_page_range(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, end_page=3, format="json")
|
||||
assert len(result) == 3
|
||||
assert result[0]["page_number"] == 1
|
||||
assert result[2]["page_number"] == 3
|
||||
|
||||
|
||||
class TestGetPageTextPlain:
|
||||
def test_text_format_returns_string(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="text")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_text_contains_content(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="text")
|
||||
assert "Chapter 1" in result
|
||||
assert "introductory material" in result
|
||||
|
||||
def test_text_page_range_has_breaks(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, end_page=2, format="text")
|
||||
assert "--- Page Break ---" in result
|
||||
assert "Chapter 1" in result
|
||||
assert "Chapter 2" in result
|
||||
|
||||
def test_text_single_page_no_break(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="text")
|
||||
assert "--- Page Break ---" not in result
|
||||
|
||||
|
||||
class TestGetPageTextMarkdown:
|
||||
def test_markdown_returns_string(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="markdown")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_markdown_contains_content(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="markdown")
|
||||
assert "Chapter 1" in result
|
||||
|
||||
def test_markdown_page_range(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, end_page=3, format="markdown")
|
||||
assert "Chapter 1" in result
|
||||
assert "Chapter 3" in result
|
||||
|
||||
|
||||
class TestGetPageTextHtml:
|
||||
def test_html_returns_string(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="html")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_html_has_tags(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="html")
|
||||
assert "<div" in result
|
||||
assert "<span" in result
|
||||
|
||||
def test_html_contains_content(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="html")
|
||||
assert "Chapter 1" in result
|
||||
|
||||
|
||||
class TestGetPageTextHeaderFooter:
|
||||
def test_with_headers_footers(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=1, format="text", include_headers_footers=True)
|
||||
assert "Header" in result
|
||||
assert "Page 1 of 3" in result
|
||||
|
||||
def test_without_headers_footers(self, basic_pdf):
|
||||
result = get_page_text(
|
||||
basic_pdf, start_page=1, format="text", include_headers_footers=False
|
||||
)
|
||||
# Header text is at y=30, footer at y=820 — both within the 50pt margin crop
|
||||
assert "Header" not in result
|
||||
assert "Page 1 of 3" not in result
|
||||
|
||||
def test_body_preserved_without_headers_footers(self, basic_pdf):
|
||||
result = get_page_text(
|
||||
basic_pdf, start_page=1, format="text", include_headers_footers=False
|
||||
)
|
||||
assert "Chapter 1" in result
|
||||
assert "introductory material" in result
|
||||
|
||||
def test_markdown_without_headers_footers(self, basic_pdf):
|
||||
result = get_page_text(
|
||||
basic_pdf, start_page=1, format="markdown", include_headers_footers=False
|
||||
)
|
||||
assert "Chapter 1" in result
|
||||
assert "Header" not in result
|
||||
|
||||
|
||||
class TestGetPageTextEndPageDefault:
|
||||
def test_end_page_defaults_to_start(self, basic_pdf):
|
||||
result = get_page_text(basic_pdf, start_page=2, format="text")
|
||||
assert "Chapter 2" in result
|
||||
assert "Chapter 1" not in result
|
||||
assert "Chapter 3" not in result
|
||||
|
||||
|
||||
class TestGetPageTextDiagrams:
|
||||
def test_diagrams_text_extraction(self, diagrams_pdf):
|
||||
result = get_page_text(diagrams_pdf, start_page=1, format="text")
|
||||
assert "Component A" in result
|
||||
assert "Component B" in result
|
||||
assert "Pin Configuration" in result
|
||||
assert "GPIO_1" in result
|
||||
@@ -0,0 +1,29 @@
|
||||
from pdf_mcp.server import get_pdf_info
|
||||
|
||||
|
||||
class TestGetPdfInfo:
|
||||
def test_basic_metadata(self, basic_pdf):
|
||||
info = get_pdf_info(basic_pdf)
|
||||
assert info["page_count"] == 3
|
||||
assert info["metadata"]["title"] == "Test Document"
|
||||
assert info["metadata"]["author"] == "Test Author"
|
||||
assert info["metadata"]["subject"] == "Testing"
|
||||
assert info["metadata"]["keywords"] == "test, pdf, mcp"
|
||||
assert info["metadata"]["creator"] == "generate.py"
|
||||
assert info["is_encrypted"] is False
|
||||
|
||||
def test_filename_returned(self, basic_pdf):
|
||||
info = get_pdf_info(basic_pdf)
|
||||
assert info["filename"] == basic_pdf
|
||||
|
||||
def test_single_page_pdf(self, diagrams_pdf):
|
||||
info = get_pdf_info(diagrams_pdf)
|
||||
assert info["page_count"] == 1
|
||||
|
||||
def test_multi_page_pdf(self, nested_toc_pdf):
|
||||
info = get_pdf_info(nested_toc_pdf)
|
||||
assert info["page_count"] == 9
|
||||
|
||||
def test_search_targets_page_count(self, search_pdf):
|
||||
info = get_pdf_info(search_pdf)
|
||||
assert info["page_count"] == 3
|
||||
@@ -0,0 +1,39 @@
|
||||
from pdf_mcp.server import get_table_of_contents
|
||||
|
||||
|
||||
class TestGetTableOfContents:
|
||||
def test_basic_toc(self, basic_pdf):
|
||||
toc = get_table_of_contents(basic_pdf)
|
||||
assert len(toc) == 3
|
||||
assert toc[0] == {"level": 1, "title": "Chapter 1", "page": 1}
|
||||
assert toc[1] == {"level": 1, "title": "Chapter 2", "page": 2}
|
||||
assert toc[2] == {"level": 1, "title": "Chapter 3", "page": 3}
|
||||
|
||||
def test_nested_toc_entries(self, nested_toc_pdf):
|
||||
toc = get_table_of_contents(nested_toc_pdf)
|
||||
assert len(toc) == 9
|
||||
|
||||
def test_nested_toc_levels(self, nested_toc_pdf):
|
||||
toc = get_table_of_contents(nested_toc_pdf)
|
||||
levels = [e["level"] for e in toc]
|
||||
assert levels == [1, 1, 2, 2, 3, 3, 1, 2, 2]
|
||||
|
||||
def test_nested_toc_titles(self, nested_toc_pdf):
|
||||
toc = get_table_of_contents(nested_toc_pdf)
|
||||
titles = [e["title"] for e in toc]
|
||||
assert "Introduction" in titles
|
||||
assert "Database Layer" in titles
|
||||
assert "Kubernetes" in titles
|
||||
|
||||
def test_nested_toc_page_numbers(self, nested_toc_pdf):
|
||||
toc = get_table_of_contents(nested_toc_pdf)
|
||||
pages = [e["page"] for e in toc]
|
||||
assert pages == list(range(1, 10))
|
||||
|
||||
def test_no_toc(self, search_pdf):
|
||||
toc = get_table_of_contents(search_pdf)
|
||||
assert toc == []
|
||||
|
||||
def test_no_toc_diagrams(self, diagrams_pdf):
|
||||
toc = get_table_of_contents(diagrams_pdf)
|
||||
assert toc == []
|
||||
@@ -0,0 +1,88 @@
|
||||
from pdf_mcp.server import search_text
|
||||
|
||||
|
||||
class TestSearchTextBasic:
|
||||
def test_unique_marker_on_two_pages(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="UNIQUE_MARKER_ALPHA")
|
||||
pages = [h["page"] for h in hits]
|
||||
assert pages == [1, 3]
|
||||
|
||||
def test_unique_marker_single_page(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA")
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["page"] == 2
|
||||
|
||||
def test_no_results(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="NONEXISTENT_TEXT_XYZ")
|
||||
assert hits == []
|
||||
|
||||
def test_multi_page_hits(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="resistance")
|
||||
pages = [h["page"] for h in hits]
|
||||
assert 1 in pages
|
||||
assert 2 in pages
|
||||
|
||||
|
||||
class TestSearchTextCaseInsensitive:
|
||||
def test_lowercase_query(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="unique_marker_alpha")
|
||||
assert len(hits) == 2
|
||||
|
||||
def test_mixed_case_query(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="Unique_Marker_Alpha")
|
||||
assert len(hits) == 2
|
||||
|
||||
def test_case_insensitive_content(self, search_pdf):
|
||||
lower = search_text(search_pdf, query="resistance")
|
||||
upper = search_text(search_pdf, query="RESISTANCE")
|
||||
assert len(lower) == len(upper)
|
||||
|
||||
|
||||
class TestSearchTextContext:
|
||||
def test_context_contains_query(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA")
|
||||
assert "UNIQUE_MARKER_BETA" in hits[0]["context"]
|
||||
|
||||
def test_context_has_surrounding_text(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA")
|
||||
# The context should include text around the match
|
||||
assert len(hits[0]["context"]) > len("UNIQUE_MARKER_BETA")
|
||||
|
||||
def test_custom_context_chars(self, search_pdf):
|
||||
short = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=10)
|
||||
long = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=200)
|
||||
assert len(short[0]["context"]) <= len(long[0]["context"])
|
||||
|
||||
def test_zero_context(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=0)
|
||||
assert hits[0]["context"] == "UNIQUE_MARKER_BETA"
|
||||
|
||||
|
||||
class TestSearchTextHitStructure:
|
||||
def test_hit_has_page(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="resistance")
|
||||
for hit in hits:
|
||||
assert "page" in hit
|
||||
assert isinstance(hit["page"], int)
|
||||
|
||||
def test_hit_has_context(self, search_pdf):
|
||||
hits = search_text(search_pdf, query="resistance")
|
||||
for hit in hits:
|
||||
assert "context" in hit
|
||||
assert isinstance(hit["context"], str)
|
||||
|
||||
|
||||
class TestSearchTextOnOtherPdfs:
|
||||
def test_search_basic_pdf(self, basic_pdf):
|
||||
hits = search_text(basic_pdf, query="introductory")
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["page"] == 1
|
||||
|
||||
def test_search_diagrams_pdf(self, diagrams_pdf):
|
||||
hits = search_text(diagrams_pdf, query="Component A")
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["page"] == 1
|
||||
|
||||
def test_search_nested_toc_pdf(self, nested_toc_pdf):
|
||||
hits = search_text(nested_toc_pdf, query="Kubernetes")
|
||||
assert len(hits) >= 1
|
||||
Reference in new issue
Block a user