From cf451ad7844ff82eaf2d5d23f61db410f64884ad Mon Sep 17 00:00:00 2001 From: fkrebs Date: Thu, 21 May 2026 07:30:49 +0200 Subject: [PATCH] chore: initial commit --- Dockerfile | 13 +++ __pycache__/server.cpython-313.pyc | Bin 0 -> 9392 bytes requirements.txt | 2 + server.py | 175 +++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 Dockerfile create mode 100644 __pycache__/server.cpython-313.pyc create mode 100644 requirements.txt create mode 100644 server.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea5015b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py ./ + +EXPOSE 8000 + +# FastMCP streamable-HTTP endpoint at /mcp on :8000 +CMD ["python", "server.py"] diff --git a/__pycache__/server.cpython-313.pyc b/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f4acb4a7eb01e70826f3d9ed2bf4907a5fb7985 GIT binary patch literal 9392 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.6.0 +httpx>=0.27 diff --git a/server.py b/server.py new file mode 100644 index 0000000..8bbced6 --- /dev/null +++ b/server.py @@ -0,0 +1,175 @@ +"""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 1–4 (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")