0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「同期ツール」と「リアルタイムAPI」どっちを選ぶ? 読み取り専用ワークロードでNotion MCPを検討した結論

0
Posted at

環境情報

検証に使った環境は以下の通りです。結論はマシンスペックに依存しませんが、レイテンシの実測値はこの環境のものです。

  • 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リクエスト/秒でスロットリングされ、超えると 429Retry-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_morenext_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) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?