1. はじめに
ここしばらく、MCP(Model Context Protocol)で LLM を Oracle Database につなぐ検証を重ねてきました。「いま重い SQL を 3 件教えて」を SELECT AI や各 MCP サーバ経由で聞く、といったテーマです。一方で、本番で通常利用するなら絶対に必要なのに手薄だった視点が残っていました。それが 「誰が・いつ・何の SQL を流したのか」を後から追えるか、という監査・トレーサビリティです。
実際、過去記事「いま重い SQL を 3 件教えてを Claude × Oracle 公式 4 経路で 3 ターン会話してみた」では、監査ログや承認フローを「本記事スコープ外・独立記事で扱う予定」と書いて宿題にしていました。本記事はその回収です。
LLM をDBにつなぐと、トレーサビリティは2つの面に分かれます。
- LLM 側: そのエージェントが何を考え、どのツールを呼び、トークンをどれだけ使ったか → 可観測性ツール(本記事では Langfuse)
-
DB 側: 結局DBに何の SQL が届き、誰のセッションが実行したか →
V$SESSION/DBTOOLS$MCP_LOG/Unified Audit
問題は、この2つは別々には追えても、互いを突き合わせる共通のキーを持っていないことです。本記事では、主役の MCP サーバに SQLcl MCP を選び、Langfuse の trace_id を DB セッションの CLIENT_IDENTIFIER に注入することで、LLM 側トレースと DB 側監査証跡を 1 本の線で突き合わせるところまでを実機で確かめます。
今回の検証ゴール
| # | 検証項目 |
|---|---|
| 1 | SQLcl MCP 経由のDB操作は、DB側(V$SESSION / DBTOOLS$MCP_LOG)で「MCP 経由実行」と識別できるか |
| 2 | Unified Audit に「誰が・何の SQL を」を残せるか(既定で残るか、ポリシーが要るか) |
| 3 | LLM 側(Langfuse)と DB 側(Unified Audit)を、同じ trace_id で 1 対 1 に突き合わせられるか |
結論先出し
- SQLcl MCP は DB側の監査情報を最初から持っている。
V$SESSION.MODULE/ACTIONをセットし、DBTOOLS$MCP_LOG表に全実行を記録し、生成 SQL に/* LLM in use is <model> */コメントを自動付与する - ただし 公式ドキュメントの説明と実際の挙動にズレがあった(
MODULEにクライアント名ではなく LLM 名 が入る、など) - Langfuse と Unified Audit には 共有キーがない。そこで
DBMS_SESSION.SET_IDENTIFIERでtrace_idをCLIENT_IDENTIFIERに注入し、突き合わせ用の共有キーにした - 実エージェント(Gemini)の実行で、同じ
trace_idが Langfuse と Unified Audit の両方に現れ、しかも監査側の SQL コメントにモデル名まで残った。端から端まで追える - ただし Unified Audit は 必須ではない(trace_id を SQL コメントに埋めて
DBTOOLS$MCP_LOGで突き合わせる軽い手もある)。それでも Audit を選ぶのは、改竄耐性・経路非依存・CLIENT_IDENTIFIERという正規スロットがあり「監査グレード」だから - 一方、
ACTIONS SELECTの監査ポリシーは範囲が広くノイズが多い(1問いで 52 行)。実運用では対象を絞る必要がある
本記事の「監査(Unified Audit)」は Oracle Database の統合監査機能を指します。Langfuse は LLM アプリケーションの可観測性(トレース)ツールで、ここではセルフホスト版を使います。
2. 検証環境
| 項目 | 内容 |
|---|---|
| マシン | Windows 11 Pro / WSL2 + Ubuntu |
| 対象DB | Oracle Autonomous Database 26ai(adbtest02、バージョン 23.26.2.2.0) |
| MCP サーバ | SQLcl MCP(sql -mcp) |
| LLM | Gemini(gemini-3.1-flash-lite、google-genai SDK)※今回は無料枠のため Gemini を使用 |
| 可観測性 | Langfuse セルフホスト(OSS、v3.169.0)+ langfuse SDK 4.7.1(OpenTelemetry ベース) |
| DBユーザー |
dba_copilot(最小権限。本検証で AUDIT_VIEWER を追加付与) |
構成は次のとおりです。エージェントは SQLcl MCP 経由でDBを操作し、その様子を Langfuse にトレースとして送ります。最後に、両者を trace_id で突き合わせます。
3. SQLcl MCP がDB側に残す監査情報
SQLcl MCP は、接続したDBセッションに対して監査・監視のための情報を自動で残します。公式ドキュメントには次のようにあります。
Session tracking: It populates
V$SESSION.MODULEwith the MCP client in use, andV$SESSION.ACTIONwith the LLM's name.
Activity logging: It creates a table namedDBTOOLS$MCP_LOGthat records every interaction and SQL execution.
Query identification: All LLM-generated queries ... include the following comment ...:/* LLM in use ... */
これを実機で確認します。SQLcl MCP で接続したセッションの属性を V$SESSION で見てみます。
SELECT sid, program, module, action, client_identifier
FROM v$session
WHERE sid = SYS_CONTEXT('USERENV','SID');
SID PROGRAM MODULE ACTION CLIENT_IDENTIFIER
------ ---------- ---------------------- -------- -----------------
36074 SQLcl-MCP gemini-3.1-flash-lite connect
MODULE には MCP クライアントが渡すモデル名(このエージェントが使う gemini-3.1-flash-lite)が入り、接続直後・trace_id 注入前なので CLIENT_IDENTIFIER は空のままでした。ここで早速、ドキュメントの説明と実際の挙動にズレがありました。
| 項目 | 公式ドキュメントの説明 | 実際の値 |
|---|---|---|
PROGRAM |
(記載なし) | SQLcl-MCP |
MODULE |
MCP クライアント名 |
LLM 名(クライアントが渡すモデル名。例: gemini-3.1-flash-lite) |
ACTION |
LLM 名 |
接続アクション connect(LLM 名ではなかった) |
つまり「クライアント名」と「LLM 名」がドキュメントの説明とは入れ替わっており、クライアント名は別の場所(後述の DBTOOLS$MCP_LOG.MCP_CLIENT)に入っていました。なお CLIENT_IDENTIFIER は既定では空で、ここが後でゴール3の「橋渡し」に使える空きスロットになります。
3.1. DBTOOLS$MCP_LOG
SQLcl MCP は、接続ユーザーのスキーマに DBTOOLS$MCP_LOG 表を作り、ツール呼び出しを1行ずつ記録します。
SELECT column_name, data_type FROM all_tab_columns
WHERE owner='DBA_COPILOT' AND table_name='DBTOOLS$MCP_LOG' ORDER BY column_id;
| 列 | 型 | 内容 |
|---|---|---|
ID |
NUMBER | 連番 |
MCP_CLIENT |
VARCHAR2 | MCP クライアントの自己申告名(例: mcp) |
MODEL |
VARCHAR2 | LLM 名(例: gemini-3.1-flash-lite) |
END_POINT_TYPE / END_POINT_NAME
|
VARCHAR2 | 種別 / ツール名(例: tool / run-sql) |
LOG_MESSAGE |
CLOB | 実行された SQL 全文など |
CREATED_ON ほか |
TIMESTAMP | 記録時刻 |
実際の中身(後述の Gemini エージェント実行が、そのまま記録されています):
ID MCP_CLIENT MODEL END_POINT_NAME LOG_MESSAGE(抜粋)
--- ----------- ---------------------- --------------- ------------------------------------------------
98 mcp gemini-3.1-flash-lite run-sql SELECT /* LLM in use is gemini-3.1-flash-lite */ ...
97 mcp gemini-3.1-flash-lite run-sql BEGIN /* LLM in use is gemini-3.1-flash-lite */ DBMS_SESSION...
96 mcp gemini-3.1-flash-lite connect DBA_COPILOTに接続
MCP_CLIENT にクライアント名(ここでは Python の MCP クライアントが名乗る mcp)、MODEL に LLM 名、LOG_MESSAGE に実行内容(connect・SET_IDENTIFIER・本命の SELECT)が 1 行ずつ入ります。
3.2. /* LLM in use ... */ コメント
SQLcl MCP は、LLM が生成した SQL の先頭に識別コメントを自動で挿入します。実際、V$SQL を引くと次のように見えます。
SELECT /* LLM in use is gemini-3.1-flash-lite */ sql_id, elapsed_time, sql_text
FROM v$sql ORDER BY elapsed_time DESC FETCH FIRST 3 ROWS ONLY
このコメントのおかげで、DB側のどの SQL が「MCP 経由・どのモデル発」なのかが一目で分かります。ゴール1(DB側で識別できるか)は、SQLcl MCP が設計でカバーしてくれていることが確認できました。
4. Unified Audit で「誰が・何を」を残す
DBTOOLS$MCP_LOG は便利ですが、これは SQLcl MCP が独自に作る表です。組織の監査要件としては、DB の正規の監査機構である Unified Audit(統合監査) に残したいところです。
4.1. カスタム監査ポリシーで SELECT を残す
Autonomous Database で既定で有効な監査ポリシーは、管理者操作やログオン失敗を拾うものばかりで、通常ユーザーの SELECT を記録するものはありません(実際、dba_copilot の SELECT は既定では 1 件も残りませんでした)。そこで、対象ユーザーの SELECT を監査するポリシーを ADMIN で作成します。
CREATE AUDIT POLICY dba_copilot_select_pol ACTIONS SELECT;
AUDIT POLICY dba_copilot_select_pol BY DBA_COPILOT;
これで、dba_copilot の SELECT が UNIFIED_AUDIT_TRAIL に SQL 文付きで残るようになりました(監査ログの閲覧には対象ユーザーに AUDIT_VIEWER ロールが要ります)。ゴール2は、既定では残らずカスタムポリシーが必要、という条件付きで達成です。
そして、この監査証跡を Langfuse と 1 本に結ぶ鍵が UNIFIED_AUDIT_TRAIL の CLIENT_IDENTIFIER 列です。次章でエージェントが session に注入する trace_id ── 今回の実行では lf:16ede74141f05d576c8b81b428be8e2e ── が、対象ユーザーの各監査レコードのこの列に乗り、突き合わせの主キーになります。逆に言えば、この列さえ揃えれば「DB に届いた SQL」と「LLM 側のトレース」を後から 1 対 1 で辿れます。
4.2. Unified Audit は必須ではない ─ でも監査グレードならこれ
正直に言うと、LLM 側と DB 側を突き合わせるだけなら Unified Audit は必須ではありません。たとえば実行する SQL に自前で /* lf:<trace_id> */ のようなコメントを埋め込めば、その文字列は次章で見る DBTOOLS$MCP_LOG.LOG_MESSAGE や V$SQL に残るので、Audit を使わずに突き合わせることもできます。
それでも本記事で Unified Audit を選ぶのは、DBTOOLS$MCP_LOG(SQLcl MCP が独自に作る表)には監査証跡として次の弱点があるからです。
| 観点 | DBTOOLS$MCP_LOG | UNIFIED_AUDIT_TRAIL |
|---|---|---|
trace_id(CLIENT_IDENTIFIER) |
✗ 列がなく、SQL 文に自前で埋めるしかない | ✓ そのまま持てる |
| 改竄耐性 | ✗ 接続ユーザー自身のスキーマにあり、編集・DROP できる | ✓ AUDSYS 管理で利用者は触れない |
| 経路非依存 | ✗ SQLcl MCP 経由の操作だけ | ✓ どのツール・接続経由でも記録 |
| 永続性 | ✓ | ✓ |
「自分のスキーマにあって自分で消せるログ」は、監査証跡としては心もとないところです。trace_id を SQL 文に混ぜずに CLIENT_IDENTIFIER という正規の場所で持て、改竄もしにくく、SQLcl MCP 以外の経路もまとめて押さえられる ── これが「軽い突き合わせ」ではなく「監査グレードの突き合わせ」に Unified Audit を使う理由です。
なお、「後から追える」ようにする監査と並んで、そもそも MCP で接続する DB ユーザーの権限を絞っておく という素直な打ち手もあります。本検証の dba_copilot も最小権限ユーザーで、与える権限を読み取り中心にしておけば、監査ログの量も操作の影響範囲も自然に小さくできます。監査(後から追う)と権限制限(そもそも触らせない)は対立せず、両輪で効きます。
Autonomous Database では Unified Audit が標準で有効化・管理されており、利用者が直接触れない保護された監査基盤として扱えます。さらに OCI Logging 等と連携すれば監査証跡を外部に長期保存でき、永続性・信頼性をもう一段補強できます。
5. LLM側を Langfuse で追い、端から端まで繋ぐ
ここが本記事の核です。エージェントを実装し、Langfuse でトレースしつつ、その trace_id を CLIENT_IDENTIFIER に注入して、DB側の監査証跡と突き合わせます。
5.1. エージェントの組み立て
エージェントは google-genai(Gemini)で、SQLcl MCP を外部プロセスとして起動し、run-sql ツールを使わせます。ポイントは2つです。
-
trace_idを確定的に注入する: 接続直後に、こちらからDBMS_SESSION.SET_IDENTIFIER('lf:<trace_id>')を実行する(LLM 任せにしない)。SQLcl MCP のDBセッションは MCP サーバプロセス内で持続するため、一度設定すれば以降の全 SQL にCLIENT_IDENTIFIERが乗る -
Langfuse の OpenTelemetry 計装で、LLM の生成(
generate_content)とトークンを自動収集する
なぜ CLIENT_IDENTIFIER を使うのか、という点が重要です。DBTOOLS$MCP_LOG は SQL 全文やモデル名を持ちますが、Langfuse の trace_id を入れる列がありません。一方 V$SESSION と UNIFIED_AUDIT_TRAIL は CLIENT_IDENTIFIER を持ちます。つまり LLM 側と DB 側をつなぐ唯一の共有キーになり得るのが CLIENT_IDENTIFIER です。
主要部分の抜粋です(全文は src/agent.py)。
# ルート span を開いてトレースを確定し、trace_id を取得・注入する
with langfuse.start_as_current_observation(
as_type="span", name="top-sql-3-with-audit-correlation",
input={"question": PROMPT, "model": MODEL},
):
trace_id = langfuse.get_current_trace_id()
client_id = f"lf:{trace_id}"
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# 確定的な先行実行(LLM 任せにしない)
await session.call_tool("connect", {"connection_name": CONN, "model": MODEL})
await session.call_tool("run-sql", {
"sql": f"BEGIN DBMS_SESSION.SET_IDENTIFIER('{client_id}'); END;",
"model": MODEL,
})
# 以降、Gemini に「重い SQL 3件」を尋ね、run_sql を関数呼び出しで実行
...
5.2. Langfuse 側のトレース
実行すると、Gemini は run_sql ツールで V$SQL を引き、重い SQL 3 件を表で回答しました。その全体が Langfuse に 1 トレースとして残ります。
ルート span top-sql-3-with-audit-correlation の配下に、LLM 生成(AsyncGenerateContent)と run-sql の span が並びます。トークンは 954 prompt → 338 completion(合計 1,292)、モデルは gemini-3.1-flash-lite と記録されています。
run-sql span を開くと、エージェントが実際に投げた SQL SELECT sql_id, elapsed_time, sql_text FROM v$sql ORDER BY elapsed_time DESC FETCH FIRST 3 ROWS ONLY が確認できます。
LLM の意思決定(run_sql を呼ぶ → 結果を受け取る → 表で回答)がトレースとして追えます。ゴール1(LLM側を Langfuse で追う)は達成です。
5.3. 端から端まで突き合わせる
このトレースの trace_id は 16ede74141f05d576c8b81b428be8e2e でした。CLIENT_IDENTIFIER には lf:16ede74141f05d576c8b81b428be8e2e を注入しています。これをキーに、DB側の UNIFIED_AUDIT_TRAIL を引きます。
SELECT event_timestamp, client_identifier, action_name, object_name,
SUBSTR(sql_text,1,90) AS sql_head
FROM unified_audit_trail
WHERE client_identifier = 'lf:16ede74141f05d576c8b81b428be8e2e'
ORDER BY event_timestamp;
結果が次の行です。
CLIENT_IDENTIFIER ACTION OBJECT SQL_HEAD
--------------------------------------- ------- ------- ----------------------------------------------------
lf:16ede74141f05d576c8b81b428be8e2e SELECT V$SQL SELECT /* LLM in use is gemini-3.1-flash-lite */ ...
エージェントが実行した SQL が、Langfuse と同じ trace_id で、しかも /* LLM in use is gemini-3.1-flash-lite */ コメント付きで監査証跡に残りました。これで、
- Langfuse 側: 「いつ・どのモデルが・どう考えて・どのツールを・何トークンで」
- Unified Audit 側: 「DBに何の SQL が届いたか・誰のセッションか」
を、1 つの trace_id から相互に辿れるようになりました。ゴール3、達成です。
6. 考察
-
V$SESSION.MODULE/ACTIONの中身は公式ドキュメントの説明(MODULE=クライアント名 / ACTION=LLM名)とズレており、実際は MODULE に LLM 名(クライアントが渡すモデル名)、ACTION にconnect(接続アクション) が入り、いずれも説明どおりではなかった。クライアント名はDBTOOLS$MCP_LOG.MCP_CLIENT側にあった。監査の現場では、ドキュメントの語の対応より実際にどの列に何が入るかを実機で確かめるのが確実だと考えられます - SQLcl MCP がセッション追跡・
DBTOOLS$MCP_LOG・識別コメントを提供すること自体は公式に明記されています(SQLcl MCP Server Doc)。DB側の「MCP 経由実行」の識別は、追加実装なしで効きます - LLM 側(Langfuse)と DB 側(Unified Audit)は共有キーを持たないが、
CLIENT_IDENTIFIERにtrace_idを注入することで突き合わせられました。この橋渡しは LLM プロバイダに依存しません(今回は Gemini ですが、Claude でもSET_IDENTIFIERの一手で同じ線が引けます) -
ACTIONS SELECTの監査ポリシーは範囲が広く、1 つの問いに対してCLIENT_IDENTIFIER一致の監査レコードが 52 行記録されました。大半は内部の再帰 SQL(OBJ$/V$DATABASEのopen_mode確認など)や SQLcl 自身のINSERT INTO DBTOOLS$MCP_LOGで、本命の業務 SQL はその中の数行でした。実運用ではACTIONS SELECT ON <対象表>のように対象を絞るか、object_schema/sql_textでフィルタしないと監査ログが膨らむと考えられます -
現時点でできること(追加実装なし): 本記事の方式(
CLIENT_IDENTIFIERへの trace_id 注入+Unified Audit)は MCP サーバ側に一切手を入れずに成立しました。さらにDBTOOLS$MCP_LOG(SQL 全文・モデル名)も併用でき、「LLM の意思決定(Langfuse)」「DB に届いた SQL(Audit)」「MCP ツール呼び出しの台帳(DBTOOLS$MCP_LOG)」の 3 点が同じ trace_id で揃います。ここまでは実機で確認できました -
現時点でできないこと: Langfuse(OpenTelemetry)には、MCP の
_meta(W3C Trace Context)でクライアント span とサーバ span を親子に連結する仕組みがあります(Langfuse: MCP Tracing)。ただしこれは MCP サーバ側も OTel 計装されていることが前提で、SQLcl MCP サーバはその対応がありません。そのため、サーバ内部の処理時間やエラーを同じトレースツリーに載せる、という連結は今はできず、本記事はCLIENT_IDENTIFIER注入で代替しました。将来 SQLcl MCP が OTel span を出すようになれば、注入に頼らずサーバ側まで一本のトレースで追える可能性があります(未検証) -
一般的なユースケースへ広げるときに要注意な点: 本検証は「1 エージェント=1 セッションを MCP サーバプロセスが持続保持する」前提で、接続直後に 1 回
SET_IDENTIFIERすれば以降の全 SQL に trace_id が乗りました。しかし実アプリの多くは コネクションプールでセッションを使い回すため、リクエストごとにSET_IDENTIFIERし、返却時にCLEAR_IDENTIFIERしないと、別リクエスト(別 trace_id)の SQL に前の trace_id が貼り付いたまま記録されます。またCLIENT_IDENTIFIERは 64 バイト上限で、値はクライアントの自己申告(=なりすまし得るので「誰が」の同定は DB ユーザー/認証側で担保し、これは相関タグと割り切る)です。これらは本記事のスコープ外で、プール環境での per-request の set/clear・read-only の強制(前述の DML 境界)・対象を絞った監査ポリシーを次回の検証に回します - 本記事は 読み取り(SELECT)の監査に絞りました。書き込み(DML)をそもそも通すべきか、通すなら read-only をどう強制するか、という書き込み境界の話は別軸です。アプローチの候補としては、DB ユーザーに読み取り専用ロールだけを与えて DML 自体を権限で塞ぐ、
ALTER SESSION ... READ ONLYで接続セッションを読み取り専用に固定する、あるいはコネクション側で制御する、といった方向が考えられ、どれが MCP 経由のエージェントに馴染むかは次回の検証候補です
7. まとめ
| # | 検証項目 | 結論 |
|---|---|---|
| 1 | DB側で「MCP 経由実行」と識別できるか | ✅ できる。V$SESSION.MODULE/ACTION・DBTOOLS$MCP_LOG・/* LLM in use */ を SQLcl MCP が自動で残す(ただし MODULE/ACTION の中身はドキュメントと逆) |
| 2 | Unified Audit に「誰が・何を」を残せるか | △ 既定では残らないが、ADMIN でカスタムポリシー(ACTIONS SELECT)を張れば残せる。監査ログの閲覧は対象ユーザーに AUDIT_VIEWER を付与する |
| 3 | LLM側と DB側を同じ trace_id で突き合わせられるか |
✅ できる。trace_id を CLIENT_IDENTIFIER に注入し、UNIFIED_AUDIT_TRAIL で 1 対 1 に突き合わせ成立(Audit は必須ではないが、監査グレードの突き合わせに最適) |
MCP でDBにつなぐエージェントは、「つなぐ・選ぶ・ツールを絞る」だけでなく、「誰が何を流したかを後から追える」状態にして初めて通常利用に乗せられる、というのが今回の実感です。SQLcl MCP は DB 側の足場(識別コメント・ログ表・セッション属性)を最初から用意してくれており、そこに trace_id を 1 行注入するだけで、LLM 側の可観測性(Langfuse)と DB 側の監査が 1 本に繋がりました。


