Skip to main content
Glama
AlanAAG

doc-extract

by AlanAAG

doc-extract

Ein MCP-Server, der genau eine Sache tut: PDF rein → das gesamte Dokument als validiertes strukturiertes JSON raus.

Gebaut für diesen Workflow:

[1] User drops a document
[2] doc-extract MCP  ← this repo. Reads the WHOLE document, returns JSON
[3] DB node          → insert into NeonDB          (separate node)
[4] Agent node       → chats over the NeonDB content (separate node)
[5] Or: the team acts on the JSON directly, with no DB at all

Die Schritte 3, 4 und 5 sind bewusst NICHT die Aufgabe dieses Servers. Er hat keinen Datenbanktreiber und jedes Tool ist schreibgeschützt.

Keine Datenbank. Keine Seiteneffekte. Keine Persistenz. Jedes Tool ist schreibgeschützt. Was als Nächstes passiert – Einfügen, Schwärzen, Weiterleiten, Benachrichtigen – ist ein separater Knoten im MagOneAI-Workflow.


Umfang, durchgesetzt und nicht nur behauptet

Dieser Server tut

Dieser Server tut NICHT

Liest die gesamte Textebene eines PDF

In eine Datenbank schreiben

Rekonstruiert Tabellengeometrie

E-Mails senden oder benachrichtigen

Repariert umgebrochene Zellen

Das PDF schwärzen oder verändern

Validiert das Gelesene

Entscheiden, was als Nächstes passiert

Gibt JSON + Koordinaten zurück

Etwas zwischen Aufrufen speichern

Durchsetzung, damit Scope-Creep strukturell schwer ist:

  • Alle drei Tools sind mit readOnlyHint: true, destructiveHint: false, idempotentHint: true annotiert. Ein Orchestrator kann sehen, dass ein erneuter Versuch sicher ist.

  • extract() ist eine reine Funktion der PDF-Bytes. Gleiche Eingabe → gleiche Ausgabe.

  • Das Neuladen von Profilen ist eine HTTP-Admin-Route, kein MCP-Tool. Konfigurationsänderungen sind eine Operator-Aktion; ein Workflow-Agent darf nicht die Wahl haben, eine solche durchzuführen.

  • Nichts wird auf die Festplatte geschrieben, außer einer temporären Datei für das eingehende PDF.


Related MCP server: MCP PDF Reader Server

Warum keine OCR

Beide Beispieldateien sind Crystal-Reports-Exporte aus SAP Business One – eingebettete Schriftarten, keine Rasterbilder. Jedes Zeichen trägt bereits exakte Seitenkoordinaten. OCR würde das rastern und diese Koordinaten mit Fehlern neu ableiten.

Die umgebrochene BP Ref. No. ist ein Layout-Rekonstruktionsproblem:

Zeile

Token

x0

x1

278.7

SI/08781/CN/

124

164

288.4

00007

124

142

00007 sitzt exakt am linken Rand der BP-Ref-Spalte → gleiche Zelle → SI/08781/CN/00007.

Am wichtigsten ist das bei der Nutripharm-Datei, wo Fragmente nackte Ziffern sind. Beim Lesen von Fließtext würde 111 plausibel an den Betrag angehängt und ergäbe -8.762,513111. Die Koordinaten sagen x0=124, nicht x≈450, also ist es die Referenz (N-CINV-01999111) und der Betrag bleibt -8.762,513.


Das gesamte Dokument, immer

Die Profilparsung beantwortet „Was sind die Positionszeilen?" und ignoriert alles andere. Das reicht für die Schritte 2, 4 und 5 nicht, daher läuft die Volltext-Extraktion auf jedem Dokument, ob passend oder nicht, und erzeugt vier Ansichten desselben Inhalts:

Feld

Was es ist

Wofür verwenden

content.markdown

Das Dokument, gerendert für ein LLM

Chatten. Das speichern.

content.text

Klartext

Suche, Embeddings

content.key_values

Jedes Label: value auf der Seite

Filter, Lookups

content.blocks

Typisierte, geordnete, positionierte Blöcke

Programmatische Nutzung, Schwärzung

content.chunks

Markdown, an Überschriften geteilt

Retrieval bei langen Dokumenten

tables[]

Jede Tabelle als Spalten + Zeilen

Rendering, Export

line_items[], metadata{}

Typisiert + validiert

SQL-Aggregation

Ein Dokument ohne Profil ist kein toter Endpunkt mehr. Es liefert parsed_without_profile mit vollständig befülltem content – so kann das Team es speichern, damit chatten und darauf reagieren, bevor jemand ein Profil schreibt. Ein Profil ergänzt nur typisierte Positionszeilen und Querprüfungen obendrauf.

Warum Markdown das Artefakt für Chat ist

Ein Agent, der gefragt wird „Wie hoch ist der Saldo für One World?", antwortet weit zuverlässiger, wenn er das hier liest, als wenn er Zeilen aus JSON wieder zusammensetzt oder einen rohen Textdump scannt:

# ONE WORLD TRADING L.L.C.

## Key fields
| Field | Value |
|---|---|
| supplier_code | S00066 |
| currency | AED |
| ageing_date | 2025-07-11 |

### Line items
| document_no | bp_reference_no | due_date | amount | running_balance |
|---|---|---|---|---|
| 131365 | SI/08781/CN/00007 | 2025-07-07 | -43160.25 | -43160.25 |
...

## All document fields (as printed)
| Posting Date | From To 11.07.25 |
| Sales Employee | No Sales Employee |
...

Typisierte, validierte Felder führen. Rohe gedruckte Felder folgen, sodass eine Frage, die das Profil nicht modelliert, trotzdem beantwortet werden kann. Die geparste Tabelle wird einmal gerendert – sie wird nicht als loser Text dupliziert.

Faustregel für Knoten 4: Aggregationen gehen an SQL, „Was sagt dieses Dokument?" geht an Markdown. Ein einseitiger Kontoauszug passt vollständig in einen Prompt, und ihn vollständig zu füttern ist besser, als Chunks davon abzurufen.

Ausgabe-Vertrag

Konsumenten sollten auf schema_version prüfen, statt Duck-Typing zu betreiben.

{
  "schema_version": "2.0",
  "status": "ok",                     // ok | needs_review | parsed_without_profile
                                      // | profile_mismatch | no_text_layer | error
  "profile": "sap_b1_supplier_statement",
  "profile_confidence": 1.0,
  "document": { "file_name": "...", "checksum": "sha256:...",
                "pages": 1, "pages_parsed": [0] },
  "metadata": { "supplier_name": "ONE WORLD TRADING L.L.C.",
                "supplier_code": "S00066", "currency": "AED",
                "ageing_date": "2025-07-11" },
  "line_items": [
    { "line_no": 1, "document_type": "PU", "document_no": "131365",
      "bp_reference_no": "SI/08781/CN/00007",
      "posting_date": "2025-05-31", "due_date": "2025-07-07",
      "amount": -43160.25, "running_balance": -43160.25,
      "_source": {                    // only when include_coordinates=true
        "page": 0,
        "cells": { "BP Ref. No.": { "page": 0, "wrapped": true,
                                    "bbox": [123.7, 278.67, 163.79, 295.02] } }
      } }
  ],
  "summary":    { "buckets": { "Balance Due": -69966.75 } },
  "validation": { "ok": true, "checks": [ ... ] },
  "diagnostics": { "rows": 5, "rows_with_wrapped_cells": 1,
                   "column_fill_rate": { ... }, "page_geometry": [ ... ],
                   "warnings": [] }
}

checksum ist enthalten, damit ein nachgelagerter Insert-Knoten deduplizieren kann, ohne dass dieser Server wissen muss, dass eine Datenbank existiert. Das ist die Trennung in Aktion: Wir liefern die Tatsache, jemand anderes entscheidet, was damit geschieht.

include_coordinates

Standardmäßig aus (verdoppelt die Payload ungefähr). Aktivieren, wenn ein späterer Knoten schwärzen, hervorheben oder visuell verifizieren muss. bbox ist [x0, top, x1, bottom] in PDF-Punkten und umfasst alle Zeilen, die eine umgebrochene Zelle belegt hat – eine Schwärzungsbox über SI/08781/CN/00007 deckt also korrekt beide visuellen Zeilen ab.

Daten sind ISO 8601. Beträge sind Floats, negativ für Verbindlichkeiten wie gedruckt.


Validierung und der Beweis, dass es funktioniert

status: "ok" bedeutet, dass alle Prüfungen bestanden sind. Es gibt zwei unabhängige Familien:

Arithmetik – Reproduzieren die gelesenen Zahlen die gedruckten Zahlen?

  • running_balance_chain – jeder Saldo schreitet um den Betrag seiner eigenen Zeile fort. Stärker als eine Summe: Es benennt die fehlerhafte Zeile und erkennt umsortierte oder duplizierte Zeilen, die eine Summe gar nicht sehen kann.

  • sum_equals_last – die Beträge summieren sich zum Schlusssaldo.

  • summary_equals_last – die Altersstruktur-Summe stimmt überein.

Strukturell – Hat die Rekonstruktion die Seite verbraucht?

  • word_coverage – jedes Wort im Tabellenbereich landete in genau einer Zelle. Die Kerninvariante.

  • no_unassigned_words, no_orphan_lines – nichts übersprungen.

  • no_suspicious_rows – kennzeichnet spärliche Zeilen (ein Fortsetzungsfragment, das fälschlich als neue Zeile erkannt wurde) und Zeilen, die über einen Seitenumbruch genäht wurden.

  • field_matches – Formprüfung auf Referenznummern.

Strukturprüfungen existieren, weil Arithmetik Textkorruption nicht sehen kann: Eine verstümmelte Referenznummer stimmt trotzdem perfekt in der Summe. tests/test_detection.py korrumpiert Daten auf sieben Arten und stellt sicher, dass jede Prüfung auslöst:

PASS  clean data validates
PASS  misread amount on line 2      -> running_balance_chain, sum_equals_last
PASS  rows out of order             -> running_balance_chain
PASS  duplicated row                -> running_balance_chain
PASS  mangled reference number      -> field_matches:bp_reference_no   <-- ONLY this
PASS  unclaimed words on page       -> word_coverage, no_unassigned_words
PASS  summary disagrees             -> summary_equals_last
PASS  missing required field        -> required_fields

Zeile 5 ist der Kern der ganzen Übung. Eine Prüfung, die nie fehlschlägt, ist Dekoration; diese hier haben nachweislich ausgelöst.

Die Zusage an Stakeholder ist nicht „der Parser beherrscht jedes Layout" – das ist nicht falsifizierbar, und jemand wird ein Gegenbeispiel finden. Sie lautet: Jedes Dokument wird entweder geparst und verifiziert sich selbst, oder es wird markiert. Nichts erreicht den nächsten Knoten still und falsch.


Testen

TESTING.md enthält die vollständige Leiter. Kurzfassung:

bash scripts/check_repo.sh                      # is the clone complete?
bash scripts/run_tests.sh                       # all 6 suites, no server
npx @modelcontextprotocol/inspector python -m src.server   # see it as a client
python scripts/smoke_test.py <url> <token> doc.pdf         # verify a deployment

Level 3 in TESTING.md – einen echten Agenten über Claude Desktop davorzusetzen – ist der, den man nicht überspringen sollte. Die Tool-Docstrings sind die einzigen Anweisungen, die der Agent von MagOneAI jemals bekommt, und der einzige Weg, sie zu testen, ist, ein LLM versuchen zu lassen, sie zu verwenden.

Schnellstart

pip install -r requirements.txt
export DOC_EXTRACT_TOKEN=$(openssl rand -hex 32)
MCP_TRANSPORT=http python -m src.server     # http://0.0.0.0:8000/mcp

python tests/test_samples.py                # parser regression
python tests/test_detection.py              # validation fires
python tests/e2e_http.py                    # real MCP client over HTTP
docker build -t doc-extract .
docker run -p 8000:8000 -e DOC_EXTRACT_TOKEN=$TOKEN doc-extract
curl localhost:8000/health

So baut man einen MCP-Server

Behandelt in BUILDING_AN_MCP_SERVER.md – wie dieser Server aufgebaut ist und warum: Transports (warum streamable HTTP, nicht stdio), Tool-Design, Docstrings-als-Prompts, Auth und die SDK-2.x-Stolperfallen.


Was frei variiert vs. was eine Profiländerung braucht

Gemessen, nicht behauptet – tests/test_robustness.py mutiert das Format jeweils eine Achse nach der anderen.

Frei. Keine Änderung nötig:

Variation

Ergebnis

Anderer Lieferant, andere Beträge, andere Daten

ok

Beliebige Zeilenzahl, über beliebig viele Seiten

ok

Umbruchtiefe 0, 1, 2, 4+ Zeilen – gemischt in einem Dokument

ok

Spalten durch Layoutdrift verschoben

ok

Schriftgröße 5pt → 16pt

ok

Interpunktion im Kopf driftet (BP Ref. No.BP Ref No)

ok

Anderes Dokumenttyp-Präfix (PURC)

ok

Faux-Fett / Schlagschatten-Rendering

ok

Nichts ist an eine Koordinate gebunden: Spaltenbänder werden pro Seite aus dem eigenen Kopf dieser Seite neu aufgebaut, die Zeilen-Clustering-Toleranz stammt aus der medianen Glyphengröße des Dokuments, und die Zusammenführung von Kopfzellen kommt aus der eigenen Lückenverteilung der Zeile, begrenzt durch die Schriftgröße.

Braucht eine Profiländerung – und sagt das auch:

Variation

Ergebnis

Was man bekommt

Spalte umbenannt (Post. DatePosting Date)

profile_mismatch

Der fehlende Name + der Kopf wie gedruckt

Spalte entfernt

profile_mismatch

Dasselbe

Spalte hinzugefügt

needs_review

unmapped_header_columns: ["Currency"]

Anker passt nicht mehr (INV-2026-001)

needs_review

Null Zeilen, markiert statt leer durchgereicht

Vollständig anderes Dokument

parsed_without_profile

Vollständiger Inhalt, keine typisierten Zeilen

Der Fall der hinzugefügten Spalte ist der wichtigste: Der Inhalt einer neuen Spalte wird von einer Nachbarzelle absorbiert, und die Arithmetik kann trotzdem stimmen. Deshalb schlägt all_header_columns_mapped das Dokument explizit fehl, statt es still passieren zu lassen.

In jedem dieser Fälle ist content.markdown weiterhin vollständig, sodass das Dokument speicher- und chattbar bleibt, während jemand das Profil repariert.

Eine profile_mismatch-Antwort ist direkt umsetzbar:

{
  "status": "profile_mismatch",
  "header_missing": "Post. Date",
  "header_actual": ["Document","BP Ref. No.","Posting Date","Due Date",
                    "Details","Amount","Balance"],
  "next_step": "Update its `columns` to the printed header, then
                POST /admin/reload-profiles."
}

Die Behebung ist eine einzeilige YAML-Änderung und ein Neuladen – kein Redeploy.

Mehrseiten-Verhalten

Ein Kontoauszugslauf ist nicht „dieselbe Seite N-mal". Jeder dieser Fälle ist in tests/test_multipage.py getestet:

Szenario

Ergebnis

Kopf auf jeder Seite wiederholt

ok – alle Zeilen

Kopf nur auf Seite 1 gedruckt

ok – Bänder werden weitergetragen

Eine Zeile durch den Seitenumbruch geschnitten

ok – der umgebrochene Rest wird an seine Zeile genäht

Seitengröße / Ausrichtung ändert sich mitten im Dokument

ok – Bänder pro Seite neu aufgebaut

Fremde Seite (AGB, Zahlungsverkehr) eingeheftet

ok – übersprungen, keine erfundenen Zeilen

Zwei davon erforderten echte Korrekturen.

Kopf nur auf Seite 1 verlor still jede Zeile nach der ersten Seite. Jetzt werden die Bänder der vorherigen Seite weitergetragen – aber nur übernommen, wenn die Seite tatsächlich Anker-matchende Zeilen enthält, damit eine AGB-Seite nicht in eine Tabelle gezwungen wird, mit der sie nichts zu tun hat. diagnostics.pages_without_repeated_header listet auf, auf welche Seiten das zutraf.

Zeile durch den Seitenumbruch geschnitten – die Referenz SI/08781/CN/ am Ende von Seite 1 mit 00007 am Anfang von Seite 2 setzt sich wieder zu SI/08781/CN/00007 zusammen. Die Naht ist daran gekoppelt, dass die weitergetragene Zeile tatsächlich nahe dem unteren Rand der vorherigen Seite saß. Ohne diese Absicherung würde jede verirrte Zeile über der ersten Zeile einer Seite an die vorherige Zeile geklebt; mit ihr taucht ein verirrtes Fragment stattdessen als Waise auf und lässt das Dokument fehlschlagen:

status: needs_review
refs  : ['SI/2000', 'SI/2001', 'SI/2100']      <-- NOT corrupted
FAILED: no_orphan_lines  {'text': 'STRAY-FRAGMENT', 'reason': 'before_first_row'}

Eine korrekte Seitenumbruch-Naht erzwingt nicht mehr allein eine menschliche Prüfung – sie wird als Warnung gemeldet. Sonst müsste jeder lange Kontoauszug abgezeichnet werden.

Hinzufügen eines Lieferantenformats: Konfiguration, kein Code

Profile sind YAML in profiles/. Ein Format hinzuzufügen berührt layout.py nie.

  1. extract_document gibt parsed_without_profile zurück

  2. probe_layout → jede Zeile mit x-Koordinaten pro Wort

  3. Kopieren Sie die Kopfzeilenbeschriftungen wörtlich in columns

  4. Wählen Sie ein anchor_column + anchor_pattern, das mit der ersten Zelle jeder Zeile übereinstimmt und sonst nichts

  5. Legen Sie die Datei in profiles/ ab, POST /admin/reload-profiles

id: acme_invoice
detect:
  require: ["Tax Invoice"]
  text_contains: ["Tax Invoice", "Invoice No."]
table:
  columns: ["Line", "Item Code", "Description", "Qty", "Amount"]
  anchor_column: "Line"
  anchor_pattern: '^\d+$'
  stop_pattern: '^Subtotal\b'
  join_with: ""          # "" for codes/refs, " " for prose
fields:
  - {name: item_code, source: "Item Code", type: text}
  - {name: amount,    source: "Amount",    type: decimal}
validation:
  - {type: required_fields, fields: [item_code, amount]}

Typen: text, decimal, date (+format), int, token (+index). Ein defektes YAML wird isoliert — es landet in load_errors, andere Profile funktionieren weiter.


MagOneAI-Verdrahtung

[1] Trigger: user drops a document / Outlook attachment
        ↓
[2] Agent node: extract_document(source=<url>, file_name=...)
        ↓
    switch on status:
      ok                     -> [3] insert -> [4] chat agent
      needs_review           -> human approval -> insert / reject
      parsed_without_profile -> [3] insert anyway (content is complete)
                                 + alert: new vendor format seen
      no_text_layer          -> OCR queue
      error                  -> retry, then alert
        ↓
[3] DB node: run neon_schema.sql once, then upsert on document.checksum
        ↓
[4] Agent node with NeonDB access:
      "what does this say?"  -> SELECT markdown FROM documents WHERE ...
      "how much is past due?" -> SELECT SUM(amount) FROM v_document_lines ...

neon_schema.sql in diesem Repository enthält das DDL, die JSON-Pfad-zu-Spalten-Zuordnung für Knoten 3 und die Abfragen, die Knoten 4 ausführen soll. Beachten Sie, dass parsed_without_profile weiterhin einfügt: content.markdown ist vollständig, sodass das Dokument sofort chatbar ist; typisierte Positionen kommen später, wenn ein Profil hinzugefügt wird, und erneutes Einlesen ist prüfsummen-idempotent.

  • DOC_EXTRACT_TOKEN serverseitig, gesendet als Authorization: Bearer <token>.

  • max_iterations ≈ 15. Ein Aufruf im Happy Path; Review-/Onboarding- Zweige verketten mehr.

  • Bevorzugen Sie source_type="url"; base64 bläht die Nutzlasten um ~33 % auf.

  • Der Einfügeknoten besitzt das Schema. Dieser Server weiß nicht, dass es existiert.


Engineering-Notizen

  • Die Toleranz für Zeilen-Clustering wird pro Dokument abgeleitet aus der mittleren Glyphengröße, nicht hartcodiert, sodass derselbe Bericht in einer anderen Skalierung weiterhin geparst wird. Diese Änderung brachte einen echten Fehler ans Licht: BP : vs. BP: wird unterschiedlich tokenisiert, daher laufen Metadaten-Regexe jetzt gegen satzzeichennormalisierten Text.

  • Seitenumbruch-Zusammenfügung — eine Zelle, die über eine Seitengrenze umbricht, wird mit der weitergeführten Zeile verbunden und als stitched_across_page_break markiert.

  • Erkennung spärlicher Zeilen — eine Zeile, die ≤1/3 ihrer Spalten ausfüllt, wird als möglicher falscher Anker markiert, der einzige Fehler, den die Waisen-Erkennung nicht abfangen kann.

Bekannte Einschränkungen

  • Wenn ein umgebrochenes Fragment in der Ankerspalte landete und dem Ankermuster entspräche, würde es als neue Zeile gelesen. Die Erkennung spärlicher Zeilen markiert die wahrscheinlichen Fälle; ein enges Anker-Regex ist die eigentliche Verteidigung.

  • Die Zuordnung von Alterungs-Buckets ist an zwei Dokumenten verifiziert, die beide in nahe Buckets landen. Führen Sie eine Abfrage mit echtem 90+-Alter aus, bevor Sie Bucket-Beschriftungen in der Produktion vertrauen.

  • Verschlüsselte oder passwortgeschützte PDFs werden nicht verarbeitet; sie erscheinen als error.

F
license - not found
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI-powered extraction and analysis of PDF documents with 40+ specialized tools for text, tables, images, layout analysis, security assessment, and document intelligence. Supports both text-based and scanned PDFs with OCR capabilities.
    10
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI-driven PDF document processing including PDF to Markdown conversion, intelligent text and table extraction, image extraction, format conversion between PDF/Word/Markdown, batch processing, and fuzzy search - optimized for LLM context and RAG workflows.
    2
    MIT

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AlanAAG/invoice-extraction-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server