Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ services:
profiles:
- unstructured

# docling-serve: OCR-strong document parsing (photographed / scanned /
# handwritten text). Opt-in via the "docling" profile. Point the MCP server at
# it with ENABLE_DOCLING=true + DOCLING_API_URL=http://docling:5001 (and/or
# DOCUMENT_OCR_PROVIDER=docling for scanned-PDF OCR). CPU image; the first run
# downloads models and CPU OCR is slow.
docling:
image: quay.io/docling-project/docling-serve:latest@sha256:56189f190892cc03a4a5c10ba9e56c421ac2524554aa2d36f0f4bb6564132524
restart: always
ports:
- 127.0.0.1:5001:5001
profiles:
- docling

mcp:
build: .
restart: always
Expand Down
97 changes: 97 additions & 0 deletions docs/ADR-031-docling-document-parsing-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# ADR-031: Docling document-parsing backend (docling-serve)

## Status

Accepted — 2026-07-01

## Context

Files read via WebDAV (`nc_webdav_read_file`) are returned as base64, decoded
UTF-8 text, or — when document processing is enabled — text extracted by the
pluggable processor registry (`document_processors/`). The registered HTTP OCR
option, `unstructured`, does poorly on photographed, scanned and especially
**handwritten** documents.

[docling](https://github.com/docling-project/docling) has substantially stronger
OCR for exactly that content and is deployable as a standalone HTTP service,
[docling-serve](https://github.com/docling-project/docling-serve). We want to use
external docling-serve instances (per `DOCLING_API_URL`) alongside `unstructured`
without adding ML dependencies to the MCP server image and without regressing the
existing PDF pipeline.

Relevant existing architecture (reused, not replaced):

- The registry has **two independent routing paths**: `find_processor()` picks by
**priority** for images/non-PDF; `_process_pdf()` runs a tiered pipeline
(`fast → structured → ocr`) for PDFs, with the `ocr` tier gated on
`document_ocr_enabled`.
- `classifier.classify_from_text()` already **auto-detects a missing/unusable PDF
text layer** (scanned → recommend `ocr`), so born-digital PDFs stay on the cheap
local tiers and only genuine scans reach OCR.
- The `ocr` tier (`OcrProcessor`) already has **pluggable backends** (`_OcrBackend`:
gateway, Mistral) selected by `document_ocr_provider`, returning
`(text, page_boundaries, block_spans)`.
- `registry.process(processor_name=...)` already supports a **forced processor**
that bypasses tiering and MIME auto-selection.

## Decision

Add docling at three touchpoints, sharing one docling-serve HTTP client
(`document_processors/docling_serve.py`). `POST /v1/convert/file` (synchronous
multipart) is the client; `GET /health` is the probe.

1. **Images → docling (automatic).** A standalone `DoclingProcessor`
(`supported_mime_types` = images only, `tier="fast"`) is registered at
**priority 20** (above `unstructured`'s 10) in `initialize_document_processors()`
when `ENABLE_DOCLING=true` **and** `DOCLING_API_URL` is set. Images therefore
always route to docling when enabled. Gated only by `ENABLE_DOCUMENT_PROCESSING`
+ `ENABLE_DOCLING` (the image/`find_processor` path has no OCR gate).

2. **Scanned PDFs → docling (automatic, opt-in).** A `_DoclingServeBackend`
(`_OcrBackend`) is added to `ocr.py` and selected by
`DOCUMENT_OCR_PROVIDER=docling`. With `DOCUMENT_OCR_ENABLED=true`, PDFs the
classifier flags as scanned/no-text-layer escalate to the OCR tier and are
transcribed by docling. It requests `to_formats=json,md` and reconstructs
per-page `page_boundaries` by grouping `DoclingDocument.texts[].prov[].page_no`,
falling back to a single whole-text page when provenance is absent.

3. **Text-layer PDFs → docling (on demand).** `nc_webdav_read_file` gains a
`force_processor` argument threaded through `parse_document(processor_name=...)`
to the registry's forced path. `force_processor="docling"` re-parses any file
with docling even when it has a usable text layer — for tables/figures the text
layer misses. `DoclingProcessor.process()` handles PDFs (deriving
`from_formats` from the MIME type) even though PDFs are excluded from its
`supported_mime_types` (the forced path ignores `supports()`). An unknown/
unconfigured processor name raises a `ToolError` with the available names.

### Key design points

- **Images-only `supported_mime_types` + `tier="fast"`.** Keeps docling out of the
automatic PDF tier selection (`_pdf_processor_for_tier` matches on tier **and**
`supports("application/pdf")`), so enabling docling never reroutes every PDF
through it or collides with the existing `ocr` tier. PDFs reach docling only via
touchpoint 2 (OCR backend) or touchpoint 3 (explicit force).
- **`auto` never selects docling.** docling needs an explicit self-hosted URL, so
it is chosen only by `DOCUMENT_OCR_PROVIDER=docling`.
- **Registration guarded on `DOCLING_API_URL`.** A bare `ENABLE_DOCLING` without a
URL registers nothing, so it can't shadow other image processors with a dead
endpoint (mirrors the custom-processor guard).
- **`block_spans` left empty.** docling's block bboxes don't follow the gateway's
normalized `[0,1]` contract, so highlighting falls back to pymupdf (same as the
Mistral backend).
- **Office formats stay with `unstructured`** — intentional non-goal; docling is
scoped to images/scans/handwriting here.
- **Synchronous only.** docling-serve's sync convert has a ~2 min server ceiling;
async submit/poll (`/v1/convert/file/async`) is future work for very large scans.

## Consequences

- New env: `ENABLE_DOCLING`, `DOCLING_API_URL`, `DOCLING_TIMEOUT`,
`DOCLING_OCR_LANG`, `DOCLING_DO_OCR`; `docling` added to the
`document_ocr_provider` enum. A `docling` docker-compose profile runs
docling-serve for testing.
- OCR language codes are engine-dependent (docling default EasyOCR: `en,de`;
Tesseract-backed: `eng,deu`) — operator-tunable, documented, not hard-coded.
- No new Python dependencies (HTTP via the existing `httpx`).
- Existing behavior is unchanged when `ENABLE_DOCLING` is unset and
`DOCUMENT_OCR_PROVIDER` ≠ `docling`.
57 changes: 53 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,19 +633,68 @@ model, and execution mode:

```dotenv
DOCUMENT_OCR_ENABLED=true # route scanned/no-text-layer PDFs to OCR (default: false)
DOCUMENT_OCR_PROVIDER=auto # "auto" | "gateway" | "mistral" | "none"
DOCUMENT_OCR_PROVIDER=auto # "auto" | "gateway" | "mistral" | "docling" | "none"
DOCUMENT_OCR_MODEL=mistral/mistral-ocr-latest # provider-namespaced model id
```

- **`DOCUMENT_OCR_PROVIDER`** selects the backend: `gateway` posts to the Astrolabe
Cloud model gateway's `POST /v1/ocr` (no provider keys in the pod; the gateway
routes on the model's `<provider>/` prefix, so it serves Mistral, surya, etc.);
`mistral` calls the Mistral OCR API directly (`MISTRAL_API_KEY`); `auto` prefers
the gateway (if `EMBEDDING_GATEWAY_URL` is set) then direct Mistral; `none`
disables OCR.
`mistral` calls the Mistral OCR API directly (`MISTRAL_API_KEY`); `docling`
posts scanned/no-text-layer PDFs to a self-hosted docling-serve instance
(`DOCLING_API_URL`); `auto` prefers the gateway (if `EMBEDDING_GATEWAY_URL` is
set) then direct Mistral (`auto` never selects docling — it needs an explicit
self-hosted URL); `none` disables OCR.
- **`DOCUMENT_OCR_MODEL`** is the provider-namespaced model id — e.g.
`mistral/mistral-ocr-latest` (Mistral) or `surya/surya-ocr-2` (surya, via the
gateway). The gateway routes on the prefix; the direct Mistral backend strips it.
(Ignored by the `docling` backend, which uses the docling-serve instance's own
OCR engine.)

#### Docling (docling-serve) — photographed / scanned / handwritten text

[docling](https://github.com/docling-project/docling) has notably stronger OCR
than `unstructured` for photographed, scanned and **handwritten** documents. The
MCP server talks to an external
[docling-serve](https://github.com/docling-project/docling-serve) instance over
HTTP — no ML dependencies are added to the server image. Run one via the
`docling` docker-compose profile (`docker compose --profile docling up -d`).

```dotenv
ENABLE_DOCLING=false # master switch for the docling touchpoints
DOCLING_API_URL=http://docling:5001 # docling-serve base URL (required)
DOCLING_TIMEOUT=120 # image/force conversion timeout (seconds)
DOCLING_OCR_LANG=en,de # engine-dependent codes (EasyOCR: en,de; Tesseract: eng,deu)
DOCLING_DO_OCR=true # run OCR (vs. text-layer extraction only)
```

Docling plugs in at three points:

- **Images (automatic).** With `ENABLE_DOCLING=true` + `DOCLING_API_URL` set,
image files (`image/jpeg`, `image/png`, `image/tiff`, `image/bmp`, `image/gif`,
`image/webp`) always route to docling — it registers at a higher priority than
`unstructured`. `DOCLING_DO_OCR` toggles OCR on this image path only (the scanned-PDF
OCR backend always OCRs). Requires
`ENABLE_DOCUMENT_PROCESSING=true`. If `DOCLING_API_URL` is unset the processor is
not registered, so a bare `ENABLE_DOCLING` never shadows other image processors
with a dead endpoint.
- **Scanned PDFs (automatic).** Set `DOCUMENT_OCR_ENABLED=true` +
`DOCUMENT_OCR_PROVIDER=docling`. PDFs whose text layer the tier-0 classifier
finds missing/unusable escalate to the OCR tier and are transcribed by docling.
Born-digital (text-layer) PDFs still use the cheap local `fast`/`structured`
tiers — docling is only paid for genuine scans.
- **Text-layer PDFs (on demand).** Pass `force_processor="docling"` to the
`nc_webdav_read_file` MCP tool to re-parse *any* file with docling even when it
already has a text layer — useful when that layer misses tables/figures or is
incomplete. Docling returns markdown, preserving table structure. An unknown or
unconfigured processor name returns a clear tool error.

Office formats (DOCX/PPTX/XLSX) deliberately stay with `unstructured` — docling
is scoped to the image/scan/handwriting use case here. OCR language codes are
engine-dependent: the docling-serve default engine (EasyOCR) uses two-letter
codes (`en,de`); a Tesseract-backed instance wants `eng,deu`. The synchronous
convert endpoint has a server-side ceiling (~2 min); very large scans are future
work (async submit/poll). See `docs/ADR-031-docling-document-parsing-backend.md`.

#### OCR execution mode: synchronous vs batch (Deck #332)

Expand Down
16 changes: 16 additions & 0 deletions env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,22 @@ NEXTCLOUD_PASSWORD=
#CUSTOM_PROCESSOR_API_KEY=
#CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
#
# Docling (docling-serve, strong OCR for photographed/scanned/handwritten text):
# - Images always route to docling when enabled (priority over unstructured).
# - Force docling on a text-layer PDF (tables / partial text) per call via the
# nc_webdav_read_file `force_processor="docling"` argument.
# - Route scanned/no-text-layer PDFs to docling automatically by additionally
# setting DOCUMENT_OCR_ENABLED=true and DOCUMENT_OCR_PROVIDER=docling.
# OCR language codes are engine-dependent (docling default EasyOCR uses en,de;
# a Tesseract-backed instance wants eng,deu).
#ENABLE_DOCLING=false
#DOCLING_API_URL=http://docling:5001
#DOCLING_TIMEOUT=120
#DOCLING_OCR_LANG=en,de
#DOCLING_DO_OCR=true
#DOCUMENT_OCR_ENABLED=false
#DOCUMENT_OCR_PROVIDER=docling

# ===== SSL/TLS =====
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
Expand Down
26 changes: 25 additions & 1 deletion nextcloud_mcp_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,12 @@
HTTPXClientInstrumentor().instrument()


def initialize_document_processors():

Check failure on line 154 in nextcloud_mcp_server/app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=cbcoutinho_nextcloud-mcp-server&issues=AZ8ivorSE36x1qGAnyWV&open=AZ8ivorSE36x1qGAnyWV&pullRequest=1006
"""Initialize and register document processors based on configuration.

This function reads the environment configuration and registers available
processors (Unstructured, Tesseract, Custom HTTP) with the global registry.
processors (Unstructured, Tesseract, Custom HTTP, Docling) with the global
registry.
"""
config = get_document_processor_config()

Expand Down Expand Up @@ -256,6 +257,29 @@
except Exception as e:
logger.warning("Failed to register Custom processor: %s", e)

# Register Docling processor (docling-serve HTTP). High priority so images
# always route to docling when enabled; images-only for auto-selection, but
# force-selectable by name (e.g. to re-parse a text-layer PDF with tables).
if "docling" in config["processors"]:
docling_config = config["processors"]["docling"]
try:
from nextcloud_mcp_server.document_processors.docling_serve import ( # noqa: PLC0415
DoclingProcessor,
)

processor = DoclingProcessor(
api_url=docling_config["api_url"],
timeout=docling_config["timeout"],
ocr_lang=docling_config["ocr_lang"],
do_ocr=docling_config["do_ocr"],
progress_interval=docling_config.get("progress_interval", 10),
)
registry.register(processor, priority=20) # Above unstructured (10)
logger.info("Registered Docling processor: %s", docling_config["api_url"])
registered_count += 1
except Exception as e:
logger.warning("Failed to register Docling processor: %s", e)

if registered_count > 0:
logger.info(
"Document processing initialized with %s processor(s): %s",
Expand Down
54 changes: 51 additions & 3 deletions nextcloud_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@
"document_tier1_engine": "pypdfium2",
"document_ocr_enabled": False,
# OCR backend: "auto" picks gateway (if EMBEDDING_GATEWAY_URL) else mistral
# (if MISTRAL_API_KEY); "gateway"/"mistral" force one; "none" disables. The
# gateway routes on the model's "<provider>/" prefix, so it serves Mistral,
# (if MISTRAL_API_KEY); "gateway"/"mistral" force one; "docling" routes scanned
# PDFs to a docling-serve instance (DOCLING_API_URL); "none" disables. "auto"
# never selects docling (it needs a self-hosted URL, so it must be explicit).
# The gateway routes on the model's "<provider>/" prefix, so it serves Mistral,
# surya (in-cluster GPU over the tailnet), etc. — one configurable OCR tier.
"document_ocr_provider": "auto",
# Provider-namespaced OCR model id (gateway routes on the prefix; the direct
Expand Down Expand Up @@ -253,6 +255,23 @@
"custom_processor_name": "custom",
"custom_processor_api_key": None,
"custom_processor_timeout": 60,
# Docling document-parsing backend (docling-serve HTTP API). One docling-serve
# instance feeds two touchpoints: the images-only DoclingProcessor
# (find_processor path, priority 20) and — when DOCUMENT_OCR_PROVIDER=docling —
# the PDF OCR backend for scanned/no-text-layer PDFs. The same processor can be
# force-selected per call to parse a text-layer PDF (tables/partial text). URL
# unset -> the image processor is not registered and the OCR backend resolves to
# None. See ADR-031.
"enable_docling": False,
"docling_api_url": None,
"docling_timeout": 120,
# docling-serve OCR language codes. The default engine (EasyOCR) uses 2-letter
# codes ("en","de"); a Tesseract-backed instance wants "eng","deu". Engine-
# dependent, so keep it operator-tunable (see ADR-031).
"docling_ocr_lang": "en,de",
# Run OCR on IMAGES routed to the DoclingProcessor (find_processor path). The
# docling OCR *backend* (scanned PDFs) always OCRs regardless of this flag.
"docling_do_ocr": True,
# Tag-based file exclusion (issue #710): comma-separated list of
# Nextcloud system tag names. Files/folders carrying any of these tags
# are hidden from WebDAV MCP tools. Empty = feature off.
Expand Down Expand Up @@ -712,6 +731,23 @@ def get_document_processor_config() -> dict[str, Any]:
"supported_types": supported_types,
}

# Docling configuration (docling-serve HTTP API). Registered only when a URL is
# set, so a bare ENABLE_DOCLING doesn't shadow other image processors with a
# dead endpoint (mirrors the custom_url guard above). The standalone processor
# auto-serves images; PDFs go through the OCR backend (provider=docling) or an
# explicit per-call force_processor override.
if _dynaconf.get("ENABLE_DOCLING"):
docling_url = _dynaconf.get("DOCLING_API_URL")
if docling_url:
lang_str = _dynaconf.get("DOCLING_OCR_LANG") or ""
config["processors"]["docling"] = {
"api_url": docling_url,
"timeout": _dynaconf.get("DOCLING_TIMEOUT"),
"ocr_lang": [s.strip() for s in lang_str.split(",") if s.strip()],
"do_ocr": _dynaconf.get("DOCLING_DO_OCR"),
"progress_interval": _dynaconf.get("PROGRESS_INTERVAL"),
}

return config


Expand Down Expand Up @@ -995,6 +1031,13 @@ class Settings:
# fast->structured (pymupdf). 0 disables. See classifier._control_char_ratio.
document_glyph_corruption_ratio: float = 0.02

# Docling backend (docling-serve HTTP API). Shared by the images-only
# DoclingProcessor (find_processor path) and the PDF OCR backend
# (document_ocr_provider="docling"). docling_api_url unset -> the OCR backend
# resolves to None (docling OCR off) and the image processor is not registered.
docling_api_url: str | None = None
docling_ocr_lang: str = "en,de"

# Observability settings
metrics_enabled: bool = True
metrics_port: int = 9090
Expand Down Expand Up @@ -1162,7 +1205,7 @@ def __post_init__(self):
"mcp_role": {"api", "worker", "all"},
"collection_metadata_source": {"qdrant", "api"},
"document_tier1_engine": {"pypdfium2", "pymupdf"},
"document_ocr_provider": {"auto", "gateway", "mistral", "none"},
"document_ocr_provider": {"auto", "gateway", "mistral", "docling", "none"},
"document_ocr_mode": {"sync", "batch"},
}
for _field, _allowed in _enum_fields.items():
Expand Down Expand Up @@ -1738,6 +1781,11 @@ def get_settings() -> Settings:
"document_ocr_min_page_chars": "DOCUMENT_OCR_MIN_PAGE_CHARS",
"document_ocr_detect_scanned": "DOCUMENT_OCR_DETECT_SCANNED",
"document_glyph_corruption_ratio": "DOCUMENT_GLYPH_CORRUPTION_RATIO",
# Docling backend (shared by the OCR backend + images processor). Note:
# DOCLING_DO_OCR is intentionally NOT here -- it's read via dynaconf for the
# image processor only (the OCR backend always OCRs), like the unstructured_* keys.
"docling_api_url": "DOCLING_API_URL",
"docling_ocr_lang": "DOCLING_OCR_LANG",
# Observability settings
"metrics_enabled": "METRICS_ENABLED",
"metrics_port": "METRICS_PORT",
Expand Down
Loading