MCP サーバの構築 — Vibe Gallery を AI クライアントから 1 級市民として叩く
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 つだけです。
// 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:
/**
* すべてのツールは 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)
Vibe Gallery では shared/mcp/tool-annotations.ts で 全 16 ツールのアノテーションを 1 ファイルに集約し、zod スキーマで起動時に整合性を検証しています。
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 も書いてあります:
// 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 } を宣言しています。
capabilities: {
tools: { listChanged: true },
},
Advertise that this server supports the
notifications/tools/list_changednotification. クライアントはこれを見て、起動後に動的にツールが増減したことを察知して再フェッチできる。
現状ツールは静的登録ですが、将来「ユーザー権限でツールを増減させる」「実験的ツールをフラグ付きで増やす」可能性に備えて宣言だけ済ませています。
10. Profile gating — Clerk OAuth を通過した後の最後の関門
OAuth で認証は通っても、Vibe Gallery は username 必須 なので、プロフィール未設定ユーザーが MCP から書き込み系ツールを呼ぶと、こう返します:
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 のサンプルを置いています。
// ~/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 点です。
- spec に正面から従う(独自トークンを捨て、OAuth 2.1 + DCR + .well-known の正攻法へ)
- クライアントの実装をシンプルに保つ(全ツール認証必須・1 ファイル route・transport 透過)
- ヒントは構造で守る(zod スキーマで tool annotations を起動時 parse、storage key の名前空間で所有権チェック)
https://vibe-gallery.space/api/mcp を Claude Desktop / Cursor / Cline から繋いで、ぜひ自分のアプリやビハインドシーン記事を投稿してみてください。
このページ自体が、その手順で書かれています。
