Skip to main content
Glama
doc-extract

doc-extract

by AlanAAG

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 的整个文本层

写入任何数据库

重建表格几何结构

发送电子邮件或通知

修复换行单元格

编辑或修改 PDF

验证读取结果

决定接下来发生什么

返回 JSON + 坐标

在调用之间存储任何内容

强制执行,使范围蔓延在结构上变得困难:

  • 所有三个工具都标注了 readOnlyHint: true、destructiveHint: false、idempotentHint: true。编排器可以看到这是可安全重试的。

  • extract() 是 PDF 字节的纯函数。相同输入 → 相同输出。

  • 重新加载配置文件是一个 HTTP 管理路由,而不是 MCP 工具。配置更改是操作员操作;工作流代理不能选择执行它。

  • 除了传入 PDF 的临时文件外,不向磁盘写入任何内容。


Related MCP server: MCP PDF

为什么不用 OCR

两个样本都是 SAP Business One 导出的 Crystal Reports——嵌入字体,没有光栅图像。每个字符已经携带精确的页面坐标。OCR 会将其光栅化并以误差重新推导这些坐标。

被换行的 BP Ref. No. 是一个布局重建问题:

行

标记

x0

x1

278.7

SI/08781/CN/

124

164

288.4

00007

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 来说不够,因此全文档提取在每个文档上运行,无论是否匹配,产生同一内容的四个视图:

字段

它是什么

用它做什么

content.markdown

为 LLM 渲染的文档

**聊天。**存储它。

content.text

纯文本

搜索、嵌入

content.key_values

页面上的每个 Label: value

过滤器、查找

content.blocks

类型化、有序、定位的块

程序化使用、编辑

content.chunks

在标题处分割的 Markdown

长文档检索

tables[]

每个表格作为列 + 行

渲染、导出

line_items[]、metadata{}

类型化 + 验证

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 是 PDF 点中的 [x0, top, x1, bottom],并跨越换行单元格占用的所有行——因此覆盖 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

TESTING.md 中的第 3 级——通过 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 HTTP
docker 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 中介绍——这个服务器是如何组装起来的以及为什么:传输(为什么用 streamable HTTP 而不是 stdio)、工具设计、docstring 即提示、认证,以及 SDK 2.x 的陷阱。


什么可以自由变化 vs. 什么需要编辑配置文件

经过测量而非断言——tests/test_robustness.py 一次一个轴地变异格式。

自由。无需更改:

变化

结果

不同的供应商、金额、日期

ok

任意数量的行,跨任意数量的页

ok

换行深度 0、1、2、4+ 行——在一个文档中混合

ok

列因布局漂移而重新定位

ok

字体大小 5pt → 16pt

ok

标题标点漂移(BP Ref. No. → BP Ref No)

ok

不同的文档类型前缀(PU → RC)

ok

伪粗体 / 投影渲染

ok

没有任何内容固定到坐标:列带从每页自己的标题重建,行聚类容差来自文档的中位数字形大小,标题单元格合并来自行自身的间隙分布并由类型大小限制。

需要编辑配置文件——并且会明确说明:

变化

结果

你得到什么

列重命名(Post. Date → Posting Date)

profile_mismatch

缺失的名称 + 按打印的标题

列移除

profile_mismatch

相同

列添加

needs_review

unmapped_header_columns: ["Currency"]

锚点不再匹配(INV-2026-001)

needs_review

零行,被标记而不是作为空通过

完全不同的文档

parsed_without_profile

完整内容,没有类型化行

添加列的情况是最重要的:新列的内容会被吸收到相邻单元格中,算术仍然可以合计。因此 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 中测试:

场景

结果

标题在每页重复

ok — 所有行

标题只打印在第 1 页

ok — 带被向前携带

一行被分页符切断

ok — 换行尾部被缝合到其行

页面大小 / 方向在文档中间变化

ok — 带按页重建

不相关的页面(条款、汇款)被装订进来

ok — 跳过,不发明行

其中两个需要真正的修复。

标题只在第 1 页静默丢失了第一页之后的每一行。现在前一页的带被向前携带——但只有在页面实际包含锚点匹配行时才提交,因此条款和条件页面不会被强行套入一个与它无关的表格。diagnostics.pages_without_repeated_header 列出这适用于哪些页面。

行被分页符切断——第 1 页底部的参考号 SI/08781/CN/ 与第 2 页顶部的 00007 重新组装为 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'}

正确的分页缝合不再单独强制人工审查——它被报告为警告。否则每个长对账单都需要签字确认。

添加供应商格式:配置,而非代码

配置文件是 profiles/ 中的 YAML。添加格式永远不会触及 layout.py。

  1. extract_document 返回 parsed_without_profile

  2. probe_layout → 每一行都带有逐词的 x 坐标

  3. 将表头标签逐字复制到 columns 中

  4. 选择一个 anchor_column + anchor_pattern,使其匹配每行的第一个单元格且不匹配其他内容

  5. 将文件放入 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、节点 3 的 JSON-path → 列映射,以及节点 4 应运行的查询。请注意,parsed_without_profile 仍然会插入:content.markdown 是完整的,因此文档立即可供聊天使用;添加配置文件后,类型化的行项目稍后到达,并且基于校验和的重新摄取是幂等的。

  • DOC_EXTRACT_TOKEN 在服务端,作为 Authorization: Bearer <token> 发送。

  • max_iterations ≈ 15。正常路径上调用一次;审查/接入分支会链式调用更多次。

  • 优先使用 source_type="url";base64 会使负载膨胀约 33%。

  • 插入节点拥有 schema。此服务器不知道它的存在。


工程笔记

  • 行聚类容差是根据文档派生的,基于中位数字符大小,而非硬编码,因此同一报告在不同缩放比例下仍能解析。这一更改暴露了一个真实 bug:BP : 与 BP: 的分词结果不同,因此元数据正则表达式现在针对标点归一化后的文本运行。

  • 分页拼接——跨页边界换行的单元格会与携带的行合并,并标记为 stitched_across_page_break。

  • 稀疏行检测——填充列数 ≤1/3 的行会被标记为可能的假锚点,这是孤立检测无法捕获的唯一失败情况。

已知限制

  • 如果换行的片段落在锚点列中并且匹配锚点模式,它会被读作新行。稀疏行检测会标记可能的情况;严格的锚点正则表达式才是真正的防御。

  • 账龄桶映射已在两份文档上验证,两者都落在相近的桶中。在信任生产环境中的桶标签之前,请先运行一条包含真实 90+ 账龄的语句。

  • 加密或受密码保护的 PDF 无法处理;它们会以 error 形式出现。

Related MCP Connectors

Related MCP Servers