@pilot-agents/fact-check-mcp
v0.3.0
Published
MCP server that records and checks an AI agent's fact-checking work: quotes must be found verbatim in fetched evidence, coverage is computed from the source text, and the report is generated from the ledger - not from what the agent claims. It does not ju
Downloads
666
Maintainers
Readme
fact-check MCP サーバー
AI エージェントが文章の裏取りをするときに、作業の跡を機械的に確かめて記録する MCP サーバーです。
このサーバーが確かめるのは次の 3 つで、主張が本当かどうかは確かめません。
- 引用文が証拠本文に実在すること — 実在しない引用は登録できません
- 元ネタのどこを見て、どこを対象外にしたか — 全文が埋まるまで finalize は通りません
- 判定に必要な根拠の記録が揃っていること — 例えば
verifiedにはsupportsの紐づけが要ります
| AI がやること | ツールが機械的にやること |
| --- | --- |
| 元ネタのどこが事実主張かを切り分ける | 範囲が本文の内側かを検証し、範囲の実テキストを返す |
| 証拠の取得元と、その出どころ(元ネタが示した出典か/自分で探したか)を申告する | 取得そのもの・スナップショット保存・取得経緯の記録 |
| 証拠本文から引用文を選ぶ | 引用文が証拠本文に実在するかの照合(実在しなければ登録を拒否) |
| 証拠と主張の関係(supports / contradicts など)を判断する | 判定と関係の記録が揃っているかの確認(supports の紐づけが無い verified は拒否) |
| — | 網羅率の計算、保存済みスナップショットからの引用箇所の画像生成、最終レポートの生成 |
レポートは台帳から生成するので、AI が「確認済み」と書いただけでレポートに載ることはありません。 一方で、関係の選び方が妥当か・証拠が信頼できるか・主張が本当かは判定していません。 何を確かめていないかは末尾の「このツールが確かめないこと」に書いてあります。
0.1.1 から 0.2.0 への移行
0.1.1 から続けて使うときに先に知っておく必要がある変更だけを並べます。仕組みの説明は リンク先にあります。
台帳の保存形式が version 3 になりました。古い版には戻せません。 0.1.1 は version 3 の台帳を 「バージョンが違う」と言って読みません。0.1.1 で作った version 2 のセッションはそのまま読めますが、 更新した時点で version 3 で保存され、0.1.1 では開けなくなります(読み取りだけの操作では 書き換わりません)。0.2.0 に上げる前に
FACT_CHECK_DIR(既定は.fact-check/)を丸ごと コピーしておいてください → 保存先対応する Node.js を 2 系統の LTS に絞りました。 22 系は 22.22.1 以降、24 系は 24.11.1 以降です (0.1.1 の
>=22から狭まっています)→ 必要なものstart_sessionが候補範囲を全件返さなくなりました。振る舞いの互換性変更です。 返るのは 1 ページ分(既定 100 件)で、続きはread_source_segmentsで読みます。0.1.1 の感覚で 「返ってきた候補が全部」と思って登録を終えると、網羅率が 100% に届かずfinalizeが拒否します → 候補範囲のページング// start_session の応答(抜粋) { "segments": [ /* 100 件 */ ], "segment_total": 503, "next_segment_offset": 100 } // 続きを read_source_segments に渡す。next_segment_offset が null になるまで繰り返す { "session_id": "fc_...", "segment_offset": 100, "segment_granularity": "sentence" }ツールが 10 個から 12 個になりました。 増えたのは
read_source_segments(候補範囲の続きを 読む)とrevise_record(取り消しと復元)です → ツール一覧**誤登録を取り消せるようになりました。**同時に、
finalizeの拒否条件が 1 つ増えています。 取り消しで根拠を失った判定があるとレポートを書き出せません(0.1.1 では通っていた台帳が 拒否されることがあります。get_statusのverdicts_without_basisを見て付け直してください)→ 誤登録の取り消しと復元引用箇所の画像は、ライブページではなく取得時に保存したスナップショットから作ります。 見た目は元ページと違います。保存名も変わり、HTML から撮った画像は
attachments/<attachment_id>-saved-html.png、抽出本文から撮った画像は-saved-text.pngと、 試行ごとに別のファイルとして残ります(PDF は従来どおり<attachment_id>.png)→ 引用箇所の画像について
必要なもの
- Node.js 22.22.1 以降の 22 系、または 24.11.1 以降の 24 系(どちらも LTS)。
下限がこの 2 つなのは、失敗の原因を整形するときに使う
util.inspectが、読み出せない Error に当たると例外を投げる不具合を抱えていて、それが直ったのがこの版だからです (PDF の読み取りに使うpdfjs-distの要求は 22.13.0 以上で、こちらより緩いです)。 検証していない奇数系は対象に含めていません - ヘッドレスブラウザ(Chromium)。同梱していません。JS で描画するページの取得と、保存済み スナップショットからの引用箇所の画像生成に要ります。HTTP 取得だけで済む証拠(ふつうの HTML ページ・PDF・ローカルファイル)はブラウザ無しでも扱えます
npx playwright install chromium # 初回のみ。ブラウザが要る操作で必要になるChromium を入れずに使い始めても構いません。ブラウザが要る場面に来たときだけ失敗し、 エラー文にこのコマンドが出ます。
MCP クライアントへの登録
npm から使う場合はビルド不要です。npx が起動のたびにパッケージを解決します。
Claude Code
claude mcp add fact-check -- npx -y @pilot-agents/fact-check-mcp保存先を指定する場合:
claude mcp add fact-check \
--env FACT_CHECK_DIR=/absolute/path/to/fact-check-data \
-- npx -y @pilot-agents/fact-check-mcp登録後、claude mcp list に fact-check が出れば接続できています。
.mcp.json(プロジェクトに置く場合)
{
"mcpServers": {
"fact-check": {
"command": "npx",
"args": ["-y", "@pilot-agents/fact-check-mcp"],
"env": { "FACT_CHECK_DIR": "/absolute/path/to/fact-check-data" }
}
}
}Codex
設定ファイル(~/.codex/config.toml)に追記します。
[mcp_servers.fact-check]
command = "npx"
args = ["-y", "@pilot-agents/fact-check-mcp"]
# npx は初回にパッケージと依存を取得するので、既定の起動待ちでは間に合わないことがある
startup_timeout_sec = 120
[mcp_servers.fact-check.env]
FACT_CHECK_DIR = "/absolute/path/to/fact-check-data"リポジトリをそのまま使う場合(開発・改造)
pnpm install
pnpm exec playwright install chromium # ヘッドレスブラウザ(初回のみ)
pnpm buildpnpm build が dist/index.js を作ります。登録先のコマンドを npx の代わりにこのファイルにします。
claude mcp add fact-check -- node /absolute/path/to/fact-check/dist/index.js[mcp_servers.fact-check]
command = "node"
args = ["/absolute/path/to/fact-check/dist/index.js"]ローカルのソースで試す(ホットリロード)
dist/ を作り直さず、ローカルのソースのまま クライアントに繋ぐ方法です。ソースを保存すると
数百ミリ秒で中身が差し替わるので、「ツールを操作する → バグを見つける → ソースを直す → もう一度
操作して確かめる」をクライアントを再起動せずに回せます。
pnpm install
pnpm exec playwright install chromium # ヘッドレスブラウザ(初回のみ)
pnpm dev:mcp # 普段はクライアントが起動するので、手で叩く必要はありませんpnpm dev:mcp はクライアントとの接続を保ったまま、内側の実サーバー(既定 tsx src/index.ts)だけを
入れ替える薄い前段です(scripts/dev-mcp/)。stdio の MCP サーバーはクライアントがプロセスを起動して
繋ぎっぱなしにするため、素朴にプロセスを再起動すると接続が切れます(新しいプロセスは initialize を
受け取っておらず、クライアントも送り直しません)。前段が initialize を覚えていて新しいプロセスに
送り直すので、接続は切れません。
| オプション | 既定 | 意味 |
| --- | --- | --- |
| --server <コマンド> | tsx src/index.ts | 内側で動かす実サーバー |
| --watch <ディレクトリ> | src | 再帰的に監視して、変更で入れ替えるディレクトリ |
.mcp.json(Claude Code のプロジェクトスコープ)
{
"mcpServers": {
"fact-check-dev": {
"command": "pnpm",
"args": ["--silent", "dev:mcp"],
"env": { "FACT_CHECK_DIR": "/absolute/path/to/fact-check-data" }
}
}
}Claude Code はプロジェクトスコープの .mcp.json に書いたコマンドを、そのプロジェクトのディレクトリで
起動します。だから pnpm と相対パスのままで動きます(この前提は pnpm e2e:dev が実際に
pnpm --silent dev:mcp を cwd 指定で起動して確かめています)。--silent は pnpm 自身の出力が
stdout に混ざらないようにするためです。FACT_CHECK_DIR はそのまま実サーバーに渡ります。
Codex
[mcp_servers.fact-check-dev]
command = "pnpm"
args = ["--silent", "--dir", "/absolute/path/to/fact-check", "dev:mcp"]
startup_timeout_sec = 120
[mcp_servers.fact-check-dev.env]
FACT_CHECK_DIR = "/absolute/path/to/fact-check-data"動きかた
- ソースを保存すると、300ms ほど変更をまとめてから実サーバーを入れ替えます。入れ替えが終わると
notifications/tools/list_changedを送るので、ツール定義を変えたときはクライアントが取り直します - 入れ替えの瞬間に流れていた呼び出しはエラーになります(
-32000、本文に「サーバーを再起動したため 中断した」とどの method だったか)。同じ呼び出しをもう一度投げれば通ります - 入れ替え中に届いた呼び出しは溜めておき、新しいプロセスの初期化が済んでから順番どおりに流します
- ソースに構文エラーがあると実サーバーは起動に失敗しますが、前段は生き続けます。その間の呼び出しは
-32001(子プロセスが起動していない旨)で返り、ソースを直して保存すると起動をやり直します - 失敗の理由は stderr に出ます。Claude Code では
/mcpからサーバーの状態と stderr のログを見られます (fact-check-devを選ぶと、起動失敗のスタックトレースもそこに出ます) dist/は関係ありません。scripts/dev-mcp/はtsconfig.build.jsonの対象外なので、dist/にも npm パッケージにも入りません
共通の注意
- MCP 接続(プロセス起動)でサーバーが立ち上がります。常駐サービスや別途起動するデーモンはありません
- ヘッドレスブラウザは 初回に必要になった時点 で起動します。ブラウザを使わないセッションでは起動しません
- ローカル利用が前提です
ワークフロー
start_session ←→ read_source_segments(候補範囲の続きを読む)
↓ 返ってきた候補範囲を…
register_segments(まとめて)/ register_claim・mark_non_claim(1 件ずつ) ←→ get_status(残りを確認)
↓ 本文の全文字がどちらかに入る(網羅率 100%)
fetch_evidence ──取得できないとき──▶ submit_agent_capture
↓
attach_evidence(引用文の実在をツールが照合)
↓
set_verdict
↓
finalize(網羅率 100% かつ全 claim 判定済み、判定の根拠が揃っていなければ拒否)
誤って登録したものは、どの段階からでも revise_record で取り消せます(復元もできます)。ツール一覧
| ツール | 役割 | 拒否する条件 |
| --- | --- | --- |
| start_session | 元ネタ(text / file / url)を取り込む。本文を隙間なく敷き詰めた候補範囲を1 ページ分返す | 本文テキストを抽出できない |
| read_source_segments | 候補範囲の続きを読む(読み取り専用。台帳は変わらない) | session_id が無い・segment_offset が負・max_segments が範囲外 |
| register_claim | 範囲を「裏取りすべき事実主張」として登録する | 範囲が本文の外・start > end・空範囲 |
| mark_non_claim | 範囲を「裏取り対象外」として理由つきで登録する | 同上 |
| register_segments | 範囲を claim / non_claim としてまとめて登録する | 1 件でも不正なら全件拒否(途中まで登録しない)・空配列 |
| fetch_evidence | 証拠(Web ページ・PDF・ローカルファイル)をツール自身が取得し、スナップショットを保存する | HTTP もブラウザも失敗(submit_agent_capture を促す) |
| submit_agent_capture | AI 自身のブラウザ操作ツールで取った内容を証拠として提出する(expected_terms で「在るはずの語」を照合できる) | 本文テキストが空・text と text_path の同時指定/両方未指定・スクショのパスが読めない |
| attach_evidence | 引用文の実在を照合して claim と evidence を結ぶ | 引用文が証拠本文に実在しない・自己参照 |
| set_verdict | claim に最終判定を付ける | verified に有効な supports が無い / contradicted に有効な contradicts が無い |
| revise_record | 誤登録した claim / non_claim / evidence / attachment を理由つきで取り消す。取り消しの復元、セッション全体の保管もこれで行う | 対象の id が無い・すでに取り消し済みの二重取り消し・取り消されていないものの復元 |
| get_status | 網羅率・未処理の範囲・未判定の claim・根拠が無くなった判定・取り消し履歴を返す | — |
| finalize | report.md / report.json / report.html を書き出す | 網羅率 100% 未満・判定漏れ・claim が 0 件・判定を支える有効な根拠が無くなっている |
| export_report | レポートを画像ごと 1 ファイルにした HTML / PDF を、指定した絶対パスへ持ち出す(台帳は変えない) | 相対パス・形式と拡張子の食い違い・出力先に既にファイルがある(overwrite 無し)・台帳が参照する画像が無い・PDF の描画で例外や画像の読み込み失敗があった |
誤登録の取り消しと復元(revise_record)
打ち間違いや取り違えを、セッションをやり直さずに直せます。
- 取り消しは論理的な無効化です。元の claim / non_claim / evidence / attachment のレコードも、 取得した本文・HTML・PDF・画像も、1 つも消えません。物理削除の口はありません
- 取り消したものは、その時点から
claim/non_claim— 網羅率の根拠になりません(その範囲は未処理に戻ります)evidence/attachment— 判定の根拠になりません。親の claim か evidence を取り消すと、 ぶら下がる attachment も自動的に根拠でなくなります(attachment 側の記録は変わりません)session— それ以上の裏取りを受け付けません(保管)。読み返しとpnpm report:rebuildはできます
- 復元はその取り消し 1 件だけを戻します。 親を復元しても、個別に取り消した子は取り消されたままです
- 根拠が無くなった判定は
get_statusのverdicts_without_basisに出て、finalizeが拒否します。set_verdictで付け直すか、根拠を付け直してください - 二重の取り消しと、取り消していないものの復元は、今の状態を示して拒否します(黙って成功しません)
- 理由・時刻・対象 id は履歴に残り、
report.md/report.json/report.html(画面と印刷の両方)の 「取り消し履歴」に全件出ます。取り消した中身(主張の文・対象外の理由・引用文)も一緒に出ます - 台帳を変えたあと
finalizeを呼び直すまで、書き出し済みのレポートは「最新の有効な結果」では ありません。すでにレポートがあるセッションでは、台帳を変えた時点で 3 形式を書き直し、 先頭に「finalize を通していない暫定表示」と明記します(取り消しに限らず、register_claimやset_verdictなどのふつうの変更でも同じです) - ただし、レポートを書き直せなかった場合は古いファイルがそのまま残ります。
ディスクに書けない状態では、その古いファイル自体に警告を書き込むこともできません。
そのとき台帳には「レポート未完了」が残り、
get_statusのreports_stale_sinceが 非 null のままになります。エラーにも「残っているレポートは古い可能性がある」と回復手順を出します。 紙やファイル単体を見て最新かどうかは判断せず、get_statusを見てください
引用文の照合規則
attach_evidence に渡した引用文は、次の規則で証拠本文と突き合わせます。
- Unicode NFKC 正規化(全角英数 ↔ 半角、半角カナ ↔ 全角カナ などを吸収)
- 空白の連続を 1 つの半角空白に畳む(改行・タブ・全角スペースを含む)
- その上での完全部分一致
要約・言い換え・記憶からの復元は通りません。見つからなかった場合は、引用文と最も長く一致した箇所の抜粋を 添えてエラーを返すので、どこまで合っていてどこから違うかが分かります。
証拠の出どころ
fetch_evidence と submit_agent_capture は discovered_via を必須で要求します。
| 値 | 意味 |
| --- | --- |
| cited_in_source | 元ネタ自身が出典として示していた |
| agent_search | AI が検索などで自分で見つけた |
| agent_knowledge | AI が自分の知識から当たりを付けた |
任意の discovery_note(どこに書いてあったか、何で検索したか)も添えられます。どちらもレポートの
証拠ごとの表示と末尾の証拠一覧の両方に出ます。「元ネタが示した裏付け」と「AI が後から探してきた
裏付け」は読み手にとって重みが違いますが、ツールからは区別できないため AI に申告させています。
網羅率
網羅率は「元ネタ本文のうち、claim か non_claim のいずれかの範囲に含まれる文字の割合」です(範囲の重なりは
和集合で 1 回だけ数えます)。見出しも空行も分母に入るため、start_session が返す候補範囲は本文を隙間なく
敷き詰めるように割ってあります。候補をそのまま使えば 100% に到達できます。
取得できない URL に当たったとき
fetch_evidence は HTTP 取得 → ヘッドレスブラウザの順に試し、どの段階で何が起きたかを attempts に残します。
両方失敗した場合はエラーの中で「あなた自身のブラウザ操作ツールでこの URL を開き、本文テキストとスクリーン
ショットを取って submit_agent_capture を呼ぶこと」と指示します。
本文が短すぎて失敗したとき(表題と資料へのリンクしか置いていないページ)は、そのページの中の PDF への
リンクを最大 5 件、絶対 URL にしてエラー文に並べ、「本文がこの PDF にある可能性が高いので、その URL で
fetch_evidence を呼ぶこと」と指示します。href が .pdf で終わるもの(クエリやフラグメントが付いていても
可)と、type="application/pdf" を持つものを候補にします。PDF リンクが無ければ従来どおりの文面です。
この経路で登録した証拠は provenance = agent_captured として記録され、レポートには
「ツールが直接取得していない証拠」という警告が必ず付きます。
PDF を証拠にする
fetch_evidence に PDF の URL やローカルパスを渡すと、ページ境界を保ったまま本文を抽出します。
判定は拡張子ではなく中身(先頭バイト列)で行うので、.pdf でない URL でも PDF なら PDF として扱います。
- 元の PDF バイト列 (
evidence/evidence_N.pdf) と抽出テキスト (.txt) の両方を保存します attach_evidenceは引用箇所が何ページ目かを台帳とattach_evidenceの返り値に記録します- そのページを描画し、引用箇所に枠を重ねた PNG を
attachments/に保存します
描画は既に依存しているヘッドレスブラウザの中で pdf.js に行わせます(ネイティブ拡張も外部コマンドも 増やさないため)。枠の左右端は text item 内を文字数で按分するので、プロポーショナルフォントでは 1 文字ぶん程度ずれることがあります。テキストレイヤの無いスキャン PDF は「1 文字も抽出できなかった」 として失敗します(黙って空の証拠は作りません)。
本文の返し方
fetch_evidence が返す抽出本文は既定で先頭 12000 文字です。text_limit(上限 40000)で長さを、
text_offset で続きの位置を指定できます。切り詰めた場合は truncated と next_offset が付きます。
数万文字のページを先頭から読み進めたくないときは find に語を渡します。引用文の照合と同じ規則
(NFKC 正規化 + 空白の畳み込み)でその語を探し、一致箇所の周辺を返します。返り値の find には
一致件数・原文でのオフセット・次の一致位置が入るので、同じ find と text_offset=<次の一致位置>
で次の箇所を読めます(同じ find を付けたまま呼ぶと「その位置以降の最初の一致」を返すので、
1 つの一致の続きを読むときは find を外して text_offset だけを指定してください)。見つからなかったときは find.found=false と件数を返し、窓が検索結果では
ないことを明記します(黙って先頭を返しません)。全文はこれまでどおりセッションディレクトリに保存されます。
一致の手前に付ける文脈の量は text_limit に合わせて決まります。窓は必ず一致の先頭を含み、
一致が text_limit 以下なら一致全体が入ります(text_limit=10 のような小さい値でも、余白だけを
返して一致が窓の外に出ることはありません)。一致が text_limit より長いときは先頭だけを返し、
一部であることと続きの text_offset を find.note に書きます。
候補範囲のページング
start_session は候補範囲を1 ページ分だけ返します(既定 100 件、合計 4000 文字が目安)。
長い元ネタで全件を返すと応答が途中で切れ、見えた範囲が全部だと誤解したまま進んでしまうためです
(15,768 文字の元ネタが 503 件・約 3,500 行になりました)。
- 続きは
read_source_segmentsにsegment_offset=<前の応答の next_segment_offset>を渡して読みます segment_granularityにparagraphを渡すと段落単位の粗い候補になり、件数が減ります。どれだけ 減るかは元ネタの書き方(1 段落あたりの文の数)次第です(sentenceが既定。ページを跨ぐときは 同じ値を使ってください)- 候補の
textは予算のために縮めません。終止符も改行も無い長い塊(1 行に詰まった表など)は、 候補を作る段階で 1000 文字以下に割ります。割るのはページングの前なので、indexと[start, end)はmax_segmentsを変えても同じ値になります - 割る位置はコードポイント境界(既定では書記素境界)なので、サロゲートペアや合字が壊れることは ありません
- 守っている不変条件は 2 つです。全ページを繋ぐと本文が 1 文字も欠けずに復元できることと、
1 ページの
text合計が 4000 文字を超えないこと - 1 ページ分の候補を登録しただけでは網羅率は 100% になりません。残りは
get_statusとfinalizeの 拒否で分かります
提出した本文が目的のページかを確かめる
submit_agent_capture には任意で expected_terms(そのページに在るはずの語)を渡せます。引用文の
照合と同じ規則で実在を確かめ、見つからなかった語を応答・台帳・レポートに警告として残します。
AI のブラウザ操作ツールが本文の抽出に失敗し、CSS だけ・別ページの内容を提出してしまったことに
気づくための仕組みです。警告が出ても登録はできます(体裁の崩れた本物を締め出さないため)。
未指定のときは「未検査」と記録します(「検査して問題なし」と区別するためです)。
引用箇所の画像について
attach_evidence が作る画像は、取得時に保存したスナップショットを描いたものです。ライブページを
開き直しません。
- 開き直すと、取得時と違う内容が写ります(記事の差し替え・ログイン要求・レート制限)。取得は できたのに画像を撮るときだけ弾かれることも実際に起きました(HTTP 取得は 200 なのに ヘッドレスブラウザだけ 403)。保存済みを描けば、取得後にページが落ちても 403 でも画像が残ります
- 引き換えに、外部の CSS・画像・フォントは読み込まれないので見た目は元ページと違います。 これは「取得時点の保存内容の描画」であって、元ページの外観の再現ではありません
- 描画中の外部通信は二重に塞いでいます。JavaScript を切ったコンテキストで開き(保存 HTML の中の インライン script を走らせない)、さらにスナップショット本体以外の要求をすべて遮断します
- HTML を保存していない証拠(
text/plainのページなど)は、保存した抽出本文を HTML エスケープして 等幅で描きます - 何から描いたかは台帳とレポートの
screenshot_source(saved_html/saved_text/pdf_page/agent_captured)に残ります。うまくいかなかったときの理由はscreenshot_noteに残ります。 1 回で撮れたときはこの欄は空ですが、保存 HTML で撮れずに抽出本文へ切り替えた場合は、 画像が得られていても HTML 側の失敗理由がここに全文入ります(試行の一覧はscreenshot_attempts) - サイト側が隠している領域(有料会員向けブロック、折りたたみ、
display: none)に引用文があると、 保存 HTML を描いただけでは描画矩形が取れず、ハイライトを重ねられません。このとき、保存した 抽出本文のほうを描いて 1 度だけ撮り直します。 表示の解除もペイウォールの回避も行いません (隠されている要素はそのまま。別に保存してある抽出本文を描くだけです) - 撮り直した画像には「取得時に保存した抽出本文を描画したものです。元ページの実キャプチャでは ありません。」と画像そのものに焼き込みます。レポートの外に画像だけ持ち出されても、 元ページのキャプチャと読み違えられないようにするためです
- 保存 HTML 側の画像と失敗理由は捨てません。2 つの試行は別のパス(
<attachment_id>-saved-html.png/<attachment_id>-saved-text.png)に保存し、台帳のscreenshot_attemptsに 「何を試して、どうなって、どれを採用したか」を全件残します。report.mdとビューアにも出ます - 両方失敗したときは、両方の理由を残します(片方だけにしません)
「取得成功」の判定
HTML は、記事領域(main / article / #main など)を選び、ナビゲーション・ヘッダ・フッタ・
cookie バナーを除いた上で、リンクでない地の文が 200 文字以上あることを成功の条件にします。
ページ全体の文字数で判定すると、ナビゲーションのリンク文字列だけで足切りを越えてしまい、
記事本文が 1 文字も無いページが「取得成功」として記録されるためです。足りなければヘッドレス
ブラウザで描画してから同じ規則で判定し直します。PDF とテキストには文字数の足切りを課しません
(次に試す段階が無く、短い一次資料を誤って捨てることになるため)。
保存先
セッションデータの保存先は環境変数 FACT_CHECK_DIR で指定します。未設定の場合は
MCP サーバープロセスの作業ディレクトリ直下の .fact-check/ です。
1 セッション = 1 ディレクトリで、次の構成になります。
<FACT_CHECK_DIR>/<session_id>/
├── ledger.json 台帳(このセッションの事実の全部)
├── source.txt 元ネタ本文
├── evidence/
│ ├── evidence_1.txt 抽出した本文テキスト
│ ├── evidence_1.html 取得した HTML(あれば)
│ ├── evidence_1.pdf 取得した PDF そのもの(PDF 証拠のとき)
│ └── evidence_1.png フルページスクリーンショット(ブラウザ取得時)
├── attachments/
│ └── attachment_1.png 引用箇所をハイライトした画像(保存済みスナップショットの描画。PDF は該当ページ)
├── report.md
├── report.json 台帳を丸ごと含む(レポートと台帳の食い違いを機械的に確認できる)
└── report.html 人が読んで回るためのビューア(1 ファイル完結。下記「レポートの読み方」)台帳はツール呼び出しのたびに読み直して書き戻すので、プロセスを再起動しても session_id だけでセッションを
再開できます。読み込みから書き戻しまではセッション単位で直列化してあるので、AI が複数のツール呼び出しを
1 度に送っても、後から書いた側が先の変更を消すことはありません。
台帳の形式は version で区別します。新しく書く台帳は version 3 です。 MCP ツールが読めるのは
version 2 と 3 で、version 1 は拒否します(必須になった discovered_via を持たないため)。
- 読んだだけでは版は上がりません。
get_statusやread_source_segmentsのような読み取りだけの 操作では、台帳ファイルは 1 バイトも変わりません - 更新すると version 3 で保存されます。 version 2 のセッション(0.1.1 で作ったもの)を更新した 時点で、そのセッションは 0.1.1 では開けなくなります(0.1.1 から 0.2.0 への移行)
- version 1 で作った過去のセッションを読み返したいときは
pnpm report:rebuildを使ってください (下記「レポートの読み方」)
開発
pnpm typecheck # tsc --noEmit
pnpm lint # biome check(混入検査は check:leaks で別に走らせる)
pnpm format # biome の自動修正
pnpm check:leaks # 公開物の混入検査(下記。pnpm build の後に走らせる)
pnpm test # vitest(ユニットテスト)
pnpm e2e # build して、子プロセスの MCP サーバーに stdio で繋いで一連の流れを通す
pnpm e2e:package # npm pack した tarball を入れ直して、npm 経由でも動くかを見る
pnpm e2e:dev # ホットリロード用の前段(pnpm dev:mcp)を MCP クライアントから動かして確かめる
pnpm dev:mcp # ローカルのソースのままクライアントに繋ぐ(上記「ローカルのソースで試す」)
pnpm viewer # セッション一覧のローカルサーバーを起動する
pnpm report:rebuild <session_dir> # 既存セッションの report.html を今のビューアで作り直す
pnpm report:export <session_dir> <output_path> [--overwrite] # 画像ごと 1 ファイルの HTML / PDF を持ち出すpnpm e2e / pnpm e2e:package / pnpm e2e:dev は外部サイトには一切アクセスしません。ローカルの HTTP サーバーが配る
固定ページとローカルファイルだけを使います。
公開物の混入検査(pnpm check:leaks)
公開するのは 2 経路あります。git のコミット対象と、npm パッケージの中身です。両方を走査して、 ローカルの絶対パス・メールアドレス・実行しているマシンの利用者名が混ざっていないかを機械的に確かめます。 1 件でも見つかれば終了コード 1 で落ちます。
pnpm lint(biome)とは別のコマンドです。pnpm build の後に走らせてください。 dist/ が無いと
npm パッケージの中身がほとんど空になり、公開物の大部分を検査せずに 0 件で通ってしまいます。
package.json の bin が指すファイルがパッケージの一覧に無ければ、検査自体が「先に build せよ」と
言って落ちます(順序を間違えたまま通らないようにしてあります)。
本文だけを見るのではありません。
- ファイル名そのものも照合します(利用者名がファイル名に入るのを防ぐため)
- 本文を読めなかったファイル(NUL を含むバイナリ)は失敗扱いです。中身を見ずに公開してよいと
判断したものだけ、
FACT_CHECK_LEAK_ALLOW_BINARYにカンマ区切りの相対パスで並べます - パッケージの中身の形も見ます。
dist/src/のような階層(rootDirのずれ)や*.map(ローカルの絶対パスを埋め込む)が入っていたら落とします - 照合に使ったパターンの id を全部出力します。環境から作れなかったパターン(
env-user/env-home)は使わなかった理由を出力します。「検査したつもりで実は何も見ていない」状態を 出力だけで見分けられるようにするためです
当たった文字列はマスクして出します(先頭 2 文字だけを残します)。検査の出力がログに残って 二次的な漏洩になるのを避けるためです。同じ理由で、禁止語リストの置き場所は絶対パスではなく ファイル名だけを出します。
利用者名($USER / $USERNAME / $LOGNAME)は単語として照合します(ab-name-cd の一部には
当てません)。root や node のようなコンテナ・CI の汎用アカウント名は、誰のものでもないうえ普通の
コードに単語として現れるので照合に使いません(使わなかった理由が出力に出ます)。
組み込みで見るのは「形」だけです。固有の語(社名・サービス名・内部の呼び名など)を足したいときは、 リポジトリの外にリストを置いて環境変数で指す形にします。リポジトリの中に置くと、禁止語そのものが 公開されてしまいます。
# リストは各自のマシンに置く(1 行 1 語、# 以降はコメント)
export FACT_CHECK_LEAK_DENYLIST=~/.config/fact-check/leak-denylist.txt
pnpm build && pnpm check:leaks指定しなければ、組み込みのパターンだけで走ります(その旨が出力に出ます)。
--require-denylist を付けると(環境変数 FACT_CHECK_LEAK_STRICT=1 でも同じ)、外部の禁止語リストが
未設定・読めない・語が 0 件のときにエラーで落ちます。公開の経路(prepublishOnly と
publish.yml)はこのモードで呼びます。リストの設定を忘れたまま公開できる経路を残さないためです。
pnpm check:leaks --require-denylist # 禁止語リストが無ければ落ちる公開の手順
export FACT_CHECK_LEAK_DENYLIST=~/.config/fact-check/leak-denylist.txt # 厳格モードで必要
npm pack --dry-run # 何が入るかを確認する(dist / README.md / LICENSE / package.json だけ)
npm publish # prepublishOnly が typecheck → lint → test → build → check:leaks --require-denylist を回すprepublishOnly はこの 5 つを 1 回ずつ、この順で回します。混入検査はビルドの後に 1 回だけです。
GitHub Actions
.github/workflows/ に 2 つあります。
| ワークフロー | いつ動くか | 何をするか |
| --- | --- | --- |
| ci.yml | main への push、pull request、手動実行 | Node 22 / 24 の両方で typecheck → pnpm verify(lint → test → build → 混入検査(組み込みパターンのみ)→ e2e → e2e:package)→ npm pack --dry-run |
| publish.yml | GitHub Release を公開したとき | CI と同じ検証を通してから、Release のタグと package.json の version の一致を確認し、禁止語リストの secret を一時ファイルへ書き出して npm publish(prepublishOnly が厳格モードの混入検査を回す) |
混入検査は pnpm build の後に走ります(ビルド前に走らせるとパッケージの中身を検査できないため)。
ci.yml は pull request から secret に届かないので、禁止語リストを使わず組み込みパターンだけで走ります。
固有の語まで見るのは publish.yml の厳格モードです。
外部 action は commit SHA で固定してあります(タグは差し替えられるため)。pnpm の版は
package.json の packageManager が決めるので、ワークフロー側では指定しません。
npm への認証は Trusted Publisher(GitHub Actions の OIDC)で行い、長期トークンは持ちません。
来歴証明(provenance)は package.json の publishConfig.provenance で有効化してあります。
初回だけ、npm 側でパッケージの Settings → Trusted Publisher に GitHub Actions を登録します
(organization pilot-agents、repository fact-check、workflow publish.yml)。
リポジトリの secret は 1 つだけです。
| secret | 中身 |
| --- | --- |
| FACT_CHECK_LEAK_DENYLIST_CONTENT | 禁止語リストの中身(1 行 1 語)。ワークフローが一時ファイルへ書き出して厳格モードの検査に渡します。ログには出しません |
公開の手順は次のとおりです。
npm version patch # package.json の version を上げてコミットとタグを作る
git push origin main --tags
gh release create v0.1.1 --generate-notes # Release を公開すると publish.yml が走るレポートの読み方
finalize はセッションディレクトリに 3 つの成果物を書きます。
| ファイル | 用途 |
| --- | --- |
| report.html | 人が読むためのビューア。ブラウザで開く |
| report.md | 差分を見る・引用する・別のツールに渡す |
| report.json | 台帳を丸ごと含む機械可読の記録(attention 配列付き) |
report.html(ビューア)
report.html をブラウザで開くだけです(file:// でそのまま動きます。サーバーは要りません)。
CSS も JavaScript も 1 ファイルに入っていて、外部 URL は一切読み込みません。スクリーンショットだけは
同じディレクトリからの相対パスで参照するので、セッションディレクトリごと渡してください。
PC では画面の全幅を使い、上部の集計の下に主張一覧・本文・詳細を横に並べます。
- 上部: 表題・主張と証拠の件数・網羅率・判定フィルター。
判定ボタンで表示対象を切り替え、「すべて表示」で戻せます。
AI が提出した証拠(
agent_captured)の警告は常に表示し、取得元や生成時刻などは「セッションの詳細」で読めます - 主張一覧: 全主張を本文の順に並べ、要確認の主張に印を付けます。 クリックすると本文の該当箇所へ移動し、その判定と証拠が詳細に出ます
- 元ネタ本文: 全文を表示し、判定を記号・下線・背景色で区別します。裏取り済みは下線だけで示します。
対象外 (
non_claim) の範囲にポインターを重ねると理由が出ます - 主張の詳細: 主張・判定理由・証拠の引用・根拠説明・出典を表示します。
元ネタの該当文や ID・取得時刻・本文 sha256 は詳細を開いて読めます。
画像は何から描いたかを表示し、「原寸で開く」で拡大できます。閉じるボタンか
Escで戻れます。 画像の取得失敗は見出しを表示し、開くと保存されたエラー全文を読めます
スマートフォンでは主張一覧を短い枠に収め、その下に詳細、元ネタ本文の順で表示します。
判定は色だけでなく 1 文字の記号(済 裏取り済み / 部 一部のみ / 不 裏取り不能 / 矛 矛盾 /
未 未判定)と下線の種類でも示します。色の違いが見えなくても読めます。
「前」「次」ボタンや j / k(または ↑ ↓)で、絞り込み中の主張を順に移動できます。
キーボードだけでも読み進められます。
Tabの 1 つ目と 2 つ目がスキップリンクで、本文と詳細へ直接飛べます(主張一覧のボタンを すべて通る必要はありません)- 本文の主張は
Tabで到達でき、EnterかSpaceで選べます - 3 領域には見出し(
h2)があり、読み上げソフトの見出し移動で行き来できます - 選択を変えると「何件目の何を選んだか」だけが短く読み上げられます。詳細ペイン全体は 読み上げの対象にしていません(主張を 1 つ移るたびに証拠の全文が読み上げられると操作できません)
- 画像の原寸表示は
Escで閉じ、開いたボタンへフォーカスが戻ります
印刷(PDF 保存)では、絞り込みに関係なく次を全件出します。画面側の開閉状態は変わりません。
- 全主張の詳細(画面で閉じていた管理情報やエラー全文も含む)
- 対象外とした範囲(理由と元ネタの該当文。
report.mdと同じ件数・同じ並び) - 取り消し履歴(取り消した理由・時刻・対象と、取り消した中身)
レポートを 1 ファイルで持ち出す(export_report / pnpm report:export)
report.html はスクリーンショットを相対パスで参照するので、そのファイルだけを別の場所へ置くと画像が
切れます。人に渡す・別の場所に保存する・紙にするときは、画像を中身ごと埋め込んだ 1 ファイルを
作ります。
MCP ツールから:
// export_report の入力
{ "session_id": "fc_...", "format": "pdf", "output_path": "/absolute/path/to/fact-check.pdf" }
// 既にあるファイルを置き換えるなら "overwrite": true を足すコマンドラインから(形式は出力先の拡張子で決まります):
pnpm report:export .fact-check/fc_20260101T000000_deadbeef ~/Desktop/fact-check.html
pnpm report:export .fact-check/fc_20260101T000000_deadbeef ~/Desktop/fact-check.pdf --overwritehtml—report.htmlと同じビューアに、画像を data URI で埋め込んだもの。ファイル 1 つでfile://から開けます。画像 1 件あたり数百 KB 入るので、ファイルはreport.htmlより大きくなりますpdf— 同じ HTML を Chromium の印刷レイアウト(上記「印刷(PDF 保存)」と同じ内容。全主張・ 対象外の範囲・取り消し履歴を全件)で A4 にしたもの。ヘッドレスブラウザが要ります (必要なもの)。日本語のフォントは実行環境のものを使うので、フォントの無い 環境では文字が出ません
台帳は変えません(読み取りだけの操作です)。finalize を通していないセッションでも書き出せますが、
その内容には「finalize を通していない暫定表示」の断りが焼き込まれ、応答にも warning が付きます。
正式なレポートにするなら finalize を通してから書き出し直してください。
出力先は絶対パスで指定します。親ディレクトリが無ければ作ります。既にファイルがある場所へは
overwrite: true(CLI では --overwrite)を付けない限り書きません。台帳が参照する画像ファイルが
欠けているときは、欠けている全件を挙げて拒否し、画像の切れたファイルは作りません。PDF では、
ビューアの描画で例外が出たり画像を読み込めなかったりしたときも同じく PDF を書かずに拒否します
(白紙のページを成功として渡さないため)。
セッション一覧(pnpm viewer)
pnpm viewer # 空いているポートで起動し、URL を表示する
pnpm viewer --port 8080 # ポートを指定するFACT_CHECK_DIR(未設定なら <cwd>/.fact-check)配下のセッションを一覧します。表題・開始日時・
元ネタ・網羅率・判定ごとの件数が並び、report.html があればそこへのリンク、無ければ
「未完了(網羅率 xx%・未判定 n 件)」が出ます。台帳が読めないセッションも行は出して、理由を添えます。
待受は 127.0.0.1 だけで、配るのはセッションディレクトリ配下のファイル(.html / .md / .json /
.txt / .png / .pdf)に限ります。書き込み系の機能はありません。ブラウザは自動では開きません。
過去のセッションを新しいビューアで見直す(pnpm report:rebuild)
pnpm report:rebuild .fact-check/fc_20260101T000000_deadbeefセッションディレクトリの report.json と source.txt から report.html だけを作り直します。
finalize はやり直さないので、証拠の取り直しも台帳の書き換えも起きません。
version 1 の台帳で作られたセッションも読めます。 MCP のツールは version 1 の台帳を拒否しますが
(必須になった discovered_via を持たないまま裏取りを続けさせないため)、済んだ結果を読み返すだけの
この経路は受け付けます。記録が無い項目は「旧版のため未記録」と表示されます。
扱える範囲
- 証拠にできるのは Web ページ、PDF、ローカルのファイル(
.txt/.md/.html/.pdf) です - コマンド出力・別モデルによる再判定(監査)は含みません
このツールが確かめないこと
確かめるのは引用文の実在・本文の網羅範囲・根拠の形の 3 つで、 主張が本当かどうかは確かめません。
verifiedにsupportsの添付を要求するのは、「引用が実在し、その引用と主張が結ばれた記録が ある」ことの確認であって、その引用が主張を実際に裏づけているかの判定ではありません。 引用と主張の関係(supports/contradictsなど)を選ぶのは AI で、ツールは選択を検算しません- 同じ引用に
supportsとcontradictsの両方を付けることは拒否しません。複合的な主張の 一部を支持し別の一部を否定する引用は実際にあり、機械的に誤りとは言えないためです - 証拠の信頼性・出典の権威・情報の新しさは評価しません。
discovered_via(元ネタが示した出典か、 AI が後から探したか)とprovenance(誰が取得したか)を記録に残し、判断は読み手に委ねます - 主張の切り出し方が適切か(1 文に複数の主張が混ざっていないか)も判定しません
ライセンス
MIT License(LICENSE)。
