pdf-extract-mcp
# pdf-extract-mcp




A Model Context Protocol (MCP) server that extracts structured data from
unstructured PDF documents **deterministically** — plain text extraction plus
regex/heuristic field matching, no LLM API calls at extraction time.
## Features
- **Real MCP server** — built on the official MCP Python SDK (2.x), speaking
the protocol over stdio, SSE, or streamable HTTP. Verified by an
end-to-end test that drives the actual server with the official client.
- **Schema-driven extraction** — point extract_fields at any JSON Schema and
get back structured JSON for exactly the fields you asked for.
- **Deterministic and inspectable** — regex/heuristic matching, no LLM API
calls, no hidden costs, no black box. Every extraction is repeatable and
auditable.
- **Human-readable validation reports** — validate_against_schema explains
per field why it passed, failed, or is missing.
- **Pre-built schemas** — invoice, resume, and purchase_order ship ready to
use, plus synthetic sample PDFs so everything is demonstrable out of the
box.
- **Graceful errors** — corrupted PDFs, missing files, and bad schemas return
structured errors, never stack traces.
## What is MCP and why this is useful
Model Context Protocol is an open standard that lets AI assistants (Claude,
Cursor, etc.) call external tools over a persistent, bidirectional connection.
Instead of pasting PDF text into a chat and asking the model to "figure it
out", an assistant can call pdf-extract-mcp directly, receive *structured
JSON* matching a schema you supply, and act on it. Because extraction here is
deterministic (regex + heuristics) — not a probabilistic model call — every
result is inspectable, repeatable, and cheap. That makes it ideal for
automated document pipelines (invoices to accounting, resumes to ATS, POs to
procurement) where you need to know *why* a field was extracted a certain way.
## Install
~~~bash
cd pdf-extract-mcp
python3 -m venv .venv
source .venv/bin/activate
make install # pip install -e ".[dev]" (installs the console script too)
~~~
or, with plain pip:
~~~bash
pip install -e ".[dev]"
~~~
The server uses the official MCP Python SDK (mcp >= 2.x, the current release
line, which provides the MCPServer API). pdfplumber handles text extraction,
jsonschema handles validation, and reportlab generates the sample PDFs.
Installing also provides a pdf-extract-mcp console script, so you can run the
server from anywhere with:
~~~bash
pdf-extract-mcp # stdio (default)
pdf-extract-mcp --transport streamable-http --host 127.0.0.1 --port 8000
~~~
## Run
~~~bash
python server.py
~~~
This serves MCP over **stdio** (the default, and what Claude Code / Claude
Desktop expect). You can also expose it as a network service:
~~~bash
python server.py --transport streamable-http --host 127.0.0.1 --port 8000
python server.py --transport sse --host 127.0.0.1 --port 8001
~~~
## Connect to Claude Code / Claude Desktop
**Claude Code** — add a .mcp.json to your project root:
~~~json
{
"mcpServers": {
"pdf-extract": {
"command": "python",
"args": ["/absolute/path/to/pdf-extract-mcp/server.py"],
"env": {}
}
}
}
~~~
**Claude Desktop** — add the same block to your Claude Desktop config
(claude_desktop_config.json, found under
~/Library/Application Support/Claude/ on macOS):
~~~json
{
"mcpServers": {
"pdf-extract": {
"command": "python",
"args": ["/absolute/path/to/pdf-extract-mcp/server.py"]
}
}
}
~~~
Restart the client after saving. You should see three new tools:
extract_fields, validate_against_schema, and list_supported_document_types.
## Tools
| Tool | Purpose |
|------|---------|
| extract_fields(pdf_path, schema) | Pull structured fields from a PDF matching a JSON Schema -> {"ok": true, "data": {...}} |
| validate_against_schema(data, schema) | Check extracted data against a schema -> pass/fail/missing report with human-readable reasons |
| list_supported_document_types() | List document types that ship with pre-built schemas |
The schema argument of extract_fields accepts a JSON Schema object, a
built-in schema **name** (e.g. "invoice"), or a path to a .json schema file.
Built-in schemas live in schemas/:
- **invoice** — vendor_name, invoice_number, total_amount, due_date
(required) + issue_date, customer_name
- **resume** — name, email (required) + phone, skills
- **purchase_order** — po_number, vendor_name, total_amount (required) +
issue_date, customer_name
## Worked example
First generate the sample PDFs (already present in the repo; regenerate any
time with):
~~~bash
python sample_pdfs/generate_samples.py
~~~
Now call extract_fields on the sample invoice using the built-in invoice
schema name. In Claude Code you can just say "extract the fields from
sample_pdfs/invoice.pdf using the invoice schema"; underneath it issues a
tool call equivalent to:
~~~json
{
"name": "extract_fields",
"arguments": {
"pdf_path": "/absolute/path/to/pdf-extract-mcp/sample_pdfs/invoice.pdf",
"schema": "invoice"
}
}
~~~
**Actual expected result:**
~~~json
{
"ok": true,
"data": {
"vendor_name": "Acme Widgets Corp",
"invoice_number": "INV-2024-0087",
"total_amount": 1750.0,
"due_date": "April 1, 2024",
"issue_date": "March 1, 2024",
"customer_name": "Globex Industries"
},
"text_length": 372
}
~~~
Feeding data into validate_against_schema with the same schema:
~~~json
{
"ok": true,
"valid": true,
"passed": ["customer_name", "due_date", "invoice_number", "issue_date", "total_amount", "vendor_name"],
"failed": [],
"missing": [],
"summary": "Valid: all 6 present field(s) conform to the schema.",
"error": null
}
~~~
Run these from Python directly to see it live:
~~~python
import json
from tools.extract import extract_fields
from tools.validate import validate_against_schema
schema = json.load(open("schemas/invoice.json"))
result = extract_fields("sample_pdfs/invoice.pdf", schema)
print(result["data"])
print(validate_against_schema(result["data"], schema))
~~~
## How MCP tool registration works in server.py
This is the heart of the project, so it is worth understanding exactly what
the SDK does on your behalf.
**1. Create the server object.**
~~~python
from mcp.server.mcpserver import MCPServer
mcp = MCPServer(
"pdf-extract-mcp",
title="PDF Extract MCP",
description="Deterministic structured-data extraction from PDF documents",
version="0.2.0",
)
~~~
MCPServer is the mcp SDK 2.x server class. It implements the MCP wire
protocol: it knows how to answer the JSON-RPC messages a client sends during
the MCP handshake (initialize, tools/list, tools/call, and so on). The
constructor arguments are metadata — the server *name* (required for the
protocol handshake) plus optional title/description/version that clients may
surface to the user.
**2. Register each tool with a decorator.**
~~~python
@mcp.tool()
def extract_fields(pdf_path: str, schema: dict) -> dict:
"""Extract structured fields from an unstructured PDF ..."""
return _extract_fields(pdf_path, schema)
~~~
The decorator does three jobs for you:
- **Name registration** — the function name extract_fields becomes the tool
name a client uses to invoke it. (You can override it with
@mcp.tool(name="...").
- **Schema inference** — the SDK inspects the function's type annotations
(pdf_path: str, schema: dict) and generates the tool's JSON input schema
automatically. That is why the MCP client knows, before calling, that
pdf_path is a string and schema is an object. This is the same pattern
FastAPI uses — types *are* the contract.
- **Description** — the docstring becomes the tool's description, which
Claude reads to decide when to call the tool and with what arguments.
So when a client asks the server "what can you do?" (tools/list), the SDK
responds with the name, description, and inferred input schema for each
decorated function — no manual registration table to keep in sync.
**3. The function body is just Python.**
When a client calls the tool (tools/call with arguments), the SDK
deserializes the JSON arguments, calls your function with them, and
serializes the return value back over the wire. The return value is what the
client sees — which is why the tools always return plain JSON-able dicts and
never raise: an exception would become an opaque protocol error, while a
structured {"ok": false, "error": "..."} dict is something Claude can read
and react to. The actual extraction/validation logic lives in
tools/extract.py and tools/validate.py so it stays unit-testable without an
MCP client.
**4. Run it.**
~~~python
if __name__ == "__main__":
main() # argparse -> mcp.run(transport="stdio")
~~~
mcp.run(transport="stdio") starts the protocol loop: it reads
newline-delimited JSON-RPC requests from stdin, dispatches them to the
registered tools, and writes responses to stdout. That is the entire server —
no HTTP framework, no routes, no manual request handling. (For
streamable-http / sse, the same run() call starts an internal ASGI app.)
One more detail worth noting: extract_fields uses a tiny helper _load_schema
that accepts a schema dict, a built-in schema name, or a file path — so the
same tool works with "invoice" or a full schema object. The actual extraction
function stays strict (dict only) and the server layer handles the
convenience conversions.
## How extraction works (deterministic, inspectable)
1. **Text extraction** — pdfplumber opens the PDF and pulls the plain text
from every page.
2. **Field matching** — for each property in your schema, an ordered list of
regexes is tried; the first match wins (tools/extract.py ->
_FIELD_PATTERNS). Patterns are most-specific-first, and unknown field
names fall back to a generic "Field Name: value" match, plus a synonym
table (_FIELD_ALIASES).
3. **Type coercion** — matched strings are coerced to the JSON Schema type
(e.g. "$1,750.00" -> 1750.0 for "type": "number"; comma-split for arrays).
Coercion failures fall back to the raw string rather than losing data.
4. **Validation** — validate_against_schema re-checks the extracted data
with the jsonschema package and reports, per field, whether it passed,
failed (with a human-readable reason), or is missing entirely.
Because every step is plain code, you can trace exactly why a field was or
wasn't extracted — no black box.
## Error handling
All three tools return structured JSON on every path — they never raise a
stack trace across the MCP boundary:
- Corrupted/unreadable PDF -> {"ok": false, "error": "Could not read PDF ..."}
- Missing file -> {"ok": false, "error": "PDF not found: ..."}
- PDF with no extractable text -> {"ok": false, "error": "... contains no extractable text."}
- Invalid schema (empty, no properties, or invalid JSON Schema) -> structured error key
- Missing required fields -> listed in "missing"; malformed values -> listed in "failed" with reasons
## Tests
~~~bash
pytest tests/ -v
~~~
19 tests covering:
- Successful extraction for all three document types (invoice, resume,
purchase_order)
- A PDF missing required fields (negative extraction)
- Schema validation catching a malformed field type, missing required
fields, enum/pattern violations
- Error paths: corrupted PDF, nonexistent file, textless PDF, invalid schema
- A real end-to-end MCP test (tests/test_mcp_end_to_end.py) that spawns
server.py as a subprocess, connects over stdio with the official MCP
client, and calls all three tools over the wire — proving this is a genuine
MCP server, not a library pretending to be one
Sample PDFs are auto-regenerated by tests/conftest.py if missing.
## Repository layout
~~~
pdf-extract-mcp/
server.py # MCP server: MCPServer + tool registration + transports
tools/
__init__.py
extract.py # pdfplumber text extraction + regex field matching
validate.py # jsonschema validation with structured reports
schemas/
invoice.json # pre-built schema: invoice
resume.json # pre-built schema: resume
purchase_order.json # pre-built schema: purchase_order
sample_pdfs/
generate_samples.py # reportlab generator for the 4 sample PDFs
invoice.pdf
invoice_missing_fields.pdf
resume.pdf
purchase_order.pdf
tests/
conftest.py # auto-generates sample PDFs if missing
test_tools.py # unit tests for extract/validate
test_mcp_end_to_end.py # end-to-end test over the real MCP stdio transport
README.md
requirements.txt
~~~
## Troubleshooting
- **ModuleNotFoundError: No module named 'mcp'** — you are not in the
virtualenv: source .venv/bin/activate (or use ./.venv/bin/python
server.py).
- **FastMCP import errors** — server.py targets the mcp **2.x** API
(MCPServer). If your environment has mcp 1.x, reinstall with
pip install -U "mcp>=2.0".
- **Tools not showing up in Claude** — restart the client after editing the
config, and make sure "args" points at the absolute path to server.py,
using the venv's python as the command if needed.
- **Extraction misses a field** — add a pattern for it in _FIELD_PATTERNS in
tools/extract.py (or rely on the generic "Field Name: value" fallback and
the synonym table).
TDQS
Scored across 3 tools
Each tool serves a clearly distinct purpose: listing supported schemas, extracting fields from a PDF, and validating extracted data against a schema. There is no overlap or ambiguity in tool responsibilities.
All tool names follow a consistent verb_noun pattern in snake_case: list_supported_document_types, extract_fields, validate_against_schema. The convention is uniform and predictable.
With only 3 tools, the set is at the lower end of the well-scoped range but still appropriate for a focused PDF extraction and validation server. Each tool earns its place without redundancy.
The tool surface covers the core workflow: discovering available schemas, extracting fields, and validating results. Minor gaps exist (e.g., no tool to add custom schemas or handle batch processing), but they are not critical for the primary purpose.