chore: initial commit
This commit is contained in:
+13
@@ -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"]
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
mcp[cli]>=1.6.0
|
||||||
|
httpx>=0.27
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user