AIに渡す前の「データ棚卸し」Webアプリをつくってみた:MCPの接続URLづくりと、AIから見えるテーブルの一覧

対象: 前の記事でMCPの「読み取り専用」を試して、「チームで使うとき、渡す前に何を確認すればいいの?」が気になった方
所要時間: 40〜60分 / 使うもの: ブラウザ、ターミナル、Supabaseのプロジェクト(Freeプランで可)
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): AIエージェントにDBを触らせる前に:Supabase MCPの読み取り専用モードとプロジェクト限定設定
ビジネス編(やさしい解説): AIでアプリをつくる人のための「最小権限」入門:AIエージェントに渡す“鍵”の範囲をどう決めるか
サンプルコード一式(GitHub): GitHub
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)は ja/ フォルダーにあります。

前の記事では、「読み取り専用」のロールが、書き込みは止めるけれど、見える範囲は狭めないことを、SQL Editorで確かめました。

でも、それをチームの人に伝えるのは大変です。「SQLで確かめたら、こうでした」では、AIツールの導入を決める人には届きません。そこで、AIに鍵を渡す前に見る1枚の画面をつくってみました。使うのは、セキュリティ担当や、チームのリーダー、情報システムの人を想像しています。

この画面は2枚で、1つは「棚卸し」(このデータベースで、何がAIから読めてしまうか)、もう1つは「接続URLづくり」(鍵をどこまで狭くして渡すか)です。

0. 先に伝えておきたいこと(正直ベース)

  • AIエージェントは、実際には接続していません。(前の記事と同じです。認証が必要で、見送りました。)この画面が調べるのは、データベースの権限の設定です。AIが実際にどう動くかではありません。
  • 「AIから読める」の判断は、読み取り専用MCPが使うロール(supabase_read_only_user)の権限から出しています。このロールを使うことは、ドキュメントの説明です。私は、このロールで実際にSQLを実行したことはありません(前の記事のとおり、切り替えられませんでした)。
  • 「見え方」は目安です。ポリシーの中身(条件)は読んでいません。
  • 費用:画面を開くと、exposure-report 関数が1回呼ばれます(自動の更新はありません)。Freeプランの月50万回に対して、ほぼ無視できます。他にかかるお金はありません。AIツール側の利用料は、この記事の範囲外です。
  • セキュリティ:この関数が返すのは、テーブル名・列名・行数・権限です(行の中身は返しません)。それでも社外に見せてはいけない情報です。関数はVerify JWTをオフにして、「アプリのpublishableキー」を確認するだけの簡単な守りにしています。社外に出すページには、本物のログインを付けてください。

1. つくったもの①:データ棚卸し

データ棚卸しの画面

テーブルごとに、誰に、どう見えるかを並べます。

列 見えること どこから取る?
RLS RLSがオンか、ポリシーの数 pg_class・pg_policies
ブラウザ(ログインなし) anon ロールに、読める権限とポリシーがあるか has_table_privilege・pg_policies
ログインしたユーザー authenticated ロールに、同じ判定 同上
AI(読み取り専用の接続) 読み取り専用ロールに、読める権限があるか has_table_privilege
全体像

画面の一番上に、赤い警告が出ます。

読み取り専用のAI接続は、RLS(行ごとのアクセス制限)を通りません。このデータベースでは、AIが読めるテーブルが5個あり、その全行が見えます。

前の記事で確かめたとおり、このロールは rolbypassrls = true(RLSをすり抜ける)でした。そのため、このデモのデータベースでは、5つのテーブルすべてが、AIから全行読めるという結果になりました。

一番「おっ」と思ったのは、meeting_notes(面接メモが入っているテーブル)です。

  • ブラウザ(ログインなし):見えない(RLSはオンで、ポリシーが0個だから)
  • ログインしたユーザー:見えない
  • AI(読み取り専用の接続):全行読める

つまり、アプリのAPIからは誰にも見えないのに、AIの接続からは全部見えるテーブルです。面接メモは個人情報です。RLSで守っているから安心、と思っていたデータが、AIの接続からは丸見えになりうる。画面で一覧にして初めて、実感しました。

「見せたくない」と決めてみる

右端の列で、テーブルごとに「見せてよい」「見せたくない」を決められます(ブラウザの localStorage に保存するだけで、サーバーには送りません)。

見せたくないテーブルが、AIから読める、と警告される

invoices と meeting_notes を「見せたくない」にすると、「見せたくないと決めたテーブルが、2個、AIから読めます」と警告が出ます。あなたの判断と、現実のズレを、赤で見せる画面です。

画面の下には、見せたくないデータがあるときの考え方の例を載せました(開発用のプロジェクトにつなぐ、別のプロジェクトに置く、範囲・書き込み禁止・道具を絞る、人が承認する)。「別のプロジェクトに置く」は、私は試していません。

2. つくったもの②:接続URLづくり

2つ目のタブでは、MCPの接続URLを、チェックを入れてつくります。ダッシュボードの Connect → MCP で見たURLの形を、そのまま組み立てています。

範囲・読み取り専用・道具を絞った例(鍵の広さ:狭い)

プロジェクトのID(画像は、架空のIDです)を入れて、読み取り専用と3つの道具(docs / database / debugging)だけにすると、「渡す鍵の広さ:狭い」と出ます。右には、AIツールに追加するコマンド(Claude Codeの例)が出ます。コマンドは画面では実行しません。

わざと広くすると、こうなります。

広くした例(鍵の広さ:広い)

読み取り専用を外し、プロジェクトのIDを空にし、account branching functions を足すと、「広い」になり、危険・注意が並びます。

各グループの説明は、ツール名から私が読み取ったものです(前の記事で、create_project や delete_branch といった名前を見ました)。「project_ref を付けると account が無効になる」は、ドキュメントの説明で、私は確かめていません。画面にも、そう書きました。

3. つくり方

app/index.html                                    画面(HTML 1枚、ビルド不要)
supabase/functions/exposure-report/index.ts       棚卸しのAPI(読むだけ)

APIは、カタログ(データベースの設計情報)を調べるだけです。

select c.relname as name, c.relrowsecurity as rls_enabled,
       has_table_privilege('anon', c.oid, 'select') as anon_select,
       has_table_privilege('authenticated', c.oid, 'select') as authenticated_select,
       has_table_privilege('supabase_read_only_user', c.oid, 'select') as mcp_select
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r';

動かすときは、前の記事のアプリと同じです。

# exposure-report を Edge Function としてデプロイ(Verify JWT はオフ)
cd app
cp config.example.js config.js     # URL と publishable キーを入れる
python3 -m http.server 8765 --bind 127.0.0.1

4. つまずいたこと

① 「ブラウザ(ログインなし)」の判定が、まちがっていた

最初の版では、invoices が、ブラウザ(ログインなし)にも「条件つきで見える」と出ていました。ポリシーが2個あるから、と判断したのです。

不安になったので、実際にログインなしで、APIから読んでみました。

テーブル ログインなしで読めた行数
invoices 0
meeting_notes 0
memberships 0
posts 1(公開済みの記事)
tenants 0

「条件つきで見える」と出していた4つのテーブルのうち、実際に読めたのはposts の1件だけでした。画面が嘘をついていたのです。

原因は、ポリシーがどのロール向けかを見ていなかったことでした。invoices のポリシーは to authenticated(ログイン済みの人向け)だけなので、ログインなしの人には当てはまりません。直し方は、「読む(SELECT)ためのポリシーのうち、そのロールに当てはまるものの数」を数えることでした。

(select count(*)::int from pg_policies p
  where p.schemaname = 'public' and p.tablename = c.relname
    and p.cmd in ('SELECT', 'ALL') and (p.roles && array['anon', 'public']::name[])) as anon_select_policies

直したあとは、ログインなしの列が、実際の結果(posts だけ読める)と合うようになりました。ただし、ポリシーの「条件」(using の中身)までは見ていません。 そのため「条件つき」は、「条件に合えば見える」の意味で、実際に何行見えるかは分かりません。

学び:権限を表示する画面は、実際に読んでみて、突き合わせる。 「ポリシーがある」と「そのロールが読める」は、別のことでした。

② 「個人情報かも」の判定は、当てにならない

列名に name mail phone などが入っていると、「個人情報かも」のタグを付けています。tenants の name は会社名なのに、タグが付きました(上の画面)。逆に、中身が個人情報の列でも、名前が content なら、何も付きません(meeting_notes.content がそうです)。

列名での判定は、目印程度です。最終的には、人が中身を見て決めるしかなさそうです。

③ 画面を直したら、一覧の幅があふれた

判定の表示を長くしたら、一番右の列がカードからはみ出して、選択肢が切れました。ラベルを短くして(「見えない」「条件つき」「全行」)、詳しい説明は、表の下の注意書きに移しました。

5. 実際の運用にするなら

どれも、私は試していません。

  • 本物のAI接続との突き合わせ:権限の設定から出した目安が、実際のエージェントの動きと合うかを確かめる
  • ポリシーの中身の解析:using の条件まで読んで、「何行見えるか」を出す
  • 列ごとの棚卸し:テーブル単位でなく、列単位(個人情報の列だけ隠す、など)
  • 判断の保存と共有:今は自分のブラウザだけ。チームで共有して、承認の記録を残す
  • 定期実行:テーブルやポリシーが増えたら、自動で「見せたくないのに読める」を見つける
  • ログイン:棚卸しの結果は、社外に出してはいけない情報です

まとめ

  • AIに鍵を渡す前の確認を、1枚の画面にした(棚卸し + 接続URLづくり)
  • このデモでは、読み取り専用のAI接続から、5つのテーブルすべてが全行読める結果になった
  • アプリのAPIからは誰にも見えない(RLSオン・ポリシーなし)テーブルも、AIの接続からは見える
  • 「見せたくない」と決めたテーブルが読めるときは、赤い警告で、判断と現実のズレを見せられる
  • 最初の版は、ログインなしの判定がまちがっていた。実際に読んで、突き合わせて直した
  • 実際のAIエージェントとの接続は、私は未検証

コードは、上のGitHubにあります。次は、Realtimeを使った「みんなで同時に見る」ライブ注文ボードです。

Leave a Reply

Your email address will not be published. Required fields are marked *