MCP TypeScript SDK と Supabase Edge Functions を組み合わせることで、AI クライアントとデータベースの間に位置する、ホストされたグローバル分散サーバーが実現します。AI クライアントが生のデータベース アクセスを取得することはありません。Supabase デプロイ ガイドに従い、このスタックに推奨されるトランスポートは WebStandardStreamableHTTPServerTransport です。以下で説明するのはこの実装のみです。以下で得られるもの:合成読み取り専用商品カタログ、ページネーション対応クエリ ツール、RLS による 2 ユーザー分離、Inspector トランスクリプト ウォークスルー、およびデプロイとバージョニングに関する注記。
サーバーとデータフロー
MCP は AI クライアント(Claude Code、Claude Desktop、Cursor)とバックエンド間に位置します。クライアントは HTTP 経由で JSON-RPC でサーバーと通信します。サーバーはツールを公開します。ツールはクライアントが呼び出せる型付き関数です。サーバーがこれらのツールが何にアクセスできるかを決定します。
Supabase の場合、この境界は重要です。UI Bakery の説明によれば、AI クライアントがデータベースへの無制限の直接アクセスを取得してはいけません。ツールを設計し、クエリを定義し、Supabase Row Level Security(RLS)が呼び出し元の ID に基づいて行単位のアクセスを処理します。
データフローは次のようになります:
- Claude Code は JSON-RPC
tools/callリクエストを Edge Function URL に送信します。 - Edge Function はそれを受信し、
Authorizationヘッダーから JWT を抽出し、その ID にスコープされた Supabase クライアントを作成します。 - ツールがクエリを実行します。テーブルの RLS ポリシーが呼び出し元が表示できる行をフィルタリングします。
- 結果は JSON-RPC レスポンスとしてシリアライズされます。
このチェーンには service-role キーがないことに注意してください。service-role クライアントは RLS を完全にバイパスするため、呼び出し元向けのツールで使用すると、RLS ポリシーは装飾になります。service-role キーはバックグラウンド管理タスク用にのみ保持してください。
合成サンプル データ
ここの例では、3 つの列を持つ合成 products テーブルを使用します:id(uuid)、owner_id(uuid、auth.users への外部キー)、name(text)。2 人の合成ユーザー alice と bob は、それぞれ互いに無関係な行セットを所有します。このセットアップは明示的に説明目的であり、実際のクライアント プロジェクトに由来していません。
create table public.products (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id),
name text not null
);
各ユーザーの UUID を使用して、たとえば各ユーザーについて 10 行でシードします。
ページネーションで小規模なツール サーフェスを定義する
狭いツールの方が広いツールより優れています。テーブル全体を返すツールは負債です。ページネーションはペイロードを予測可能に保ち、Edge Function レスポンス制限への到達を回避します。
このサーバーのツール サーフェスは意図的に小規模です:
- list_products:呼び出し元が所有する商品のページを返し、page と
page_sizeパラメータを受け入れ、デフォルトはページ 1 で 1 ページあたり 20 行です。 - get_product:id で単一の商品を返し、呼び出し元が所有していない場合はグレースフル エラー となります。
以上です。2 つのツール。クエリ エンジンを構築しているのではありません。Supabase 上で大規模なディレクトリ形式の読み取り層を構造化する方法を理解したい場合は、25,000 ページ ディレクトリ ポストが最初に読む価値があります。
Zodでスキーマを定義する
MCP TypeScript SDKはZodを入力検証に使用します。ツール定義は大体このような形になります(イラスト用):
import { z } from 'npm:zod'
``
const ListProductsInput = z.object({
page: z.number().int().min(1).default(1),
page_size: z.number().int().min(1).max(100).default(20),
})
``
const GetProductInput = z.object({
id: z.string().uuid(),
})
Zodはハンドラが実行される前に検証を行います。クライアントがゴミデータを送信した場合、ランタイム例外ではなく型付きエラーが返されます。最初から構築しておくべき良い動作です。
TypeScriptサーバーを実装して実行する
Supabaseデプロイガイドは@modelcontextprotocol/sdk@1.25.3とWebStandardStreamableHTTPServerTransportを使用しています。これは執筆時点でのピン留めバージョンです。デプロイ前に最新のドキュメントを確認してください。
スキャフォールディング
mkdir my-mcp-server && cd my-mcp-server
supabase init
supabase functions new mcp
関数はsupabase/functions/mcp/index.tsに配置されます。デフォルトの内容を以下のような形に置き換えてください(イラスト用構造):
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { McpServer } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/mcp.js'
import { WebStandardStreamableHTTPServerTransport } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/streamableHttp.js'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { z } from 'npm:zod'
``
Deno.serve(async (req) => {
const authHeader = req.headers.get('Authorization') ?? ''
const jwt = authHeader.replace('Bearer ', '')
``
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: Bearer ${jwt} } } }
)
``
const server = new McpServer({ name: 'products-mcp', version: '1.0.0' })
``
server.tool('list_products', 'List products owned by the caller',
{ page: z.number().int().min(1).default(1), page_size: z.number().int().min(1).max(100).default(20) },
async ({ page, page_size }) => {
const from = (page - 1) * page_size
const { data, error } = await supabase
.from('products')
.select('id, name')
.range(from, from + page_size - 1)
if (error) return { content: [{ type: 'text', text: Error: ${error.message} }] }
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
``
server.tool('get_product', 'Get a single product by id',
{ id: z.string().uuid() },
async ({ id }) => {
const { data, error } = await supabase
.from('products')
.select('id, name')
.eq('id', id)
.maybeSingle()
if (error) return { content: [{ type: 'text', text: Error: ${error.message} }] }
if (!data) return { content: [{ type: 'text', text: 'Not found or access denied' }] }
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
``
const transport = new WebStandardStreamableHTTPServerTransport({ path: '/mcp' })
await server.connect(transport)
return transport.handleRequest(req)
})
ここで注目すべき点が2つあります。1つめは、リクエストごとに新しいMcpServerとトランスポートインスタンスを作成することがEdge Function実行時が想定するステートレスパターンということです。2つめは、SupabaseクライアントがサービスロールキーではなくSUPABASE_ANON_KEYで作成され、JWTがリクエストヘッダーで転送されるため、RLSが呼び出し元の識別情報を認識するということです。
ローカルで実行する
supabase start
supabase functions serve --no-verify-jwt mcp
サーバーはhttp://localhost:54321/functions/v1/mcp で利用可能です。ローカルテストであれば--no-verify-jwtフラグで問題ありません。本番環境ではJWT検証を有効にする必要があります。
呼び出し元を認証してロー アクセスを制御する
ほとんどのチュートリアルがここで失敗します。シンプルだからという理由でサービスロールキーを選びがちです。しかしそれではRLSが迂回され、ツール表面がどんなに限定的でも、管理者レベルの読み取りアクセスがすべての行に対して発生します。これは呼び出し元向けサーバーとして受け入れられません。

上記のパターン(呼び出し元のJWTを渡す、anonキーを使用)では、Supabase Authがトークンを評価し、すべてのクエリ内でPostgresのauth.uid()コンテキストを設定します。RLSポリシーはそれを使用できます:
alter table public.products enable row level security;
``
create policy "owners can read own products"
on public.products
for select
using (owner_id = auth.uid());
alice のJWTはowner_idが彼女のUIDと一致する行のみを返すことができます。bobがAliceのいずれかの商品IDでget_productを呼び出すと、「見つかりません」または「アクセスが拒否されました」が返され、エラーもAliceのデータも返されません。これが正しい動作です。
RLSポリシーの書き方について詳しくは、Supabase RLSガイドが一般的なパターンと落とし穴を詳細に説明しています。
アクセス分離チェック(イラスト用)
2ユーザーチェックはスクリプト化するのが簡単です。Alice用のJWTを取得し(supabase.auth.signInWithPassword経由)、list_productsを呼び出し、返されたowner_idの値がすべてAliceのUIDと一致することを確認します。Bobで繰り返します。クロスコンタミネーションがないことを確認します。ポリシーが正しければこれは構築時に合格します。サービスロール間違いを犯した場合は、両ユーザーがすべての行を見られるため、ここで表面化します。
Supabaseプロジェクトがこれと並行して複雑なNext.jsデータフェッチのニーズをすでに抱えている場合は、/solutions/nextjs-supabase-development/のチームがサービスロールショートカットをせずにデータベース層を構造化するのに役立てます。
Claude Codeを接続してエラーケースを検証する
ローカルで関数が実行されたら、Claude Code に追加します:
claude mcp add products-mcp -t http http://localhost:54321/functions/v1/mcp
Claude Code セッションから、ツールを直接呼び出すことができます。利用可能なツールリストに list_products と get_product が表示されるはずです。表示されない場合は、claude mcp list を実行してサーバーが正しく登録されたことを確認してください。
Inspector トランスクリプト ウォークスルー
MCP Inspector (npx @modelcontextprotocol/inspector) は、フルクライアントなしでツールを実行できるブラウザ UI を提供します。mcp-lite ガイドで説明したように、エンドポイント URL を Inspector に貼り付けると、ツールが自動的に検出されます。
list_products の実例的な Inspector セッション:
Request:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "list_products",
"arguments": { "page": 1, "page_size": 5 }
},
"id": 1
}
``
Response (Alice's JWT):
{
"result": {
"content": [{ "type": "text", "text": "[{\"id\":\"...\",\"name\":\"Widget A\"}...]" }]
}
}
ここでエラーケースを意図的にテストします。Bob に属する有効な UUID を使用して get_product を呼び出し、Alice の JWT を使用します。「Not found」または「access denied」が表示されるはずです。page_size: 200 (100 の上限を超える) で list_products を呼び出します。Zod がクエリ実行前にこれを拒否するはずです。どちらも想定される動作であり、デプロイ前に確認する価値があります。
JWT がない場合はどうなりますか?
Authorization ヘッダーがない場合、createClient 呼び出しは空の JWT を渡します。Supabase はこれを anon ロールとして扱います。RLS ポリシーが anon ロールに明示的にアクセスを付与していない場合、クエリは 0 行を返します。これは正しい動作です。ツールは空の配列を返し、エラーは返しません。「unauthenticated」というメッセージを明示的に返すことを好むかもしれません。いずれにせよ、本番環境で発見するのではなく、意図的に決定してください。
サーバーのデプロイ、監視、バージョン管理
デプロイは 1 つのコマンドです:
supabase functions deploy mcp
関数は https://<project-ref>.supabase.co/functions/v1/mcp に安定した URL を取得します。Supabase ダッシュボード (Settings > Edge Functions > Secrets) で環境変数 SUPABASE_URL と SUPABASE_ANON_KEY を設定します。関数ランタイムは呼び出し時にこれらを注入します。
可観測性
Supabase はダッシュボードに組み込みの Edge Function ログを提供します。関数名でフィルタリングしてください。コールドスタート時間、実行期間、キャッチされていないエラーが表示されます。より構造化された監視の場合は、ツールハンドラーの内部から console.log JSON を記述してください。ログストリームがそれを取得します。
本番環境での使用については、以下を検討してください:
- リクエストごとにツール名と呼び出し元 UID (完全な JWT ではなく) をログに記録すること。
- Supabase クエリにタイムアウトを設定して、低速なクエリが Edge Function 実行予算を消費しないようにすること。
- AI クライアントが障害を一貫して処理できるように、決定的なエラーシェイプを返すこと。
バージョン管理
Edge Functions には組み込みのバージョン管理がありません。実用的なアプローチはパスベースです: v2 関数を別の Edge Function (supabase functions new mcp-v2) としてデプロイし、独立してテストしてから、Claude Code 登録を更新します。古いクライアントは切り替わるまで v1 URL を指し続けることができます。
複数のサーバーを持つ本番 MCP スタックを構築している場合、本番 MCP スタック投稿では、このスコープ外のサーバー選択とアーキテクチャの決定について説明しています。
デプロイメント チェックリスト
- デプロイ前に、ローカル serve コマンドから
--no-verify-jwtを削除します。 SUPABASE_ANON_KEYがダッシュボード シークレットに設定されていることを確認します (service-role ではなく)。- エンドポイントを発表する前に、本番 URL に対して 2 ユーザーアクセス分離チェックを実行してください。
- すべてのツール内で
max_rows上限を設定し、無制限のクエリを防ぎます。 - インポートに固定されている SDK バージョン(執筆時点での Supabase ドキュメントの
@modelcontextprotocol/sdk@1.25.3)を現在のリリースと照合し、必要に応じて更新してください。
FAQ
公式 SDK の代わりに mcp-lite を使用できますか?
はい。Supabase ドキュメントでは、公式 SDK の代わりに mcp-lite または mcp-handler を使用できると明記されています。上記で使用した WebStandardStreamableHTTPServerTransport は公式 SDK から取得されていますが、mcp-lite はより軽量で依存関係がゼロです。どちらかを選んで一貫性を保ってください。mcp-lite ガイドではスキャフォルドコマンドが示されています:npm create mcp-lite@latest。上記で説明したアーキテクチャと RLS の動作は、選択するフレームワークに関係なく適用されます。
Edge Functions をデプロイするには有料の Supabase プランが必要ですか?
Edge Functions は無料プランで利用可能ですが、呼び出し数と実行時間に制限があります。現在の Supabase 価格ページで詳細を確認してください。低トラフィックの内部ツールの場合、無料プランで問題ないことがほとんどです。実運用で実際のクエリボリュームがある場合は、Pro プランの制限がより適切です。
MCP サーバーがデータを読み取るのではなく、書き込む必要がある場合はどうしますか?
insert、update、または delete クエリを実行するツールを追加します。RLS パターンは引き続き適用されます。ポリシーは auth.uid() に基づいて呼び出し元が変更できる行を制御します。追加の主な検討事項は冪等性です。AI クライアントが失敗したツール呼び出しを再試行する場合、insert を 2 回実行したいですか?安定した主キーを持つ upsert を使用するか、挿入前に存在確認するのが標準的な対策です。
Supabase アカウントがなくても、これをローカルで実行できますか?
はい、supabase start は Docker 経由で Supabase スタック全体(Postgres、Auth、Edge Functions を含む)をローカルで実行します。CLI 出力からローカル SUPABASE_URL と SUPABASE_ANON_KEY が得られます。関数と RLS ポリシーは本番環境で使用されるのと全く同じように動作します。唯一の違いは JWT 発行者で、Supabase のクラウド認証ではなくローカル GoTrue インスタンスです。
ツールのレート制限をどのように処理しますか?
Edge Functions には呼び出し元ごとの組み込みレート制限がありません。実用的な選択肢は、UID ごと、時間枠ごとに呼び出し数を記録する Postgres テーブル(ツール内でチェックとインクリメント)、または上流 API ゲートウェイ(Supabase 独自の API ゲートウェイはプロジェクトレベルである程度処理)です。ほとんどの内部ツールでは、意図的な悪用ではなく、無料プランプロジェクトの Edge Functions 呼び出し制限に最初に達するのが最初の制約です。
上記のすべてから最も重要な注意点:呼び出し元対応ツールでサービスロールキーを使用した場合、RLS ポリシーは機能しません。anon キーと JWT 転送はあると便利ではなく、呼び出し元の分離を機能させるメカニズムです。
