# MCP to Oracle Implementation: Deep-Dive on VPD Policies at the Protocol Layer

*By the database security engineering team at OnlineCloud — a US-based Agentic AI & MCP engineering studio*
—
## 1. Introduction: Why Agentic SQL Access Is Inherently Dangerous
Before we talk about Virtual Private Database (VPD) policies, let’s be honest about the threat model that makes them necessary. An agent with SQL access is not a faster analyst. It is a new class of database principal, and it violates nearly every assumption the Oracle security model was designed around.
The first violation is *judgment*. A human analyst carries context: which cost center they belong to, which accounts they may touch, which tables are too sensitive to query casually. An LLM carries none of that. It has a system prompt, a tool description, and a stochastic sampler. Its notion of “the right rows” comes from whatever text was in its context window — including text an attacker injected through a fetched document, an email thread, or a poisoned web page. Hand an agent a connection string and a free-text SQL tool, and you have handed your prompt-injection attack surface the keys to your data dictionary.
The second violation is *impersonation*. Legacy application access is mediated by application code that authenticates the end user and then shapes every query around that identity. Agentic tool calls bypass the application entirely. The MCP (Model Context Protocol) server is the application now — and if it connects to Oracle with a single shared, privileged credential, then every end user behind the agent inherits the same database identity. Row-level ownership, tenancy boundaries, and segregation-of-duties controls silently stop meaning anything. A tool that answers “show me this customer’s invoices” for one user can, under a shared credential, answer it for every customer.
The third violation is *amplification*. SQL is a Turing-complete, set-oriented language where one malformed predicate can scan a billion rows, one omitted predicate can return a full table, and one `MERGE` can rewrite a ledger. A human who does that is caught by review. An agent that does it has already moved on — and may have serialized the entire result set into its context window, where it becomes training signal, cached memory, or a prompt-injection payload.
You cannot prompt your way out of this. The fix is a database that is physically incapable of returning a row the principal may not see — regardless of what the model asks or what string an attacker smuggled in. That is exactly what Oracle Virtual Private Database provides, and why VPD belongs at the heart of any production MCP-to-Oracle bridge.
## 2. Oracle Virtual Private Database: The Principles of FGAC
Virtual Private Database is Oracle’s implementation of **Fine-Grained Access Control (FGAC)**. The principle is disarmingly simple: a security policy is attached directly to a table or view, and Oracle’s kernel appends a predicate to *every* SQL statement that references the object — regardless of the tool, the driver, the connection pool, or the human (or model) behind it.
The policy is a PL/SQL function you write, registered through the `DBMS_RLS` package:
“`sql
— Policy function: returns the predicate Oracle appends to every statement
CREATE OR REPLACE FUNCTION mcp_sec.f_tenant_scope (
p_schema VARCHAR2, p_object VARCHAR2
) RETURN VARCHAR2 AS
v_tenant VARCHAR2(64);
BEGIN
v_tenant := SYS_CONTEXT(‘mcp_ctx’, ‘tenant_id’);
— FAIL CLOSED: no context, no rows. Never return NULL here.
IF v_tenant IS NULL THEN
RETURN ‘1 = 0’;
END IF;
RETURN ‘tenant_id = SYS_CONTEXT(”mcp_ctx”, ”tenant_id”)’;
END;
/
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ‘FIN’,
object_name => ‘INVOICES’,
policy_name => ‘mcp_tenant_scope’,
function_schema => ‘MCP_SEC’,
policy_function => ‘f_tenant_scope’,
statement_types => ‘SELECT, UPDATE, DELETE’,
sec_relevant_cols => ‘AMOUNT,TAX_ID’,
policy_type => DBMS_RLS.CONTEXT_SENSITIVE
);
END;
/
“`
Three properties make this the right control for agentic access.
**Transparency.** The predicate is invisible to the caller. The agent issues `SELECT * FROM fin.invoices` and receives only its own tenant’s rows — no `WHERE` clause it can argue with, drop, or inject around. The model never knows the boundary exists, which means it cannot be social-engineered into crossing it.
**Enforcement inside the kernel.** VPD is not an application-layer filter; the predicate is compiled into the execution plan by the optimizer. No code path — tool, SQL*Plus session, or `EXECUTE IMMEDIATE` trick — reaches rows without passing through it. That matters because the model’s tool arguments are untrusted input; defense must live below the layer an attacker can influence.
**Column-level reach.** Through `sec_relevant_cols`, VPD can mask sensitive columns (replacing them with `NULL`) while leaving the rest of the row visible — useful when an agent legitimately needs invoice metadata but must never receive `TAX_ID` or PII columns.
One rule makes or breaks a deployment: *fail-closed discipline*. A policy function that returns `NULL` or an empty string when the context is missing disables restriction entirely — a guard quitting his post because nobody badged in. Every policy function must return `1 = 0` on missing context and treat a NULL context as an attack signal worth auditing, not a configuration convenience.
## 3. The MCP Integration: `DBMS_SESSION.SET_CONTEXT` Inside the Tool Invocation Loop
VPD predicates need a per-principal value to filter on, and Oracle’s mechanism for supplying that value is the **application context**: a namespace of name-value pairs associated with a database session, read by SQL through `SYS_CONTEXT`. Application contexts are how VPD deployments have bound end-user identity to row security — and the MCP tool loop is a new, unusually demanding caller of that machinery.
The binding happens inside the MCP server’s tool handler. The critical architectural decision is where the MCP Oracle connector sits relative to the LLM:
“`
LLM Host ──MCP (streamable HTTP / SSE)──▶ MCP Server (Oracle tools)
│
1. authenticate OAuth2 token
2. resolve principal → tenant_id
3. bind Oracle session context
│
▼
Oracle DB ──VPD predicate──▶ Rows
“`
Each tool handler in the MCP server follows a strict sequence: **authenticate, resolve, bind, execute, clear**. If your MCP server uses a session pool (and it should — connect cost alone rules out per-call sessions), the pooled session is a shared, reusable asset that must be re-scoped on every checkout:
“`java
// MCP tool handler — “get_customer_invoices”
ToolResult handle(Invocation inv) {
var claims = verifyOAuthToken(inv.principalToken()); // 1. authenticate
String tenantId = identityResolver.mapToTenant(claims.sub()); // 2. resolve
try (var conn = pool.getConnection()) { // 3. bind
conn.prepareCall(“{call mcp_sec.set_principal(?, ?, ?)}”)
.setString(1, claims.sub())
.setString(2, tenantId)
.setString(3, inv.requestId())
.execute();
try (var rs = conn.prepareStatement(inv.sql()) // 4. execute
.executeQuery()) {
return rowMapper.apply(rs);
} finally {
conn.prepareCall(“{call mcp_sec.clear_principal()}”).execute(); // 5. clear
}
}
}
“`
The PL/SQL side is where the security lives. A namespace can only be written by the package it is bound to (`CREATE CONTEXT … USING`), which means the database login used by the MCP pool **cannot forge a context attribute directly** — it can only invoke the package, and the package decides what it will accept:
“`sql
CREATE OR REPLACE CONTEXT mcp_ctx USING mcp_sec.set_principal;
CREATE OR REPLACE PACKAGE mcp_sec AS
PROCEDURE set_principal(p_sub VARCHAR2, p_tenant VARCHAR2, p_req VARCHAR2);
PROCEDURE clear_principal;
END;
/
CREATE OR REPLACE PACKAGE BODY mcp_sec AS
PROCEDURE set_principal(…) IS
BEGIN
— Server-side validation: never trust raw tool arguments.
— p_sub/p_tenant arrive only after OAuth verification at the MCP edge.
DBMS_SESSION.SET_CONTEXT(‘mcp_ctx’, ‘oauth_sub’, p_sub);
DBMS_SESSION.SET_CONTEXT(‘mcp_ctx’, ‘tenant_id’, p_tenant);
DBMS_SESSION.SET_CONTEXT(‘mcp_ctx’, ‘request_id’, p_req);
DBMS_APPLICATION_INFO.SET_MODULE(‘mcp:’ || p_req, ‘tool_execute’);
DBMS_SESSION.SET_IDENTIFIER(p_sub); — visible in Unified Audit
END;
PROCEDURE clear_principal IS
BEGIN
DBMS_SESSION.CLEAR_CONTEXT(‘mcp_ctx’);
DBMS_APPLICATION_INFO.SET_MODULE(NULL, NULL);
DBMS_SESSION.SET_IDENTIFIER(NULL);
END;
END;
/
“`
The `finally` block that clears the context is not hygiene — it is the security boundary between one agent user and the next. A pooled session retaining the previous principal’s context is a cross-tenant read channel waiting for a scheduling coincidence. Context bleeding of this kind is the most common VPD-in-pooling bug I have reviewed, and exactly what an MCP tool loop amplifies by multiplexing many end users over few sessions.
## 4. Security: From the OAuth2 Principal to the SQL Predicate
Row-level security is only as trustworthy as the identity chain that feeds it. VPD does not care what your OAuth2 token *claims* — it cares what the application context contains, because that is what the predicate compares against. Every hop in that chain — token claims → context attributes → SQL predicate — must be validated and must fail closed.
**Hop one: the token.** The MCP server terminates OAuth2 (for MCP hosts, the Authorization Code flow with PKCE per the MCP authorization specification) and verifies signature, issuer, audience, and expiry on *every* tool call — not once per connection. The claim that anchors the chain is `sub`, the stable principal identifier, which your identity provider guarantees is unique per human or service account.
**Hop two: claims to context.** The tool handler must never pass raw model-provided arguments into the context. The tenant and any scoping attributes are *derived server-side* from the verified `sub` by your identity resolver — the same directory that powers the rest of your authorization estate. An unmapped `sub` returns an authorization error, never a default “no filter.”
**Hop three: context to predicate.** Inside the policy function, `SYS_CONTEXT(‘mcp_ctx’,’tenant_id’)` is interpolated into a SQL predicate string. That string is assembled by *your* code, not user input — but treat it as injection-prone anyway. Validate attribute values with `DBMS_ASSERT` (or a whitelist regex) before embedding them, bind with literals only after validation, and always fail closed when the attribute is absent or malformed:
“`sql
IF NOT mcp_sec.validate_tenant_id(v_tenant) THEN
RETURN ‘1 = 0’; — invalid value is the same as no value: deny
END IF;
“`
**Why this defeats the agentic attack classes.** Prompt injection cannot manufacture rows, because the predicate is appended below any SQL the model can influence. Tool misuse cannot escalate scope, because even a “correct” tool call is filtered by the context of whoever’s token authorized it. And a compromised MCP server cannot become a tenant-wide read, because the database login itself holds no broad data privilege that VPD does not narrow — audit the login’s grants so that table privileges exist precisely *because* VPD constrains them, not despite it.
One additional hardening worth shipping: restrict the MCP database account so it can only reach protected tables through paths where the context has been set. A logon trigger that raises when a statement references a VPD-protected table while `mcp_ctx.tenant_id` is null turns “forgot to set context” from a silent data-classification incident into a loud, audited failure at connect time.
## 5. Audit Trails: Reconciling MCP Decision Logs with Oracle Unified Audit
VPD tells you what a principal *can* see. Audit tells you what the system *actually did* — and with an agent in the loop you need two independent records, because they answer different questions. **MCP decision logs** answer *why*: the tool name, the model’s arguments, the request ID. **Oracle Unified Audit** answers *what really happened at the data layer*: the exact SQL parsed, the database user, the client identifier, the bind values. Neither record suffices alone; the security value is in the *reconciliation*.
To make reconciliation possible, every MCP tool execution must stamp the database session with correlation identifiers that flow into the audit trail. In the package above, that is `DBMS_SESSION.SET_IDENTIFIER(p_sub)` — which Unified Audit records as `CLIENT_IDENTIFIER` — and `DBMS_APPLICATION_INFO.SET_MODULE(‘mcp:’ || request_id, …)`, recorded as `MODULE`/`ACTION`. The request ID is generated by the MCP host at conversation start and logged by the decision logger, so the join key exists end to end:
“`sql
CREATE AUDIT POLICY mcp_fin_access
ACTIONS SELECT, UPDATE, DELETE ON fin.invoices,
SELECT ON fin.customers
WHEN ‘SYS_CONTEXT(”USERENV”,”MODULE”) LIKE ”mcp:%”’
EVALUATE PER STATEMENT;
AUDIT POLICY mcp_fin_access;
— Reconciliation: did every audited statement have a logged decision?
SELECT ua.os_user, ua.client_identifier, ua.module, ua.sql_text,
ua.event_timestamp, dl.request_id
FROM unified_audit_trail ua
LEFT JOIN mcp_decision_log dl
ON dl.request_id = REGEXP_SUBSTR(ua.module, ‘mcp:([a-z0-9-]+)’, 1, 1, NULL, 1)
WHERE ua.module LIKE ‘mcp:%’
AND ua.event_timestamp > SYSTIMESTAMP – INTERVAL ‘1’ HOUR;
“`
The anomalies are where the investigation lives:
– **Audit rows with no matching decision log** mean SQL executed on the MCP credential that no tool call can explain — a policy bypass, a compromised server, a manual session using the same account, or a driver-side retry. All four are worth knowing about.
– **Decision logs with no matching audit rows** mean a tool claimed success the database never executed — a dropped connection, a misrouted request, or (worse) an audit policy that missed a table. Empty reconciliation results are how you discover your audit coverage gap.
– **Context and identity drift**: audit rows whose `CLIENT_IDENTIFIER` does not match the decision log’s principal indicate the context was stale or overwritten — the pooling bug from Section 3, caught in the wild.
For column-level sensitivity, layer `DBMS_FGA` fine-grained audit policies on top of the VPD-protected objects so that access to specific columns (tax identifiers, account numbers, PII) generates its own auditable events. Keep the MCP account out of `AUDIT_ADMIN` and `AUDIT_VIEWER`, set a retention policy on `UNIFIED_AUDIT_TRAIL` that survives the application’s own log rotation, and treat the decision log as a tamper-evident append-only stream (hash-chained or written to object storage with WORM retention). When an incident responder asks “what did the agent do and why,” the two logs answer in one join.
## 6. Performance: Evaluating the Latency Overhead of Session-Scoped Policies
VPD is not free, and an MCP tool loop multiplies the cost of getting the policy configuration wrong, because it runs many small statements across pooled sessions. In our benchmark harness — a 19c database, 10M-row `INVOICES` table partitioned by tenant with an index on `(tenant_id, invoice_date)`, an MCP server issuing point lookups through a 20-session pool — the overhead breaks down into three measurable components.
**Context binding.** A `DBMS_SESSION.SET_CONTEXT` call costs on the order of tens of microseconds. Done once per tool call (not per statement), it is noise — we measured ~0.1–0.3 ms added to a ~2 ms request. The failure mode is doing it *per statement* inside the tool handler: with agent loops issuing 5–10 statements per call, that compounds into visible tail latency for zero security benefit. Bind once at checkout, clear once at check-in.
**Predicate generation.** The policy function runs per statement for `DYNAMIC` policies, and its cost is dominated by the `SYS_CONTEXT` reads and any validation SQL it executes — typically 50–150 µs. The bigger risk is *how often it runs*. With `DYNAMIC`, Oracle re-executes the function on every parse; under connection pooling with many short agent statements, you pay the function call plus potential cursor invalidation. Switching to `CONTEXT_SENSITIVE` (as in Section 2) tells Oracle to skip re-execution when the application context has not changed since the last execution in that session — the correct setting for the MCP pattern, where the context is stable across all statements of one tool call. We measured ~0.4 ms average added per statement with `CONTEXT_SENSITIVE` versus ~0.9 ms with `DYNAMIC` on the same workload, and a measurable reduction in hard parses.
**Plan quality.** This is the cost that shows up at the 99th percentile, not the mean. VPD predicates are appended at parse time, and the optimizer must choose a plan for a statement whose real filter it cannot see in the text. An equality predicate on an indexed tenant column (`tenant_id = SYS_CONTEXT(…)`) folds to a literal at parse and uses the index normally — our p95 stayed within ~5% of the no-VPD baseline for point lookups. The danger is non-sargable or wide predicates, or cases where the agent’s own filter is selective only *after* the VPD filter applies — the optimizer can badly misestimate cardinality and choose a full scan. Mitigations that worked in our rig: keep predicates on indexed leading columns, gather stats with the policy in place, and treat `CURSOR_SHARING` as a deliberate setting — context-sensitive policies plus literal replacement can produce version-count storms in pooled sessions.
The honest summary from our measurements: on an OLTP-shaped agent workload, session-scoped VPD with context-sensitive policies added **0.4–0.9 ms per statement and roughly 3–6% end-to-end latency** — while `DYNAMIC` policies and per-statement context binding doubled or tripled that overhead. For the protection delivered — tenant isolation that no prompt injection, tool misuse, or compromised server process can defeat — that is the cheapest insurance an Oracle estate can buy.
—
## Ready to Lock Down Oracle Behind Your Agents?
OnlineCloud is a US-based engineering studio that builds MCP-native agentic systems on Oracle, MS SQL Server, Postgres, and Snowflake — with VPD row-level policies, OAuth2 at the edge, Unified Audit reconciliation, and eval suites built in from the first sprint. Senior engineers only, no offshore handoffs, and a working vertical slice in your environment within six to ten weeks.
**Talk to an OnlineCloud architect about your Oracle agent roadmap** — email [contact@onlinecloud.dev](mailto:contact@onlinecloud.dev) or visit [onlinecloud.dev](https://www.onlinecloud.dev/). Free initial consultation, response within 24 hours.
Leave a Reply