doc-extract
doc-extract
MCP-сервер, который делает ровно одну вещь: PDF на входе → весь документ в виде проверенного структурированного JSON на выходе.
Создан для этого рабочего процесса:
[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Шаги 3, 4 и 5 намеренно не являются задачей этого сервера. У него нет драйвера базы данных, и каждый инструмент доступен только для чтения.
Никакой базы данных. Никаких побочных эффектов. Никакого сохранения состояния. Каждый инструмент только для чтения. Что происходит дальше — вставка, редактирование, маршрутизация, уведомление — это отдельный узел в рабочем процессе MagOneAI.
Область применения, закреплённая не только на словах
Этот сервер делает | Этот сервер НЕ делает |
Читает весь текстовый слой PDF | Записывает в любую базу данных |
Восстанавливает геометрию таблиц | Отправляет email или уведомления |
Исправляет перенесённые ячейки | Редактирует или изменяет PDF |
Проверяет прочитанное | Решает, что делать дальше |
Возвращает JSON + координаты | Хранит что-либо между вызовами |
Ограничение, чтобы расширение области было структурно сложным:
Все три инструмента аннотированы
readOnlyHint: true,destructiveHint: false,idempotentHint: true. Оркестратор видит, что повторять безопасно.extract()— чистая функция от байтов PDF. Одинаковый вход → одинаковый выход.Перезагрузка профилей — это HTTP-административный маршрут, а не инструмент MCP. Изменения конфигурации — действие оператора; агент рабочего процесса не должен иметь возможности выбрать такое действие.
На диск ничего не записывается, кроме временного файла для входящего PDF.
Related MCP server: MCP PDF
Почему не OCR
Оба образца — это экспорты Crystal Reports из SAP Business One — встроенные шрифты, без растровых изображений. Каждый символ уже несёт точные координаты страницы. OCR растрировал бы это и заново выводил бы эти координаты с ошибкой.
Перенесённый BP Ref. No. — это проблема восстановления макета:
строка | токен | x0 | x1 |
278.7 |
| 124 | 164 |
288.4 |
| 124 | 142 |
00007 находится ровно на левом краю колонки BP Ref → та же ячейка →
SI/08781/CN/00007.
Это важнее всего для файла Nutripharm, где фрагменты — голые цифры.
При чтении плоского текста 111 правдоподобно склеивается с суммой, давая
-8,762.513111. Координаты говорят x0=124, а не x≈450, значит это
ссылка (N-CINV-01999111), а сумма остаётся -8,762.513.
Весь документ, всегда
Разбор профиля отвечает на вопрос «что такое позиции?» и игнорирует всё остальное. Этого недостаточно для шагов 2, 4 и 5, поэтому извлечение полного документа выполняется для каждого документа, независимо от соответствия профилю, и даёт четыре представления одного и того же содержимого:
Поле | Что это | Для чего использовать |
| Документ, отображаемый для LLM | Для чата. Сохраните это. |
| Простой текст | Поиск, эмбеддинги |
| Каждая пара | Фильтры, поиск |
| Типизированные, упорядоченные, позиционированные блоки | Программное использование, редактирование |
| Markdown, разбитый по заголовкам | Поиск по длинным документам |
| Каждая таблица как колонки + строки | Отображение, экспорт |
| Типизированные + проверенные | SQL-агрегация |
Документ без профиля больше не тупик. Он возвращает
parsed_without_profile с полностью заполненным content — так что команда может
сохранить его, общаться с ним и действовать, прежде чем кто-то напишет профиль. Профиль
лишь добавляет типизированные позиции и перекрёстные проверки поверх.
Почему markdown — артефакт для чата
Агент, которому задан вопрос «каков исходящий баланс для One World?», отвечает гораздо надёжнее, читая это, чем собирая строки из JSON или сканируя сырой текстовый дамп:
# 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 |
...Типизированные, проверенные поля идут первыми. Сырые напечатанные поля следуют за ними, так что на вопрос, который профиль не моделирует, всё равно можно ответить. Разобранная таблица отображается один раз — она не дублируется как свободный текст.
Правило для узла 4: агрегации идут в SQL, «что говорит этот документ?» — в markdown. Одностраничная выписка целиком помещается в промпт, и подача её целиком лучше, чем извлечение фрагментов.
Контракт вывода
Потребители должны опираться на schema_version, а не на утиную типизацию.
{
"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 включён, чтобы нижестоящий узел вставки мог дедуплицировать без необходимости
этому серверу знать о существовании базы данных. Это и есть разделение: мы предоставляем
факт, кто-то другой решает, что с ним делать.
include_coordinates
По умолчанию выключено (примерно удваивает полезную нагрузку). Включайте, когда следующему
узлу нужно редактировать, выделять или визуально проверять. bbox — это [x0, top, x1, bottom]
в пунктах PDF и охватывает все строки, которые занимала перенесённая ячейка — так что
поле редактирования над SI/08781/CN/00007 корректно покрывает обе визуальные строки.
Даты — ISO 8601. Суммы — числа с плавающей точкой, отрицательные для кредиторской задолженности, как напечатано.
Проверка и доказательство, что это работает
status: "ok" означает, что все проверки пройдены. Есть два независимых семейства:
Арифметические — воспроизводят ли прочитанные числа напечатанные?
running_balance_chain— каждый баланс увеличивается на сумму своей строки. Сильнее, чем итог: он называет строку, где ошибка, и ловит переставленные или дублированные строки, которые сумма вообще не видит.sum_equals_last— суммы сходятся с исходящим балансом.summary_equals_last— итог по срокам совпадает.
Структурные — поглотила ли реконструкция страницу?
word_coverage— каждое слово в области таблицы попало ровно в одну ячейку. Основной инвариант.no_unassigned_words,no_orphan_lines— ничего не пропущено.no_suspicious_rows— отмечает разреженные строки (фрагмент переноса, ошибочно принятый за новую строку) и строки, сшитые через разрыв страницы.field_matches— проверка формы ссылочных номеров.
Структурные проверки существуют, потому что арифметика не видит искажения текста:
искажённый ссылочный номер всё равно идеально сходится. tests/test_detection.py
искажает данные семью способами и проверяет, что каждая проверка срабатывает:
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Строка 5 — суть всего упражнения. Проверка, которая никогда не срабатывает, — это украшение; эти проверки доказанно срабатывают.
Гарантия, которую стоит заявить заинтересованным сторонам, — не «парсер обрабатывает любой макет» — это неопровержимо, и кто-нибудь найдёт контрпример. Она такова: каждый документ либо разбирается и самопроверяется, либо помечается. Ничто не достигает следующего узла молча и с ошибкой.
Тестирование
В TESTING.md — полная лестница. Кратко:
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Уровень 3 в TESTING.md — поставить перед ним реального агента через Claude
Desktop — тот, который не стоит пропускать. Docstring'и инструментов — единственные
инструкции, которые агент MagOneAI когда-либо получит, и единственный способ их
проверить — дать LLM попробовать их использовать.
Быстрый старт
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 HTTPdocker build -t doc-extract .
docker run -p 8000:8000 -e DOC_EXTRACT_TOKEN=$TOKEN doc-extract
curl localhost:8000/healthКак собрать MCP-сервер
Описано в BUILDING_AN_MCP_SERVER.md — как этот
сервер устроен и почему: транспорты (почему потоковый HTTP, а не stdio),
дизайн инструментов, docstring'и как промпты, аутентификация и подводные камни SDK 2.x.
Что свободно варьируется, а что требует правки профиля
Измерено, а не заявлено — tests/test_robustness.py мутирует формат по одной оси за раз.
Свободно. Изменений не требуется:
Вариация | Результат |
Другой поставщик, суммы, даты |
|
Любое количество строк, на любом количестве страниц |
|
Глубина переноса 0, 1, 2, 4+ строк — смешанная в одном документе |
|
Колонки смещены из-за дрейфа макета |
|
Размер шрифта от 5pt до 16pt |
|
Пунктуация заголовка дрейфует ( |
|
Другой префикс типа документа ( |
|
Ложное жирное / отбрасывание тени |
|
Ничто не привязано к координате: полосы колонок перестраиваются для каждой страницы из её собственного заголовка, допуск кластеризации строк берётся из медианного размера глифа документа, а объединение ячеек заголовка — из распределения пробелов в самой строке, ограниченного размером шрифта.
Требует правки профиля — и сообщает об этом:
Вариация | Результат | Что вы получаете |
Колонка переименована ( |
| Отсутствующее имя + заголовок, как напечатано |
Колонка удалена |
| То же |
Колонка добавлена |
|
|
Якорь больше не совпадает ( |
| Ноль строк, помечено, а не пропущено пустым |
Совершенно другой документ |
| Полное содержимое, без типизированных строк |
Случай с добавленной колонкой — самый важный: содержимое новой колонки поглощается
соседней ячейкой, и арифметика всё ещё может сойтись. Поэтому all_header_columns_mapped
явно проваливает документ, а не позволяет ему пройти молча.
В каждом из этих случаев content.markdown по-прежнему полон, так что документ
остаётся сохраняемым и пригодным для чата, пока кто-то чинит профиль.
Ответ profile_mismatch непосредственно применим:
{
"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."
}Исправление — это изменение одной строки YAML и перезагрузка — без переразвёртывания.
Поведение на нескольких страницах
Прогон выписки — это не «та же страница N раз». Каждый из этих случаев проверяется
в tests/test_multipage.py:
Сценарий | Результат |
Заголовок повторяется на каждой странице |
|
Заголовок напечатан только на странице 1 |
|
Строка разрезана разрывом страницы |
|
Размер страницы / ориентация меняется в середине документа |
|
Несвязанная страница (условия, платёж) вставлена |
|
Два из них потребовали реальных исправлений.
Заголовок только на странице 1 молча терял все строки после первой страницы. Теперь
полосы предыдущей страницы переносятся вперёд — но только фиксируются, если страница
действительно содержит строки, совпадающие с якорем, так что страница с условиями
не натягивается на таблицу, к которой не имеет отношения. diagnostics. pages_without_repeated_header перечисляет страницы, к которым это применилось.
Строка, разрезанная разрывом страницы — ссылка SI/08781/CN/ внизу страницы 1
с 00007 вверху страницы 2 собирается в SI/08781/CN/00007. Сшивка ограничена тем,
что перенесённая строка действительно находилась у нижнего края предыдущей страницы.
Без этой защиты любая случайная строка над первой строкой страницы была бы приклеена
к предыдущей строке; с ней случайный фрагмент вместо этого всплывает как сирота и
проваливает документ:
status: needs_review
refs : ['SI/2000', 'SI/2001', 'SI/2100'] <-- NOT corrupted
FAILED: no_orphan_lines {'text': 'STRAY-FRAGMENT', 'reason': 'before_first_row'}Правильная сшивка через разрыв страницы больше не требует ручной проверки сама по себе — она сообщается как предупреждение. Иначе каждую длинную выписку пришлось бы подписывать.
Добавление формата поставщика: конфигурация, а не код
Профили — это YAML в profiles/. Добавление формата никогда не затрагивает layout.py.
extract_documentвозвращаетparsed_without_profileprobe_layout→ каждая строка с координатами x для каждого словаСкопируйте заголовки столбцов дословно в
columnsВыберите
anchor_column+anchor_pattern, соответствующие первой ячейке каждой строки и ничему другомуПоместите файл в
profiles/,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]}Типы: text, decimal, date (+format), int, token (+index).
Сломанный YAML изолируется — он попадает в load_errors, остальные профили продолжают работать.
Подключение MagOneAI
[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 в этом репозитории содержит DDL, маппинг JSON-path → столбец для узла 3 и запросы, которые должен выполнять узел 4. Обратите внимание, что parsed_without_profile по-прежнему вставляет: content.markdown полный, поэтому документ сразу доступен для чата; типизированные позиции строк появляются позже, когда добавляется профиль, и повторная загрузка идемпотентна по контрольной сумме.
DOC_EXTRACT_TOKENна стороне сервера, отправляется какAuthorization: Bearer <token>.max_iterations≈ 15. Один вызов на счастливом пути; ветви ревью/онбординга добавляют больше вызовов.Предпочитайте
source_type="url"; base64 увеличивает полезную нагрузку ~33%.Узел вставки владеет схемой. Этот сервер не знает о её существовании.
Инженерные заметки
Допуск кластеризации строк вычисляется для каждого документа на основе медианного размера глифа, а не жёстко задан, поэтому один и тот же отчёт в другом масштабе всё равно парсится. Это изменение выявило реальный баг:
BP :иBP:токенизируются по-разному, поэтому регулярные выражения метаданных теперь работают с текстом, нормализованным по пунктуации.Сшивка разрывов страниц — ячейка, переносящаяся через границу страницы, присоединяется к перенесённой строке и помечается
stitched_across_page_break.Обнаружение разреженных строк — строка, заполняющая ≤1/3 своих столбцов, помечается как возможный ложный якорь, единственный сбой, который не может поймать детектор сирот.
Известные ограничения
Если перенесённый фрагмент попал в столбец якоря и совпал с шаблоном якоря, он будет прочитан как новая строка. Обнаружение разреженных строк помечает вероятные случаи; надёжная защита — это строгий regex якоря.
Маппинг возрастных корзин проверен на двух документах, оба попадают в ближние корзины. Выполните запрос с реальным старением 90+ перед тем, как доверять меткам корзин в продакшене.
Зашифрованные или защищённые паролем PDF не обрабатываются; они отображаются как
error.
This server cannot be deployed
Maintenance
Related MCP Connectors
High-fidelity PDF to structured Markdown conversion and document field extraction.
Turn documents into structured data: parse, extract, classify, split, and fill PDF forms.
Turn any PDF into structured JSON via AI + OCR: invoices, bank statements, contracts.
Read PDFs and images as markdown or text, with exact costs and hard spend caps. $0.75/1k pages.
Related MCP Servers
- FlicenseAqualityDmaintenanceEnables reading and extracting content from PDF documents including text (as Markdown), images, tables, and metadata from both local files and URLs, with OCR support for scanned documents.2-
- AlicenseNot gradedqualityCmaintenanceEnables 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.271 PyPI11MIT
- AlicenseNot gradedqualityCmaintenanceEnables 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.2MIT
- FlicenseNot gradedqualityBmaintenanceEnables conversion between PDF/Office documents and structured JSON, supporting extraction and generation of document content.-