サイトを 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 つ。
- サイトが持つ VMOST → Roadmap → Topics/Log の「一本の線」を、そのまま MCP の導線に写す。まず全体像(VMOST)を掴み、次の一手(Roadmap)を見て、詳細(Topics/Log)を読む——という読み方を、ツールの並びで誘導する。
- コンテンツの唯一の出所(
content/*)を二重管理しない。MCP 用に別のデータストアを持つと、サイトと MCP で内容がずれる。だから MCP はサイト本体(apps/web)に相乗りさせ、同一プロセスから同じcontent/*を配信する。
設計 — 3 層に分ける
MCP の実装を、役割で 3 つのファイルに分けた。
- 配線(
app/api/[transport]/route.ts) …mcp-handlerに初期化関数を渡すだけの薄いルート。 - プロトコル写像(
lib/mcp/register.ts) … ツール・リソース・プロンプトのスキーマ(zod)を宣言し、MCP プロトコルへ写す。 - データクエリ層(
lib/mcp/content.ts) … MCP SDK に一切依存しない純粋関数群。返す中身は必ずcontent/*から引く。
この分離が効くのは、データを取り出すロジック(content.ts)を MCP から切り離せるからだ。content.ts は lib/logs.ts・lib/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_vmost | VMOST 全体、または step で 1 階層に絞る |
get_roadmap | 次の一手。各 TODO が VMOST のどこに効くか+実施ログへの逆リンク+全体進捗 |
get_glossary | 主要用語の一行定義と正規 URL(語彙合わせ用) |
search_topics / search_logs | 一覧を検索(本文は返さず URL と read_article へ誘導) |
read_article | Topic / 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_article が SAFE_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_vmost→ Vision〜Tactics と行動原則が、各アンカー 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_SLUG1 本を境界に置き、危険な入力は「見つからない」に丸めた。 - 一覧と詳細で「取れるものの集合」をずらさない。 一覧に出るのに本文取得で拒否される、は AI から見て理由の分からない失敗になる。制約を課したら、その制約で一覧側も絞って整合を保つ。
force-dynamic× 毎リクエストの重い処理は、環境で分けてメモ化する。 production では不変・dev では可変、という前提の違いをそのままキャッシュ方針に落とすと、性能と開発体験を両取りできる。




