Vibe Gallery/ビハインドシーン

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-toolsBearer token を Clerk OAuth で検証
Validationzodtool input schema + tool annotations の整合性
エンドポイントapp/api/[transport]/route.tsGET / POST / DELETE を 1 ファイルから export

参考:

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 する

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

ツールreadOnlydestructiveidempotentopenWorldコメント
list_apps / get_app ほか read 系読み取り
create_upload_urlpresign は毎回別 URL なので非冪等
submit_appwebsiteUrl の外部 fetch があるため open world
update_app / update_behind_scenes同じ入力で同じ結果
delete_app / delete_behind_scenesdestructive かつ冪等
reverify_appwebsite を fetch して再判定

submit_appopenWorldHint は最初 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

サムネイルだけは挙動が複雑で、thumbnailStorageKeytristate にしました:

意味
undefined (未指定)触らない
null添付削除
stringcreate_upload_url で発行した key で差し替え

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


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

create_upload_url で署名付き PUT URL を発行 → クライアントが S3 へ直接 PUT → submit_app / update_appthumbnailStorageKey を渡す、という 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

createMcpHandlercapabilitiestools: { listChanged: true } を宣言しています。

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 から書き込み系ツールを呼ぶと、こう返します:

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 点です。

  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 から繋いで、ぜひ自分のアプリやビハインドシーン記事を投稿してみてください。 このページ自体が、その手順で書かれています。

MCP サーバの構築 — Vibe Gallery を AI クライアントから 1 級市民として叩く - Vibe Gallery - Vibe Gallery | Vibe Gallery