Files
mcp-docling/server.py
T
2026-05-21 07:30:49 +02:00

176 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SAIA Docling MCP server — document → Markdown/HTML/JSON conversion.
Wraps GWDG SAIA's Docling endpoint (POST /v1/documents/convert).
Async queue: large PDFs (OCR + image extraction) can take minutes and would
blow past LLM client tool-call timeouts. `convert_document` submits the job and
waits up to CONVERT_DEADLINE seconds inline; if it's still running it returns a
job_id to poll with `get_job_status`. The conversion keeps running in the
background regardless (asyncio.shield), so the result is never lost.
Defaults chosen for LLM/RAG ingestion:
response_type = markdown (best for embedding / chat context)
extract_tables_as_images = False (tables stay structured, not pictures)
image_resolution_scale = 2 (images kept, reasonable size)
"""
import asyncio
import base64
import os
import time
import uuid
from urllib.parse import urlparse
import httpx
from mcp.server.fastmcp import FastMCP
API_KEY = os.environ.get("SAIA_API_KEY", "")
API_ENDPOINT = os.environ.get(
"DOCLING_ENDPOINT", "https://chat-ai.academiccloud.de/v1/documents/convert"
)
CONVERT_DEADLINE = int(os.environ.get("CONVERT_DEADLINE", 60))
_VALID_TYPES = ("markdown", "html", "json", "tokens")
mcp = FastMCP("saia-docling", host="0.0.0.0", port=8000, stateless_http=True)
# job_id -> {status, result, error, submitted_at, filename}
_jobs: dict[str, dict] = {}
_tasks: set[asyncio.Task] = set()
async def _do_convert(filename: str, content: bytes, params: dict) -> dict:
headers = {"accept": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
async with httpx.AsyncClient(timeout=httpx.Timeout(600, connect=10)) as c:
r = await c.post(
API_ENDPOINT,
params=params,
headers=headers,
files={"document": (filename, content)},
)
if r.status_code >= 400:
raise RuntimeError(f"Docling API {r.status_code}: {r.text[:400]}")
return r.json()
async def _run_job(job_id, filename, content, params, response_type):
try:
data = await _do_convert(filename, content, params)
_jobs[job_id].update(
status="done",
result={
"response_type": data.get("response_type", response_type.upper()),
"filename": data.get("filename", filename),
response_type: data.get(response_type, data.get("markdown", "")),
"images": data.get("images", []),
},
)
except Exception as e: # noqa: BLE001
_jobs[job_id].update(status="error", error=str(e)[:500])
def _result(job_id: str) -> dict:
j = _jobs[job_id]
if j["status"] == "done":
return {"job_id": job_id, "status": "done", **j["result"]}
if j["status"] == "error":
return {"job_id": job_id, "status": "error", "error": j["error"]}
return {
"job_id": job_id,
"status": "running",
"elapsed_s": int(time.time() - j["submitted_at"]),
}
@mcp.tool()
async def convert_document(
source: str,
filename: str = "document",
response_type: str = "markdown",
extract_tables_as_images: bool = False,
image_resolution_scale: int = 2,
) -> dict:
"""Convert a document (PDF, DOCX, PPTX, images, …) to Markdown/HTML/JSON.
Submits the job and waits inline up to ~60s. If conversion takes longer
(big/scanned PDFs), returns a job_id — call `get_job_status(job_id)` to
retrieve the result when ready (it keeps processing in the background).
Args:
source: An http(s) URL to the document, OR its base64-encoded bytes.
filename: Name (with extension) for the upload; aids format detection.
response_type: "markdown" (default), "html", "json", or "tokens".
extract_tables_as_images: False (default) keeps tables as structured
text; True returns them as images.
image_resolution_scale: Image scaling factor 14 (default 2).
Returns:
dict with status "done" (incl. the converted text under its format key
plus "images"), or status "running" with a job_id to poll.
"""
if response_type not in _VALID_TYPES:
raise ValueError(f"response_type must be one of {_VALID_TYPES}")
if image_resolution_scale not in (1, 2, 3, 4):
raise ValueError("image_resolution_scale must be 1, 2, 3, or 4")
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as c:
resp = await c.get(source)
resp.raise_for_status()
content = resp.content
if filename == "document":
filename = urlparse(source).path.split("/")[-1] or "document"
else:
try:
content = base64.b64decode(source, validate=True)
except Exception as e:
raise ValueError(
"source is neither an http(s) URL nor valid base64"
) from e
params = {
"response_type": response_type,
"extract_tables_as_images": str(extract_tables_as_images).lower(),
"image_resolution_scale": str(image_resolution_scale),
}
job_id = f"doc-{uuid.uuid4().hex[:8]}"
_jobs[job_id] = {
"status": "running", "result": None, "error": None,
"submitted_at": time.time(), "filename": filename,
}
if len(_jobs) > 200:
for k in sorted(_jobs, key=lambda k: _jobs[k]["submitted_at"])[:50]:
_jobs.pop(k, None)
task = asyncio.create_task(
_run_job(job_id, filename, content, params, response_type)
)
_tasks.add(task)
task.add_done_callback(_tasks.discard)
try:
await asyncio.wait_for(asyncio.shield(task), timeout=CONVERT_DEADLINE)
except asyncio.TimeoutError:
return {
"job_id": job_id,
"status": "running",
"note": (
f"'{filename}' still converting after {CONVERT_DEADLINE}s. "
f"Call get_job_status('{job_id}') to retrieve it when ready."
),
}
return _result(job_id)
@mcp.tool()
async def get_job_status(job_id: str) -> dict:
"""Retrieve a previously submitted conversion job by its job_id."""
if job_id not in _jobs:
return {"error": f"unknown job_id '{job_id}'"}
return _result(job_id)
if __name__ == "__main__":
mcp.run(transport="streamable-http")