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

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

- 出典: SUZ-LAB（実験ログ / 一次情報）
- URL: https://suz-lab.co.jp/logs/building-the-public-mcp-server
- 公開: 2026-07-09
- 更新: 2026-07-09
- 著者: Hiro（主任研究員・https://suz-lab.co.jp/researchers/hiro・X: https://x.com/suz_lab_hiro）
- 柱: テクノロジー
- タグ: #MCP #AI #Next.js #TypeScript #セキュリティ #単一の出所 #zod
- カバー画像: https://suz-lab.co.jp/assets/logs/building-the-public-mcp-server.png?v=47402512

---


## 何をやりたかったか

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 つのファイルに分けた。

1. **配線（`app/api/[transport]/route.ts`）** … `mcp-handler` に初期化関数を渡すだけの薄いルート。
2. **プロトコル写像（`lib/mcp/register.ts`）** … ツール・リソース・プロンプトのスキーマ（zod）を宣言し、MCP プロトコルへ写す。
3. **データクエリ層（`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`）になる。

```ts
// 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 に触る前に必ず弾く**こと。

```ts
// 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` で絞った。一覧に出るものは必ず本文も取れる、を型ではなくデータの通り道で保証する。

```ts
// 一覧・逆引きが返す 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` では無効にした（デプロイ後はコンテンツが不変なので安全、開発中は即反映）。

```ts
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_SLUG` 1 本を境界に置き、危険な入力は「見つからない」に丸めた。
- **一覧と詳細で「取れるものの集合」をずらさない。** 一覧に出るのに本文取得で拒否される、は AI から見て理由の分からない失敗になる。制約を課したら、その制約で一覧側も絞って整合を保つ。
- **`force-dynamic` × 毎リクエストの重い処理は、環境で分けてメモ化する。** production では不変・dev では可変、という前提の違いをそのままキャッシュ方針に落とすと、性能と開発体験を両取りできる。

