SUZ-LAB

サイトを AI に開く — 公開 MCP サーバ(読み取り専用)を建てた

SUZ-LAB の Mission は「一次情報として発信する」こと。その発信先を人間だけでなく AI エージェントにも広げるため、VMOST → Roadmap → Topics/Log を横断参照できる公開 MCP サーバ(Streamable HTTP・読み取り専用)をサイト本体に相乗りさせた記録。コンテンツの唯一の出所(content/*)を同一プロセスからそのまま配信し、データを二重管理しない設計と、認証不要の公開エンドポイントゆえに詰めたパストラバーサル対策・一覧と本文取得の整合・本番メモ化まで、実際のコードで残します。

何をやりたかったか

SUZ-LAB の Mission は「やってみたことを一次情報として発信する」ことだ。これまで発信先は人間(ブラウザで読む読者)だけを想定してきたが、いま情報を集めて回るのは人間だけではない。Claude や ChatGPT のような AI エージェントが、サイトを読み、要約し、引用する。

問題は、HTML はスクレイプに向かないことだ。人間向けの体裁(レイアウト・装飾・ナビゲーション)が本文に混ざっていて、AI が「SUZ-LAB は何を目指していて、いまどこに進んでいるか」を正確に取り出すには余計なノイズが多い。そこで、AI が構造化された形で読める窓口を、サイト本体にそのまま生やすことにした——公開 MCP(Model Context Protocol)サーバだ。

狙いは 2 つ。

  • サイトが持つ VMOSTRoadmapTopics/Log の「一本の線」を、そのまま MCP の導線に写す。まず全体像(VMOST)を掴み、次の一手(Roadmap)を見て、詳細(Topics/Log)を読む——という読み方を、ツールの並びで誘導する。
  • コンテンツの唯一の出所(content/*)を二重管理しない。MCP 用に別のデータストアを持つと、サイトと MCP で内容がずれる。だから MCP はサイト本体(apps/web)に相乗りさせ、同一プロセスから同じ content/* を配信する。

設計 — 3 層に分ける

MCP の実装を、役割で 3 つのファイルに分けた。

  1. 配線(app/api/[transport]/route.tsmcp-handler に初期化関数を渡すだけの薄いルート。
  2. プロトコル写像(lib/mcp/register.ts … ツール・リソース・プロンプトのスキーマ(zod)を宣言し、MCP プロトコルへ写す。
  3. データクエリ層(lib/mcp/content.ts … MCP SDK に一切依存しない純粋関数群。返す中身は必ず content/* から引く。

この分離が効くのは、データを取り出すロジック(content.ts)を MCP から切り離せるからだ。content.tslib/logs.tslib/topics.ts と同じ「content/* を読むだけの純粋関数」で、テストも読解もしやすい。MCP プロトコルの都合(zod スキーマ、CallToolResult の形)は register.ts に閉じ込める。

エンドポイントを /api 配下に置く

mcp-handler[transport] セグメント方式を使い、basePath: "/api" にした。Streamable HTTP のエンドポイントは /api/mcp(本番: https://suz-lab.co.jp/api/mcp)になる。

// app/api/[transport]/route.ts
export const runtime = "nodejs";        // MCP SDK は Node API を使う
export const dynamic = "force-dynamic"; // POST での動的呼び出し。静的化しない

const handler = createMcpHandler(
  registerSuzLab,
  {
    instructions:
      "SUZ-LAB の公開ナレッジサーバ。…まず get_vmost で全体像を掴み、get_roadmap で" +
      "進行中の取り組み、search_topics / search_logs で詳細を探し、read_article で本文を読む。" +
      "用語は get_glossary の定義に合わせること。…",
  },
  { basePath: "/api", maxDuration: 60 },
);

export { handler as GET, handler as POST };

あえて /api 配下に置いたのは、トップレベルのコンテンツルート(/vmost/roadmap 等)を巻き込まないためだ。instructions は接続時にクライアントへ提示される「このサーバは何か・どう使うか」の説明で、ここに読み方(VMOST → Roadmap → …)と引用ルール(URL と日付を明記)を書いておく。

公開するのは「読み取り専用」の窓口だけ

register.ts で登録したツールはすべて参照系だ。

ツール役割
get_vmostVMOST 全体、または step で 1 階層に絞る
get_roadmap次の一手。各 TODO が VMOST のどこに効くか+実施ログへの逆リンク+全体進捗
get_glossary主要用語の一行定義と正規 URL(語彙合わせ用)
search_topics / search_logs一覧を検索(本文は返さず URL と read_article へ誘導)
read_articleTopic / Log の本文を取得

一覧系(search_*)は本文を返さず URL と read_article へ誘導する。これも「まず俯瞰、必要なら本文」という一本の線を、レスポンスの粒度で表現している。あわせて、対応クライアントがそのまま実行できる定型の入口として プロンプトも 2 つ配った——orient(VMOST → Roadmap を読んで全体像を要約)と advise_with_vmost(VMOST を判断枠組みに、渡した問いへ助言)。ツールが「素材」なら、プロンプトは「使い方の見本」だ。

ハマったところ・詰めたところ

認証不要で誰でも叩ける公開エンドポイントなので、素朴に作ると穴になる箇所を順に塞いだ。

1. パストラバーサル — fs アクセスの前に slug を検証する

read_article(type, slug) は最終的に content/logs/<slug>.md のようなパスを path.join で組む。外部入力の slug をそのまま渡すと、../ を含む値で content/ の外の *.md を読めてしまう。MCP は認証不要なので、ここは特に危ない。

対策は、URL セグメントとして安全な形だけを許す正規表現を 1 つ用意し、fs に触る前に必ず弾くこと。

// lib/mcp/content.ts
export const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export const isSafeSlug = (slug: string): boolean => SAFE_SLUG.test(slug);

export function readArticle(type: ArticleType, slug: string) {
  // 外部入力の slug は fs アクセス前に必ず検証。危険・不正な slug は
  // 「見つからない」として扱う(呼び出し側は notFound を返す)。
  if (!isSafeSlug(slug)) return null;
  // …ここで初めて getLog(slug) / getTopicArticle(slug)
}

2. 「一覧に出るのに、本文取得で拒否される」不整合を作らない

read_articleSAFE_SLUG 以外を弾くようにしたことで、逆の不整合が生まれうる。将来ログのファイル名が規則外(例: 大文字やアンダースコアを含む)になったとき、search_logs の一覧には出るのに read_article では -32602 で拒否される——という、AI から見て意味不明な状態だ。

これを防ぐため、一覧の出所そのものを isSafeSlug で絞った。一覧に出るものは必ず本文も取れる、を型ではなくデータの通り道で保証する。

// 一覧・逆引きが返す slug は read_article の入力制約に必ず整合させる
const allLogs = memoize(() => getAllLogs().filter((l) => isSafeSlug(l.slug)));

Roadmap の逆引き(各 TODO の実施結果ログ)も同じ理由で isSafeSlug を通している。トピックは別経路(articleSlugs())が既に同じ規則で絞るため不要だった。

3. 本番だけメモ化する — force-dynamic × 全ログ fs 走査

force-dynamic の公開エンドポイントは毎リクエストで実行される。そして一覧系ツールは、その都度全ログ・全トピックの Markdown を fs 走査して parse する。アクセスが増えれば CPU/IO とレイテンシに効く。

かといって常時キャッシュすると、dev.md を編集しても反映されず開発が不便になる。そこで production でだけプロセス内メモ化し、dev では無効にした(デプロイ後はコンテンツが不変なので安全、開発中は即反映)。

const CACHE = process.env.NODE_ENV === "production";
function memoize<T>(fn: () => T): () => T {
  let cached: T;
  let filled = false;
  return () => {
    if (CACHE && filled) return cached;
    const value = fn();
    if (CACHE) { cached = value; filled = true; }
    return value;
  };
}

あわせて mcp-handler はバージョンを厳密ピンした(プロトコル互換の破壊を、意図しないマイナー更新で踏まないため)。

4. zod をこのモジュールに閉じ込める

このリポジトリは通常 zod を使わない方針だ(apps/web が新しい zod を持ち込むと apps/video@remotion/zod-types の型が壊れるため。AGENTS.md 参照)。ところが MCP SDK は入力スキーマに zod を要求する。折衷として、zod の import は lib/mcp/ モジュール内だけに閉じ込め、他所へ漏らさないことにした。z.enum が非空タプルを要求するので、実行時配列(VMOST_STEPS など)をタプル型へ寄せるひと手間も入れている。

5. layout: "full" のトピックは本文が読めない

/topics/vmost のような解説は、ページ全体を React コンポーネント(<Framework />)で描画する layout: "full"。この記事の content は散文ではなく MDX のコンポーネント参照なので、そのまま返しても AI には読めない。read_article は、その場合に本文の代わりに note を返して構造化ツール(get_vmost)や URL へ誘導する。

検証

MCP クライアント(Claude Code の MCP 接続)から実際に叩いて確かめた。

  • get_vmostVisionTactics と行動原則が、各アンカー URL 付きで返る。step: "strategy" で 1 階層に絞れる。
  • get_roadmap → 各 TODO に serves(VMOST への紐づき)と resultLogs(実施ログへの逆リンク)、全体の完了率が付く。
  • read_article../ や大文字を含む不正 slug を渡す → isSafeSlug で弾かれ notFound。正規の slug では本文が返る。
  • 一覧(search_logs)に出た slug が、すべて read_article で本文まで取れる(不整合ゼロ)。

このリポジトリ自身の MCP クライアント設定(.mcp.json)にも本サーバを足し、ドッグフーディングとして自分で使いながら挙動を確認している。

学び

  • AI 向けの発信は「別サーバ」ではなく「同じ出所の別の口」にする。 MCP をサイト本体に相乗りさせ、content/* をそのまま配ることで、データの二重管理が消えた。人間向けページ・sitemap・RSS・JSON-LD・そして MCP が、全部同じ 1 か所を出所にしている。
  • 認証不要の公開エンドポイントは、fs に触る前に入力を弾く。 パストラバーサルは「外部入力を検証せずファイルパスに使う」典型で、MCP のように誰でも叩けると効きやすい。SAFE_SLUG 1 本を境界に置き、危険な入力は「見つからない」に丸めた。
  • 一覧と詳細で「取れるものの集合」をずらさない。 一覧に出るのに本文取得で拒否される、は AI から見て理由の分からない失敗になる。制約を課したら、その制約で一覧側も絞って整合を保つ。
  • force-dynamic × 毎リクエストの重い処理は、環境で分けてメモ化する。 production では不変・dev では可変、という前提の違いをそのままキャッシュ方針に落とすと、性能と開発体験を両取りできる。
#MCP#AI#Next.js#TypeScript#セキュリティ#単一の出所#zod

同じ柱のほかのログ

この柱の Roadmap を見る