環境情報
検証に使った環境は以下の通りです。結論はマシンスペックに依存しませんが、レイテンシの実測値はこの環境のものです。
- macOS 15.2 / Apple Silicon (M3 Pro, 36GB RAM)
- Node.js v22.14.0
- ripgrep 14.1.1 (
rg) - Notion API
Notion-Version: 2022-06-28 - MCPクライアント: Claude Desktop / Cursor
- 対象ワークスペース: 約1,200ページ、うち本文テキスト合計約18MB
結論
読み取り専用のユースケースなら、ローカル同期の方が圧倒的に速い。
Notion公式のMCPサーバー(リモートHTTP経由のリアルタイムAPI)も検討しましたが、こちらは「常に最新を返す」代わりに、毎回のツール呼び出しでネットワーク往復とサーバーサイド処理が発生します。一方、ローカルにMarkdownミラーを持ってしまえば、検索は rg で数ミリ秒、オフラインでも動きます。
ただし「書き込み」や「他メンバーの編集を即時反映」が必要なら、リアルタイムAPI一択です。つまり読み取りはローカル、書き込みはAPIというハイブリッドが現実解になります。
なぜローカル同期が速いのか
速度差の正体はほぼ「ネットワーク往復」と「レートリミット」です。
| 観点 | ローカル同期 (Markdownミラー) | リアルタイムAPI (MCP経由) |
|---|---|---|
| 検索のレイテンシ | 10〜50ms (rg) |
300〜900ms / リクエスト |
| 本文1ページ取得 | サブミリ秒(ファイル読み) | 150〜400ms |
| 100ページ横断 |
rg 1回で完結 |
100リクエスト(約33秒〜) |
| レートリミット | なし | 平均3リクエスト/秒 |
| オフライン | 動作する | 不可 |
| データ鮮度 | 同期間隔に依存(例: 5分) | 常に最新 |
| 書き込み | 不可 | 可能 |
| 権限管理 | ローカルファイルの権限 | Notion側の権限を継承 |
Notion APIはおおむね平均3リクエスト/秒でスロットリングされ、超えると 429 と Retry-After が返ります。100ページを素朴に舐めると、体感では数十秒から数分かかります。ローカルの rg なら同じ処理が1コマンドで終わります。
手順:ローカル同期パイプラインを組む
1. 同期スクリプトを書く(初回フル同期)
まずはNotion APIで全文を引っ張り、Markdownに落とします。ブロックのネストを平坦化する処理が一番面倒なところです。
// sync.ts
import { Client } from "@notionhq/client";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const notion = new Client({ auth: process.env.NOTION_TOKEN! });
const OUT = path.resolve("./mirror");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 平均3req/sを超えないように350ms空ける
async function withRateLimit<T>(fn: () => Promise<T>): Promise<T> {
const result = await fn();
await sleep(350);
return result;
}
type Block = { id: string; type: string; has_children: boolean; [k: string]: any };
function blockToMarkdown(block: Block): string {
const payload = block[block.type];
const text = (payload?.rich_text ?? []).map((t: any) => t.plain_text).join("");
switch (block.type) {
case "heading_1": return `# ${text}`;
case "heading_2": return `## ${text}`;
case "heading_3": return `### ${text}`;
case "bulleted_list_item": return `- ${text}`;
case "numbered_list_item": return `1. ${text}`;
case "code": return "```\n" + text + "\n```";
default: return text;
}
}
async function fetchChildren(blockId: string): Promise<string[]> {
const lines: string[] = [];
let cursor: string | undefined = undefined;
do {
const res: any = await withRateLimit(() =>
notion.blocks.children.list({ block_id: blockId, start_cursor: cursor, page_size: 100 })
);
for (const block of res.results as Block[]) {
lines.push(blockToMarkdown(block));
if (block.has_children) lines.push(...(await fetchChildren(block.id)));
}
cursor = res.has_more ? res.next_cursor ?? undefined : undefined;
} while (cursor);
return lines;
}
async function main() {
await mkdir(OUT, { recursive: true });
let cursor: string | undefined = undefined;
do {
const res = await withRateLimit(() =>
notion.search({ filter: { property: "object", value: "page" }, start_cursor: cursor, page_size: 100 })
);
for (const page of res.results as any[]) {
const title = page.properties?.title?.title?.[0]?.plain_text ?? page.id;
const body = (await fetchChildren(page.id)).join("\n\n");
const safe = title.replace(/[/\\:*?"<>|]/g, "_");
await writeFile(path.join(OUT, `${safe}.md`), `# ${title}\n\n${body}\n`, "utf8");
}
cursor = res.has_more ? res.next_cursor ?? undefined : undefined;
} while (cursor);
}
main().catch((e) => { console.error(e); process.exit(1); });
2. 差分同期に切り替える
2回目以降は last_edited_time を使い、更新があったページだけ再取得します。削除済みページの検知も必要なので、マニフェストを持つのが定石です。
// incremental.ts
import { readFile, writeFile } from "node:fs/promises";
type Manifest = Record<string, { lastEdited: string; file: string }>;
export async function loadManifest(p: string): Promise<Manifest> {
try { return JSON.parse(await readFile(p, "utf8")); } catch { return {}; }
}
export async function saveManifest(p: string, m: Manifest) {
await writeFile(p, JSON.stringify(m, null, 2), "utf8");
}
// 差分判定:last_edited_time が前回より新しければ再取得
export function needsSync(prev: string | undefined, current: string): boolean {
if (!prev) return true;
return new Date(current).getTime() > new Date(prev).getTime();
}
3. 検索は ripgrep に任せる
同期さえ済めば、検索はツールを自作する必要がありません。
# 全文検索(ファイル名と行番号つき)
rg -n --no-heading "レートリミット" ./mirror
# Markdownだけ、前後2行の文脈つき
rg -n -C 2 -g '*.md' "429|Retry-After" ./mirror
# 該当ファイルだけ列挙(速い)
rg -l "MCP" ./mirror | wc -l
4. ローカル検索をMCPツールとして生やす
AIクライアントからも同じミラーを引けるようにします。ここが「リアルタイムAPIのMCP」との直接比較ポイントです。
// local-search-mcp.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
const exec = promisify(execFile);
const server = new McpServer({ name: "local-notion-mirror", version: "1.0.0" });
server.tool(
"search_mirror",
{ query: z.string(), limit: z.number().default(20) },
async ({ query, limit }) => {
const { stdout } = await exec("rg", [
"-n", "--no-heading", "-m", String(limit), query, "./mirror",
]).catch((e) => ({ stdout: e.stdout ?? "" }));
return { content: [{ type: "text", text: stdout || "(no match)" }] };
}
);
await server.connect(new StdioServerTransport());
{
"mcpServers": {
"local-notion-mirror": {
"command": "node",
"args": ["/Users/me/dev/local-search-mcp/dist/index.js"]
}
}
}
5. 定期同期をcronに載せる
# 5分ごとに差分同期。flockで多重起動を防ぐ
*/5 * * * * /usr/bin/flock -n /tmp/notion-sync.lock \
/usr/bin/env node /Users/me/dev/notion-sync/dist/incremental.js \
>> /tmp/notion-sync.log 2>&1
ハマりポイント
実際に踏んだ地雷を列挙します。
-
ページネーションの打ち切り:
has_moreとnext_cursorを両方見ないと、100件で静かに途切れます。do...whileを最後まで回すこと。 -
429の握り潰し:Retry-Afterを読まずに固定sleepで再試行すると、ワークスペース全体でレート制限を食い合って詰みます。 -
画像・ファイルのURLが期限切れ:
fileブロックのURLは署名付きで失効します。ミラーに残すなら同期時にダウンロードしてパスを書き換える必要があります。 - 削除の検知漏れ: 差分同期だけだとアーカイブ済みページのMarkdownが孤児として残ります。マニフェストに無いファイルを消す「tombstone処理」を入れてください。
-
同時実行: cronが前回実行と重なるとマニフェストが壊れます。
flockかロックファイル必須です。 -
コードブロックの入れ子: Notionの
codeブロックをそのままで囲むと、フェンスが閉じません。本文にが含まれる場合はバッククォートを増やしてエスケープします。
FAQ
Q. Notion公式のMCPサーバーを使えばいいのでは?
A. 書き込みや「常に最新」が必要なら公式MCPが正解です。ただしリモートHTTP経由なので、読み取り中心の大量検索には向きません。読み取りだけローカルミラーに逃がし、書き込みだけAPIに投げる構成が実用的です。
Q. 同期間隔はどれくらいが妥当?
A. 個人利用なら5〜15分で十分です。共同編集が激しいワークスペースで、かつ「数秒の遅延が許容できない」ならリアルタイムAPIに寄せてください。
Q. 日本語検索の精度は?
A. rg はトークナイザを持たないため部分一致です。「表記ゆれを吸収したい」「関連度順に並べたい」なら、ミラーをそのままElasticsearchや SQLite FTS5 に投入する二段構えが有効です。
Q. 書き込みもローカルからやりたい
A. ローカルでMarkdownを編集し、last_edited_time が変わったファイルだけAPIで blocks.children.append する逆同期を書けます。ただし競合解決が一気に難しくなるので、まずは読み取り専用で運用を固めるのを推奨します。
使い分けの指針
- 読み取り専用・横断検索・オフライン利用 → ローカル同期
- 書き込み・即時性・権限継承 → リアルタイムAPI
- 両方欲しい → ローカルミラーを読み取りの主軸にし、書き込みだけMCP経由でAPIを叩く
「どちらか一方」で悩むより、読み取りと書き込みでレイヤーを分けた方が、レイテンシもレートリミットも現実的に収まります。まずは1,200ページ程度のミラーを一度作って、rg の速さを体感してから判断するのがおすすめです。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中