---
title: "MCP サーバの構築 — Vibe Gallery を AI クライアントから 1 級市民として叩く"
app: "Vibe Gallery"
author: "@decobocodigital"
tags:
  - "Next.js"
  - "Platform"
tools:
  - "Claude"
website_url: "https://vibe-gallery.space"
github_url: null
---

Vibe Gallery には MCP (Model Context Protocol) サーバが組み込まれていて、Claude Desktop / Cursor / Cline / `mcp-remote` から認証付きでギャラリーを閲覧・投稿・編集・削除できます。
このビハインドシーンでは「なぜ MCP に寄せたか」「どこで詰まったか」「どう実装したか」を、コードの実物に沿って書きます。

> 親記事: [Vibe Gallery を支える技術選定と設計の信念](./) もあわせて参照してください。MCP 部分はそちらでは概要に留めています。

---

## 1. なぜ MCP を 1 級市民にしたか

Vibe Gallery 自体が「vibe coding ワークフローから生まれたものを集める場」なので、ユーザーがいる場所はブラウザではなく **AI クライアントの中** であることが多いです。
そこから直接、

- ギャラリーを検索・閲覧する
- 自分のアプリを投稿・更新する
- ビハインドシーン記事を書く

ができないと、「ブラウザに戻ってフォームを開く」一段の摩擦が体験全体を台無しにします。
だから MCP は最初から後付けではなく、**REST API と並列の 1 級市民** として作りました。実際このビハインドシーン記事自体、Claude Code から `create_behind_scenes` ツール経由で投稿されています（ドッグフーディング）。

---

## 2. Spec の選択 — `mcp_token` カラムを捨てて native OAuth へ

最初の試作では、「ユーザーごとに `profiles.mcp_token` を発行して、それを `Authorization: Bearer <token>` に詰めれば動く」という素朴なやり方をしました。

これはすぐに撤回しました。理由:

- **MCP authorization spec (2025-06-18) が OAuth 2.1 を前提にしている**。独自トークンだとクライアント側の OAuth フロー（`.well-known` の発見 → DCR → ブラウザサインイン → token 取得）が走らない
- **トークンの再発行 / 失効 / scopes 管理 を自前で持つ責任が増える**
- **Clerk が既に OAuth 2.1 + PKCE + Dynamic Client Registration を提供している**

結局、`mcp_token` カラムを廃止し、Clerk の OAuth Application を SoR にする実装へ寄せました。

教訓: **「とりあえず動く独自認証」を作ると、後で spec に追従するときに二重で書き直す**。

---

## 3. 実装スタック

| 部品 | 採用ライブラリ | 役割 |
|---|---|---|
| MCP サーバ本体 | `mcp-handler` (Vercel) | Streamable HTTP + SSE transport |
| 認可レイヤ | `@clerk/mcp-tools` | Bearer token を Clerk OAuth で検証 |
| Validation | `zod` | tool input schema + tool annotations の整合性 |
| エンドポイント | `app/api/[transport]/route.ts` | GET / POST / DELETE を 1 ファイルから export |

参考:
- <https://modelcontextprotocol.io/specification>
- <https://vercel.com/changelog/mcp-server-support-on-vercel>
- <https://clerk.com/docs/nextjs/guides/ai/mcp/build-mcp-server>

### dynamic `[transport]` セグメント

ファイルは 1 つだけです。

```ts
// app/api/[transport]/route.ts
export { handler as GET, handler as POST, handler as DELETE };
```

`/api/mcp` (Streamable HTTP) と `/api/sse` (legacy SSE) を 1 ハンドラで受け、`mcp-handler` が transport を判別します。
これにより「読み取り用と書き込み用でファイルを分ける」「transport ごとにファイルを分ける」のような分割をしないで済みます。

---

## 4. 「読み取りも全部 OAuth 必須」にした理由

最初は **書き込みだけ認証必須・読み取りは匿名 OK** にしていました。
これを `withMcpAuth({ required: true })` でサーバ全体に拡張しました。

理由は実装的なものでした:

- `required: false` だと、未認証で書き込み系ツールを呼んだとき **HTTP 401 が返らない**
- 401 が返らないと、MCP クライアントが `WWW-Authenticate: Bearer resource_metadata="…"` を見られない
- すると **OAuth フロー自体が起動しない** ので、クライアント側で「ログイン画面に行けない」状態になる

そこで「全ツール認証必須・匿名で読みたい人はブラウザで普通にサイトを開いてください」と割り切りました。
コードの該当 JSDoc:

```ts
/**
 * すべてのツールは Clerk OAuth の access token が必要。required: false
 * にして「読み取りは匿名、書き込みは認証」と分けると、書き込み系ツールを
 * 未認証で呼んだときに HTTP 401 を返せず、MCP クライアントの OAuth
 * フローが走らない (=「OAuth ログイン画面に遷移できない」状態) ため、
 * MCP authorization spec の要請通り required: true でサーバ全体を保護する。
 */
```

> 「全部認証必須」は spec の純粋な要請ではなく、**「クライアントの実装をシンプルに保つ」ための実用的な判断**。

---

## 5. `.well-known` ディスカバリ

`mcp-handler` が直接生やすわけではなく、以下を自前で route として置いています:

- `GET /.well-known/oauth-protected-resource` (RFC 9728) — 「このリソースサーバはどの auth server を信用しているか」を MCP クライアントに伝える
- `GET /.well-known/oauth-authorization-server` (RFC 8414) — Clerk のメタデータをそのままプロキシ

これがあるおかげで、MCP クライアントは事前設定なしに「`https://vibe-gallery.space/api/mcp` を叩けと言われた → 401 が返ってきた → resource metadata を辿って Clerk の OAuth サーバを発見 → DCR で動的にクライアント登録 → ブラウザサインイン → token 取得」までを自動で進められます。

---

## 6. zod 駆動の Tool Annotations

MCP のツールには `annotations` というメタデータがあり、クライアント側はこれを見て「破壊的か」「冪等か」「外部世界に影響するか」を判断し、確認 UI を出すかどうかを決めます。

参考: [MCP のツールアノテーション (azukiazusa.dev)](https://azukiazusa.dev/blog/mcp-tool-annotations/)

Vibe Gallery では `shared/mcp/tool-annotations.ts` で **全 16 ツールのアノテーションを 1 ファイルに集約**し、zod スキーマで起動時に整合性を検証しています。

```ts
export const McpToolAnnotationsSchema = z
  .object({
    title: z.string().min(1).max(100),
    readOnlyHint: z.boolean(),
    destructiveHint: z.boolean(),
    idempotentHint: z.boolean(),
    openWorldHint: z.boolean(),
  })
  .strict()
  .refine((v) => (v.readOnlyHint ? v.destructiveHint === false : true), {
    message: "readOnlyHint=true のとき destructiveHint は false でなければならない",
  })
  .refine((v) => (v.readOnlyHint ? v.idempotentHint === true : true), {
    message: "readOnlyHint=true のとき idempotentHint は true 扱いにする",
  });
```

ポイント:

- **全フィールド必須**にしている（SDK は optional だが、抜け漏れを許すと事故る）
- **refine で矛盾を弾く**（read-only なのに destructive、など）
- **モジュール load 時に `parse` する** ので、矛盾があれば起動時に即 throw する

ツールごとの分類は次の通り（一部抜粋）:

| ツール | readOnly | destructive | idempotent | openWorld | コメント |
|---|---|---|---|---|---|
| `list_apps` / `get_app` ほか read 系 | ✅ | ❌ | ✅ | ❌ | 読み取り |
| `create_upload_url` | ❌ | ❌ | ❌ | ❌ | presign は毎回別 URL なので非冪等 |
| `submit_app` | ❌ | ❌ | ❌ | ✅ | websiteUrl の外部 fetch があるため open world |
| `update_app` / `update_behind_scenes` | ❌ | ❌ | ✅ | ❌ | 同じ入力で同じ結果 |
| `delete_app` / `delete_behind_scenes` | ❌ | ✅ | ✅ | ❌ | destructive かつ冪等 |
| `reverify_app` | ❌ | ❌ | ✅ | ✅ | website を fetch して再判定 |

`submit_app` の `openWorldHint` は最初 `false` にしていましたが、サイト所有確認 (`verifyWebsiteOwnership`) で **外部 URL を fetch する** ため `true` に修正しました。

---

## 7. Web フローと MCP フローの「あえて違う」挙動

同じ「アプリ投稿」でも、Web と MCP では異なる UX を採用しています。

### `submit_app`

- **Web**: step1 (URL 入力 → draft 作成) → step2 (内容を埋めて publish)
- **MCP**: 1-shot で即 `published`

理由は単純で、AI クライアントの中ではフォームを 2 段で操作するメリットがなく、むしろ「下書きを残してしまった」状態のほうが事故になりやすいからです。
コードに JSDoc も書いてあります:

```ts
// MCP は AI クライアント向けの 1-shot 投稿なので、Web の step1/step2
// フロー (draft → publish) を経由せず、即 published にする。
```

### `create_behind_scenes`

逆にビハインドシーンは **デフォルト draft**。記事は投稿後に読み返したい / 編集してから公開したいことが多いため、MCP からも `status: "published"` を明示しないと公開されません。
最初に作った記事も draft のままです（このページが見えているなら、誰かが publish したということ）。

### `update_app` の thumbnail tristate

サムネイルだけは挙動が複雑で、`thumbnailStorageKey` を **tristate** にしました:

| 値 | 意味 |
|---|---|
| `undefined` (未指定) | 触らない |
| `null` | 添付削除 |
| `string` | `create_upload_url` で発行した key で差し替え |

repository 関数 `updateAppWithThumbnail` でこの 3 ケースを 1 トランザクションに収めています。AI クライアント側は「サムネを変えたいときだけ key を渡し、削除したいときは null、それ以外は touch しない」が直観に合っています。

---

## 8. 画像アップロードの 2 段階フロー

`create_upload_url` で署名付き PUT URL を発行 → クライアントが S3 へ直接 PUT → `submit_app` / `update_app` に `thumbnailStorageKey` を渡す、という 2 段階です。

```
Claude Desktop ──(1) create_upload_url ──▶ Vibe Gallery
              ◀── { uploadUrl, key }
              ──(2) PUT image bytes ─────▶ S3 (rustfs in dev)
              ──(3) submit_app({ thumbnailStorageKey: key }) ─▶ Vibe Gallery
```

ポイント:

- **key の名前空間は `<userId>/<kind>[/<recordId>]`**
- サーバ側で `isKeyOwnedBy(key, profile.id)` を必ず呼ぶ。**他人の発行 key を流用できないように**
- presign のヘルパは REST と MCP で **共有** (`shared/storage/presign.ts`)。フロントエンドのフォームと MCP ツールが同じロジックを通る
- 有効期限はデフォルト 5 分

これで Vibe Gallery 本体に画像バイトが流れず、Vercel の serverless body limit にも触れません。

---

## 9. capabilities: `tools.listChanged`

`createMcpHandler` の `capabilities` に `tools: { listChanged: true }` を宣言しています。

```ts
capabilities: {
  tools: { listChanged: true },
},
```

> Advertise that this server supports the `notifications/tools/list_changed` notification. クライアントはこれを見て、起動後に動的にツールが増減したことを察知して再フェッチできる。

現状ツールは静的登録ですが、将来「ユーザー権限でツールを増減させる」「実験的ツールをフラグ付きで増やす」可能性に備えて宣言だけ済ませています。

---

## 10. Profile gating — Clerk OAuth を通過した後の最後の関門

OAuth で認証は通っても、Vibe Gallery は **username 必須** なので、プロフィール未設定ユーザーが MCP から書き込み系ツールを呼ぶと、こう返します:

```ts
function profileSetupRequiredResult() {
  return {
    content: [{
      type: "text" as const,
      text: "profile_required: Vibe Gallery のプロフィール (username) が未設定です。一度ブラウザでサイトにサインインして username を設定してから再度お試しください。",
    }],
    isError: true,
  };
}
```

これは「ブラウザに戻って username を決めてください」という人間向けメッセージを、AI クライアントがそのまま再表示すれば成立する設計です。
**isError: true を返した上で、復帰手順を自然言語で返す** のは MCP ツール設計の小さなコツになっています。

---

## 11. ユーザー側の設定 UI

`/dashboard/mcp` に MCP 設定画面を置き、`/docs/mcp/install` に Claude Desktop 用 JSON のサンプルを置いています。

```jsonc
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "vibe-gallery": {
      "command": "npx",
      "args": ["mcp-remote", "https://vibe-gallery.space/api/mcp"]
    }
  }
}
```

初回接続時にブラウザで Clerk のサインイン画面が開き、ログインすると `mcp-remote` がトークンを保持します。以降は何も意識せずに `submit_app` などが叩けます。

---

## 12. やらなかったこと

- **MCP 用に独自トークンを発行**（前述。Clerk OAuth に統一）
- **読み取り匿名 + 書き込み認証の二段構え**（401 → OAuth フローを優先して全部認証必須）
- **`/mcp` のような top-level path**（一度 rename したが結局 `/api/mcp` に戻した。Next.js の `app/api/*` の慣習に揃える方が運用が素直）
- **Server Actions で書き込みを兼用**（API Route + MCP に統一。Server Actions は外部から fetch しづらい）
- **ツールアノテーションを手書き**（zod refine で抜けと矛盾を弾く）

---

## まとめ

Vibe Gallery の MCP 実装で大事にしているのは、以下の 3 点です。

1. **spec に正面から従う**（独自トークンを捨て、OAuth 2.1 + DCR + .well-known の正攻法へ）
2. **クライアントの実装をシンプルに保つ**（全ツール認証必須・1 ファイル route・transport 透過）
3. **ヒントは構造で守る**（zod スキーマで tool annotations を起動時 parse、storage key の名前空間で所有権チェック）

`https://vibe-gallery.space/api/mcp` を Claude Desktop / Cursor / Cline から繋いで、ぜひ自分のアプリやビハインドシーン記事を投稿してみてください。
このページ自体が、その手順で書かれています。
