pg-readonly-mcp について
pg-readonly-mcpは、読み取り専用のデータベースアクセスを提供するPostgres MCPサーバーです。SQL構文木と接続権限を検査し、単一の読み取りクエリだけを許可して、行数と実行時間を制限します。
主な機能
- 01SQL構文木を解析
- 02複数文や読み取り以外を拒否
- 03データベースロールの権限を確認
- 04返却行数を制限
- 05ステートメントタイムアウトを設定
活用シーン
- 01書き込み権限なしでAIエージェントからPostgresを照会
- 02複数文やCTE内に隠れた書き込みを阻止
- 03クエリの行数と実行時間を制限
MCP 対応
MCPクライアントに単一の`query`ツールを提供し、SQLをデータベースで実行する前に解析・検証します。接続ロールにも読み取り専用権限の検査を行います。
設定
ローカルサーバーの設定はどのクライアントでも共通です。コピーしてクライアントの MCP 設定ファイルに貼り付けてください。
ツール
ローカルサーバーのツールはマシン上で起動して初めて取得できます。メンテナーはまだツール一覧を提供していません。
詳細
pg-readonly-mcp
簡単に言うと: AI がデータベースを参照できるようにしますが、それ以外のことは何もできません。 書き込みをしないよう指示されているからではなく、使用するデータベースログインに書き込み能力が物理的に備わっていないためです。このツールの公式版には公開された脆弱性があり、「すべて削除」コマンドが安全チェックをすり抜ける可能性がありました。このツールは、その攻撃を対象にテスト済みです。
SQL をパターンマッチングするのではなく解析する、読み取り専用の Postgres MCP サーバーです。パターンマッチングを使う方法は、すでに一度、公に失敗しており、その失敗について具体的に説明する価値があるためです。
このツールが防ぐバイパス
Anthropic のリファレンス Postgres MCP サーバーは、各クエリを読み取り専用のトランザクションでラップすることで、「読み取り専用」を強制していました。また、セミコロンで区切った複数ステートメントの入力も受け付けていました。この組み合わせは悪用可能です。
SELECT 1; COMMIT; DROP SCHEMA public CASCADE;
COMMIT によって読み取り専用トランザクションが早期に終了します。その後のすべての処理は、セッションの完全な権限で実行されます。Datadog Security Labs はこれを2026年に公表しました。このサーバーは非推奨となり、アーカイブされましたが、脆弱なパッケージはその後も週21,000件のダウンロードを記録していました。
読み取り専用トランザクションは、クエリの実行方法に関する性質です。クエリの内容については何も保証しません。だからこそ、言い逃れが可能でした。このサーバーは、代わりに後者をチェックします。
独立した2つのレイヤー
どちらか片方だけでも、公開されたバイパスを阻止できました。両方を備えているのは、一方の不具合だけでエージェントが書き込みを行えてしまうことのないようにするためです。
1. SQL はスキャンではなく解析されます。 guard.py は sqlglot を使って実際の構文木を構築し、次の3つのチェックを行います。
- ステートメントは厳密に1つです。
sqlglot.parseは、ドライバーと同じようにステートメントを終端するセミコロンで分割します。そのため、上記のペイロードは3つのステートメントとなり、いずれも接続に到達する前に拒否されます。 - 外側のステートメントが読み取り形式であること —
SELECT、UNION、INTERSECT、EXCEPT、またはこれらから構成される CTE です。DROP TABLE usersはここで拒否されます。 - 構文木のどこにも書き込みがないことを、全体を走査して確認します。このチェックは、ほかの2つでは代替できません。Postgres では、CTE にデータ変更ステートメントを含められるためです。したがって、
WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM xは、外側から見るとSELECTです。外側の形式だけを見るチェックでは、これを完全に見落とします。すべてのノードを走査すれば、何階層下にあってもDELETEを検出できます。
認識されないステートメント形式、つまり sqlglot に具体的なルールがないものは、ほかと同じルールで拒否されます。見慣れないものは安全とは限りません。
2. 接続自体が、実行を求められる内容に関係なく書き込みできません。 check_connection_is_readonly は、接続したロールがスーパーユーザーである場合、データベースまたはロールを作成できる場合、行レベルセキュリティを回避できる場合、または参照可能なテーブルに対して SELECT 以外の権限を付与されている場合、起動を拒否します。これですべてを網羅するわけではありません。Postgres の権限は、所有権、PUBLIC への権限付与、またはこのチェックでは列挙しない RLS ポリシーを通じて与えられる場合もあります。それでも、設定ミスで最もよく見られる2つの形を検出します。暗黙に示すのではなく、ここに明記しています。
保護対象外
- それでも渡されてしまう特権付き接続文字列。 起動時チェックは、過剰な権限が付与されている一般的なパターンを検出しますが、権限を網羅的に監査するものではありません。その旨は上記で明記しており、そうであるかのような誤解を招かないようにしています。
- 制限内でのリソース枯渇。 幅の広いデータを 1,000 行返すクエリや、正当にコストのかかる実行計画を作成するクエリは、その分のコストがかかります。行数の上限とステートメントのタイムアウトによって影響を制限できますが、コストのかかる読み取りが無料になるわけではありません。
- データを取得した後、エージェントがそのデータをどう扱うか。 これはクエリゲートであり、データ損失防止ツールではありません。テーブルへの読み取りアクセス権があれば、その中にあるものすべてを読み取れます。
- パーサー間の解釈の相違。
sqlglotと Postgres 独自のパーサーは、同じ文法を別々に実装したものです。Postgres が受け付けるすべての特殊ケースで両者の解釈が一致するとは証明されていません。これは、「単一のレイヤーを信頼しない」というプロジェクトの前提に対する、実際に存在するものの範囲の狭いギャップです。レイヤー 2 がある理由の一つはここにあります。パーサーの差異によって意図しないものがガードをすり抜けたとしても、その下の接続には書き込み権限がありません。
スキャナーの結果
2026 年 8 月 18 日に agent-audit 0.19.2 で実行した結果:検出事項 15 件、自動抑制 11 件、対応が必要なもの 4 件 — BLOCK 1 件、WARN 3 件。
BLOCK の検出事項が最も興味深いもので、全文を読む価値があります。 server.py:156、信頼度 1.0:cur.execute(sql) — パラメーター化されていない実行による SQL インジェクションとして検出されました。この行は実在します。また、このコードベースで最も多重に防御されている行でもあります。sql がこの行に到達するまでに、validate_readonly() による解析が済み、ステートメントが厳密に 1 つであること、そのステートメントが読み取り形式であることが確認され、さらにその中のすべてのノードについて書き込みがないか検査されています。スキャナーにはそのどれも見えません。これは単一ファイルのパターンマッチングであり、検証は別のモジュールの別の関数で、数行前に行われます。スキャナーは一般に危険な 形 を正しく特定していますが、その形がすでに検査済みであることは把握できません。
また、スキャナーの提案どおりにしても、問題は解決できませんでした。 パラメーター化クエリが保護するのは、固定されたクエリの形式に代入する 値 です — WHERE id = %s。このツールが受け付けるのはクエリの 構造 なので、ここには当てはまりません。「呼び出し元が要求した読み取り専用 SQL を実行する」ための、値だけをパラメーター化する方法はありません。この種のツールの解決策は構造を検証することであり、このファイルの残りの部分がそれを行っています。
query() の定義に対する WARN(AGENT-034、「関数本体で入力検証が行われていない」)も、別の角度から見た同じ盲点です。この関数の最初の行では、try/except の中で validate_readonly(sql) を呼び出しています。インポートした関数の呼び出しは、スキャナーが検証として認識するパターンではありません。
ハードコードされた認証情報に対する 2 件の WARN は、tests/conftest.py にあります。デフォルトの管理者 DSN(標準的なローカル/CI 用 Postgres のデフォルト値である postgres:postgres@localhost:5432/postgres)と、テストスイートが作成し、同じテスト内で削除するロールに使用されるリテラルのパスワードです。どちらも認証情報のような文字列として正しく識別されていますが、何かを保護する認証情報ではありません。一方は使い捨てのローカルデータベースを指し、もう一方は 1 件のテストの実行中だけ存在します。
残りの 11 件の検出事項は、すべて AGENT-041 で、uuid.uuid4() に由来する名前を使って CREATE SCHEMA/GRANT/DROP ROLE ステートメントを組み立てるテスト用フィクスチャ内にありました。これらはすでにスキャナーによって自動抑制されていました。
パターンスキャナーは煙探知機であり、裁判官ではありません。検出結果とその理由を公開することは、数字だけをきれいに見せるよりも価値があります。
インストール
pip install pg-readonly-mcp
ソースをチェックアウトした場合:pip install -e .
設定
--dsn または PG_READONLY_MCP_DSN を通じて接続文字列を指定する必要があります。環境変数を用意しているのは、誤ってコミットされる可能性のあるコマンドラインやクライアント設定ファイルにパスワードを記載せずに済むようにするためです。
{
"mcpServers": {
"pg-readonly-mcp": {
"command": "pg-readonly-mcp",
"env": { "PG_READONLY_MCP_DSN": "postgresql://readonly_role:...@host:5432/db" }
}
}
}
接続文字列のロールに許可されるのは SELECT のみです。
サーバーはこれを自ら確認し、条件を満たさない場合は起動を拒否します。上記の check_connection_is_readonly を参照してください。
ツール
意図的にツールは1つだけです。存在意義が「読み取り以外はすべて拒否する」サーバーに、それを正しく実現するための別の窓口は必要ありません。
| ツール | 機能 |
|---|---|
query | SELECT 形式のステートメントを1つ実行します。接続に触れる前に解析して走査します。--max-rows(デフォルトは1000)と --timeout-ms(デフォルトは5000)で上限を設定します。 |
テスト
テストは37件です。そのうち25件はデータベースが不要で、どこでも実行できます。これは guard.py のテストスイート全体で、文字列を入力し、判定を出力するだけです。 残る12件は稼働中の Postgres が必要なため、設計上の統合テストです。check_connection_is_readonly の目的は、実際のロール属性と実際の権限に対して機能することです。モック接続では、サーバーが実環境でどう動作するかにかかわらずテストが通ってしまいます。
pytest -q --cov=pg_readonly_mcp
CI は実際の postgres:16 サービスコンテナに対してテストを実行し、データベースを使うテストがそこでスキップされた場合はビルドを失敗させます。 同じ理由から、これは sweep-mcp がシンボリックリンクのテストに適用しているルールと同じです。何もせずにひっそり終わるテストは、テストがないよりも悪いからです。
12件のテストには、実際の MCP ツール呼び出しを通じて実行する、Datadog のペイロードそのものを使った稼働中の再現テストがあります。guard.py を直接呼び出すのではありません。また、例外が発生したことだけでなく、その後も対象スキーマが残っていることを検証します。ほかにも、同じ呼び出し経路で CTE 内に隠された書き込み、行数上限、ステートメントのタイムアウト、キャンセルされたクエリの後も次のクエリで接続を使えること、CREATEDB がありテーブル権限が一切ないロールがロール属性だけを理由に拒否されることをテストします。
実際の Postgres に対する CI のカバレッジは 78%で、guard.py は100%です。 server.py の未カバー部分は、汎用の psycopg.Error の捕捉処理です。テストスイートではキャンセル以外のデータベースエラーを意図的に発生させていません。また、main() の argparse とトランスポートの配線も未カバーです。テストスイートでは代わりに build_server を直接通じて検証しています。トランスポートはモックする価値が最も低く、実際の不具合が潜んでいる可能性も最も低い部分です。
この変更の最初のプッシュはテストに通りませんでした。 実際の Postgres サービスコンテナで初めてテストスイートを実行したときにのみ、2つのバグが見つかりました。どちらもコードだけではわからないものでした。SET statement_timeout = %s が Postgres に SET statement_timeout = $1 として渡され、解析に失敗していました。SET はユーティリティステートメントであり、SELECT のようにバインドパラメーターを受け付けないためです。つまり、ツールへの実際の呼び出しはすべて同じように失敗していたことになります。もう一方では、テスト用フィクスチャが、まだ有効な権限を持つロールに対して DROP ROLE を実行しようとしていましたが、Postgres はこれを拒否します。先に DROP OWNED BY を実行する必要があります。どちらも修正済みで、以降の実行結果がここに記載した数値です。テストスイートが成功しか報告しないなら、誰も失敗を目にしたことがないテストスイートになってしまうため、この説明を残しています。
構成
src/pg_readonly_mcp/
guard.py parses and walks the tree. Opens no connection. 135 lines.
server.py the MCP tool, the connection check, the timeout and row cap.
tests/
test_guard.py 25 tests - no database, run anywhere
test_server.py 12 tests - live Postgres required, CI-enforced
guard.py は MCP も psycopg も認識しません。server.py にある理由でクエリが拒否されることがあれば、それはバグです。判定は1つ下のレイヤーで行うべきです。そこなら文字列以外は何も使わずにテストできます。
ライセンス
MIT。
作者
Leslie Kadenge が作成しました。私は MCP サーバーの独立したセキュリティレビューを行っています。本番環境で使われている13のサーバーを対象とした公開調査は、les-k.github.io/field-notes.html でご覧いただけます。
コメント
AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。