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 Reader Server
Почему не 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 installed
Maintenance
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
- 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 gradedqualityDmaintenanceEnables comprehensive PDF processing including text extraction, image extraction, and OCR capabilities for reading text within images across multiple languages.12MIT
- AlicenseNot gradedqualityDmaintenanceEnables 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.10MIT
- AlicenseNot gradedqualityDmaintenanceEnables 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
Related MCP Connectors
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.
Turn a description into a shareable, editable PDF — invoices, certificates, reports, resumes.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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