itachi023/Markdown-Layout-Extractor
<p> <div align="center"> <h1> PDF to Markdown MCP <br /> <br /> <a href=""> <img src="https://img.shields.io/badge/python%20%7C%203.12-blue" alt="Python 3.12" /> </a> <a href="https://github.com/astral-sh/uv"> <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv" /> </a> <a href="https://modelcontextprotocol.io/"> <img src="https://img.shields.io/badge/MCP-FastMCP-6C47FF" alt="FastMCP" /> </a> <a href="https://mistral.ai/"> <img src="https://img.shields.io/badge/Mistral%20AI-FF7000?logoColor=white" alt="Mistral AI" /> </a> <a href="https://www.starlette.io/"> <img src="https://img.shields.io/badge/Starlette-ASGI-009688" alt="Starlette" /> </a> <a href="https://www.uvicorn.org/"> <img src="https://img.shields.io/badge/Uvicorn-server-4051B5" alt="Uvicorn" /> </a> <a href="https://loguru.readthedocs.io/"> <img src="https://img.shields.io/badge/Loguru-logging-FF6B6B" alt="Loguru" /> </a> </h1> </div> </p>
An MCP (Model Context Protocol) server that converts PDFs and documents into Markdown using Mistral OCR.
Features
- `pdf_to_markdown` — Convert any publicly accessible PDF/document URL to merged Markdown.
- `pdf_to_structured_markdown` — Convert and get per-page structured output (page index, individual markdown, merged result).
- CORS-enabled SSE transport — connect from any MCP client or inspector.
/healthendpoint for liveness probing.- Structured, colorized logging via Loguru.
Project Structure
pdf_to_md_mcp/
├── main.py # Entry point — uvicorn runner
├── pyproject.toml
├── sample.env # Secrets reference (copy to .env)
├── development.yml # Non-secret config (server, CORS, OCR model)
└── app/
├── server.py # ASGI app factory (MCP + CORS + health)
├── core/
│ ├── config.py # Pydantic settings (loads .env + development.yml)
│ ├── logger.py # Loguru logger
│ ├── lifespan.py # AppContext + Mistral client lifecycle
│ └── exceptions.py # Domain exceptions
├── services/
│ └── ocr_service.py # Mistral OCR business logic
├── tools/
│ └── markdown_tools.py # @mcp.tool() definitions
└── utils/
├── response.py # create_response() helper
└── validators.py # URL validationSetup
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install dependencies
uv sync
# Configure secrets
cp sample.env .env
# Edit .env — set MISTRAL_API_KEY
# Non-secret config (server, CORS, OCR model) lives in development.ymlRun
uv run main.pyServer starts at http://127.0.0.1:8000 by default.
MCP Tools
pdf_to_markdown
Convert a document URL to merged Markdown (all pages concatenated).
Input
Returns — string
# Introduction
This paper presents...
## Section 2
...pdf_to_structured_markdown
Convert a document URL and get per-page structured output alongside the merged result.
Input
Returns — object
{
"page_count": 3,
"pages": [
{ "index": 0, "markdown": "# Page 1\n..." },
{ "index": 1, "markdown": "## Page 2\n..." },
{ "index": 2, "markdown": "### Page 3\n..." }
],
"markdown": "# Page 1\n...\n\n## Page 2\n...\n\n### Page 3\n..."
}Debugging with MCP Inspector
npx -y @modelcontextprotocol/inspectorConnect to http://127.0.0.1:8000/sse locally or your Railway URL in production.
Deploy to Railway
1. Push to GitHub
git init
git add .
git commit -m "initial commit"
gh repo create pdf-to-md-mcp --public --source=. --push2. Create a Railway project
Go to railway.app → New Project → Deploy from GitHub repo → select your repo.
Railway detects the railway.json and uses uv run main.py as the start command automatically.
3. Set environment variables
In Railway → your service → Variables, add:
PORTis injected automatically by Railway — do not set it manually. All other config (MISTRAL_OCR_MODEL,LOG_LEVEL, etc.) is read fromdevelopment.yml.
4. Deploy
Railway triggers a deploy on every push to your default branch. Once live, your public SSE URL will be:
https://<your-service>.up.railway.app/sseUse that URL in any MCP client or pass it to the inspector:
npx -y @modelcontextprotocol/inspector
# connect to: https://<your-service>.up.railway.app/sseWhy it works
- Railway injects
PORTas an env var — pydantic-settings reads env vars beforedevelopment.yml, so it's picked up automatically. HOST=0.0.0.0(set via Railway Variables) overrides the local127.0.0.1default so the container is reachable.proxy_headers=Trueinmain.pymakes uvicorn trust Railway'sX-Forwarded-*headers./healthis set as Railway's healthcheck path inrailway.json.
Configuration
Configuration is split across two files to separate secrets from non-sensitive settings.
.env — Secrets only
MISTRAL_API_KEY=your_mistral_api_key_heredevelopment.yml — Non-secret config
# Mistral
MISTRAL_OCR_MODEL: mistral-ocr-latest
MISTRAL_TABLE_FORMAT: markdown
# Server
APP_NAME: "Markdown & Layout Extractor"
HOST: "127.0.0.1"
PORT: 8000
LOG_LEVEL: INFO
# CORS
CORS_ALLOW_ORIGINS:
- "*"
CORS_ALLOW_METHODS:
- "*"
CORS_ALLOW_HEADERS:
- "*"Priority (highest → lowest): environment variables → .env → development.yml
All settings
Design Notes
- Single Starlette app —
sse_app()is the sole ASGI application; the health route and CORS middleware are injected directly onto it to prevent double-middleware stacking (which causes thehttp.response.startcrash). - Separation of concerns — Tools are thin wrappers around
OCRService; business logic is independently testable. - Lifespan-managed client — The Mistral client is initialized once at startup and shared across all tool calls.
- Loguru logging — Structured, colorized logs across all layers via Loguru.
- Pydantic Settings — Type-safe,
.env-driven configuration with an LRU-cached singleton.
