Skip to main content
Glama
AlanAAG

doc-extract

by AlanAAG

doc-extract

PDF を入力として、文書全体を検証済みの構造化 JSON として出力する、それだけを行う MCP サーバーです。

このワークフローのために構築されました:

[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 と座標を返す

呼び出し間での保存

スコープの拡大を構造的に難しくするための強制:

  • 3 つのツールすべてに 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. はレイアウト再構築の問題です:

line

token

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 には不十分です。そのため、全文書抽出はすべての文書に対して実行され、一致するかどうかに関係なく、同じコンテンツの 4 つのビューを生成します:

フィールド

内容

用途

content.markdown

LLM 用にレンダリングされた文書

チャット。 これを保存。

content.text

プレーンテキスト

検索、埋め込み

content.key_values

ページ上のすべての Label: value

フィルター、ルックアップ

content.blocks

型付けされ、順序付けられ、位置付けられたブロック

プログラムでの使用、編集

content.chunks

見出しで分割されたマークダウン

長い文書の取得

tables[]

各テーブルを列 + 行として

レンダリング、エクスポート

line_items[]、metadata{}

型付け + 検証済み

SQL 集計

プロファイルのない文書はもはや行き止まりではありません。 parsed_without_profile を返し、content が完全に設定されます — チームはプロファイルを作成する前に、それを保存し、チャットし、操作できます。プロファイルは、型付けされた明細項目とクロスチェックを追加するだけです。

チャットの成果物としてマークダウンが選ばれる理由

「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 へ、「この文書は何を言っているか」はマークダウンへ。 1 ページの明細書はプロンプト全体に収まり、全体を渡すことはチャンクを取得するよりも優れています。

出力契約

コンシューマーはダックタイピングではなく 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

デフォルトではオフ (ペイロードが約 2 倍になります)。後続のノードが編集、ハイライト、または視覚的検証を行う必要がある場合にオンにします。bbox は PDF ポイント単位の [x0, top, x1, bottom] で、折り返されたセルが占めたすべての行に及びます — したがって、SI/08781/CN/00007 上の編集ボックスは両方の視覚的な行を正しくカバーします。

日付は ISO 8601 です。金額は浮動小数点数で、印刷されたとおり支払いの場合は負数です。


検証、そしてそれが機能する証拠

status: "ok" はすべてのチェックが合格したことを意味します。独立した 2 つのファミリーがあります:

算術 — 読み取った数値が印刷された数値を再現するか?

  • running_balance_chain — 各残高は自身の行の金額だけ進みます。合計よりも強力です: 失敗した行を特定し、合計ではまったく見えない行の並べ替えや重複を検出します。

  • sum_equals_last — 金額の合計が期末残高と一致します。

  • summary_equals_last — エージング合計が一致します。

構造 — 再構築がページを消費したか?

  • word_coverage — テーブル領域内のすべての単語が正確に 1 つのセルに配置されました。コア不変条件です。

  • no_unassigned_words、no_orphan_lines — スキップされたものはありません。

  • no_suspicious_rows — スパース行 (継続断片を新しい行と誤認) とページ区切りをまたいで縫合された行をフラグします。

  • field_matches — 参照番号の形状チェック。

構造チェックが存在するのは、算術はテキストの破損を見ることができないからです: 壊れた参照番号でもクロスフットは完全に一致します。tests/test_detection.py はデータを 7 通りに破損させ、各チェックが発火することをアサートします:

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 で説明されています — このサーバーがどのように構成され、なぜそうなっているか: トランスポート (なぜ stdio ではなくストリーミング可能な HTTP か)、ツール設計、docstring-as-prompts、認証、SDK 2.x の落とし穴。


自由に変動するもの vs. プロファイル編集が必要なもの

主張ではなく測定 — tests/test_robustness.py は一度に 1 つの軸でフォーマットを変更します。

自由。変更不要:

変動

結果

異なるサプライヤー、金額、日付

ok

任意の行数、任意のページ数にわたる

ok

折り返し深度 0、1、2、4+ 行 — 1 つの文書内で混在

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."
}

修正は 1 行の YAML 変更と再読み込みです — 再デプロイは不要です。

複数ページの動作

明細書の実行は「同じページの N 回」ではありません。これらはそれぞれ tests/test_multipage.py でテストされています:

シナリオ

結果

ヘッダーがすべてのページで繰り返される

ok — すべての行

ヘッダーが 1 ページ目のみに印刷される

ok — バンドは引き継がれる

ページ区切りで行が切られる

ok — 折り返された末尾はその行に縫合される

文書の途中でページサイズ / 向きが変わる

ok — バンドはページごとに再構築される

無関係なページ (規約、送金) が綴じ込まれる

ok — スキップされ、行は発明されない

これらのうち 2 つは実際の修正が必要でした。

ヘッダーが 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]}

Types: 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。正常系では 1 回の呼び出し。レビュー/オンボーディングの分岐ではさらに連鎖する。

  • source_type="url" を推奨。base64 はペイロードを約 33% 膨張させる。

  • 挿入ノードがスキーマを所有している。このサーバーはその存在を知らない。


エンジニアリングノート

  • 行クラスタリングの許容値はドキュメントごとに導出される — ハードコードではなく、グリフサイズの中央値から算出されるため、異なるスケールの同じレポートでも解析できる。この変更により実際のバグが明らかになった: BP : と BP: はトークン化の結果が異なるため、メタデータの正規表現は現在、句読点を正規化したテキストに対して実行される。

  • ページ区切りステッチ — ページ境界をまたいで折り返されたセルは、持ち越された行に結合され、stitched_across_page_break としてフラグ付けされる。

  • 疎行検出 — カラムの 1/3 以下しか埋めていない行は、誤ったアンカーの可能性としてフラグ付けされる。これは孤立検出では捕捉できない唯一の失敗パターンである。

既知の制限

  • 折り返された断片がアンカーカラムに配置され、かつ アンカーパターンに一致した場合、新しい行として読み取られる。疎行検出は可能性の高いケースをフラグ付けするが、厳密なアンカー正規表現が本当の防御策である。

  • エージングバケットのマッピングは 2 つのドキュメントで検証されており、どちらも近いバケットに分類されている。本番でバケットラベルを信頼する前に、実際の 90 日超エージングのステートメントで実行すること。

  • 暗号化またはパスワード保護された PDF は処理されない。error として表示される。

Related MCP Connectors

Related MCP Servers