MCPDBWizard

Documentation  ·  Curating

Context Pinning

Context Pinning fixes a value to a connection — most often which customer this connection serves — where the agent cannot reach it. The value goes on the connection URL, and the server sets it in Oracle before every tool call.

A config declares the names it accepts, as URL context parameters; the client puts the values on its connection URL:

https://mcp.example.com/mcp/<owner>/<config>?CUSTOMER_NAME=SUZY%20BISHOP

Your PL/SQL and your curated SQL read them back:

SYS_CONTEXT('MCP', 'CUSTOMER_NAME')

So a procedure can serve one customer and refuse everything else — without taking the customer as a parameter, which is the point. A tool argument is chosen by the model. The URL is not: the model never sees it, and no tool can change it. That is what pinned means here.

A customer is the usual case and the one this page uses throughout, but the value can be any identifier your PL/SQL scopes by — a tenant, a branch, a region — and a config can pin several.

What this protects against, and what it does not

It keeps the agent in its lane. It does not keep the person in theirs.

If the person at the keyboard must not be able to choose the customer either, the customer has to come from their credential instead — see Creating application users and give each customer their own config and token.

Set it up

1. Install the MCP context — once per database, as a DBA. The scripts are in db/mcp-context/:

@install.sql                 -- creates MCPDBWIZARD, MCPDBWIZARD_CTX and CONTEXT MCP
@grant.sql APP_ACCOUNT       -- each account a generated server connects as

install.sql creates a schema that cannot log in, holding one package, and binds the MCP namespace to it. Only that package can write the namespace: anything else calling DBMS_SESSION.SET_CONTEXT('MCP', ...) gets ORA-01031, including code running as your application account. It refuses to run if an MCP context already exists bound to someone else’s package. Oracle 12.1 to 26ai.

2. Declare the names. On Design → Service Options, list them in URL context parameters, one per line. In the config file:

MCP_CONTEXT_PARAM_1=CUSTOMER_NAME

Letters, digits and underscores, starting with a letter, at most 30 characters. Generate and start the server as usual.

3. Read the value in the database. See below.

4. Connect with the value on the URL. Claude Code’s .mcp.json expands environment variables inside the URL:

{ "mcpServers": { "hotel": {
    "type": "http",
    "url": "https://mcp.example.com/mcp/alice/hotel_customer?CUSTOMER_NAME=${CUSTOMER}",
    "headers": { "Authorization": "Bearer <id>.<secret>" } } } }

Other clients write this differently — Cursor uses ${env:CUSTOMER}, and the Claude API takes the URL and token in its request. See Clients for each.

A generated server run over stdio has no URL, so it reads MCP_CONTEXT_<NAME> from its environment instead — MCP_CONTEXT_CUSTOMER_NAME=SUZY BISHOP — once, at start-up.

The rules

Every declared name is requiredA call whose URL lacks one is refused, naming it.
Only declared names are accepted?CUSTMER_NAME= (a typo) is refused, not ignored — an ignored typo would run with the context unset.
Empty is missing?CUSTOMER_NAME= is refused: Oracle stores '' as NULL.
An unsubstituted variable is refusedClaude Code sends ${CUSTOMER} literally when CUSTOMER is unset. The call is refused with a sentence saying so, rather than running as a customer called ${CUSTOMER}.
Per request, not per sessionEach call uses the URL it arrived on.
Names match case-insensitively?customer_name= works; the name in Oracle is upper case.
Values are URL-decoded, up to 4000 bytesSUZY%20BISHOP arrives as SUZY BISHOP. Longer is refused, never truncated.

A refused call is an ordinary tool error the agent can read and relay, recorded with the outcome context-refused. initialize and tools/list are not refused, so a client still connects and can say what is wrong.

If its account cannot call the context package — install.sql never run, or grant.sql not run for that account — the console’s Start stops before generating, in a few seconds, and names both scripts. A server started some other way logs the same sentence at start-up and refuses every tool call with it until a DBA fixes the database; it then works without a restart. Likewise under stdio, a missing MCP_CONTEXT_<NAME> is logged at start-up and every call is refused naming it. The server keeps running in both cases on purpose: an MCP client shows a server that exits as a connection that never started, while a refused call puts the reason in front of whoever is using it.

Connections are pooled, and that is handled for you. Before every call the server clears the whole namespace on the session it is about to use, then sets this call’s values — so a session that served another customer a moment ago cannot carry their value into your call.

Writing the PL/SQL

Read the context in one place, and refuse when it is missing rather than returning NULL — NULL matches nothing in one query and, written carelessly, everything in another:

FUNCTION current_customer RETURN VARCHAR2 IS
  l_name VARCHAR2(4000) := UPPER(TRIM(SYS_CONTEXT('MCP', 'CUSTOMER_NAME', 4000)));
BEGIN
  IF l_name IS NULL THEN
    raise_application_error(-20100, 'No customer on the connection URL.');
  END IF;
  RETURN l_name;
END;

Then every procedure filters on it, and no procedure takes the customer as an argument:

PROCEDURE my_bookings (p_bookings OUT BookingCursor) IS
  l_customer VARCHAR2(4000) := current_customer;   -- a local: a package-private
BEGIN                                               -- function cannot be called in SQL
  OPEN p_bookings FOR
    SELECT * FROM room_bookings WHERE customer_name = l_customer;
END;

Where an action targets a row by id — cancel this booking — check the row is the customer’s, and answer a row that is someone else’s exactly as a row that does not exist, so the tool cannot be used to probe for other customers’ ids.

Do not wrap the context in NVL. A missing customer should fail, visibly.

If you use a view

You can put the same filter in a view instead of in each procedure. If anything writes through that view, declare it WITH CHECK OPTION:

CREATE VIEW my_room_bookings AS
  SELECT * FROM room_bookings
   WHERE customer_name = SYS_CONTEXT('MCP', 'CUSTOMER_NAME')
  WITH CHECK OPTION;

Without it the view filters what a customer reads but not what they write: through it, customer A can insert a row for customer B, or update its own rows to belong to B. With it, Oracle refuses both (ORA-01402) and still allows A’s own rows. Measured on Oracle 12.1.

The generator builds row tools only for objects with a primary key, and a view ordinarily has none, so a view like this is reached through your PL/SQL and curated SQL statements rather than through generated table tools.

In the audit trail

Each tool call’s audit record carries the values it ran under, at every audit level (abbreviated here — the record also carries its id and timestamp):

{"tool":"customer_portal_my_bookings","outcome":"ok","ms":7,"args":[],
 "context":{"CUSTOMER_NAME":"SUZY BISHOP"}}

They are not Prometheus labels: one series per customer would not scale.

A worked example

The hotel demo ships two configs for one schema. mcpdemo.json is the staff view: its tools take a customer name, which is right for a front desk. config/mcpdemo_customer.json is the customer view: a CUSTOMER_PORTAL package (my_details, my_bookings, book_rooms, cancel_booking, my_complaints, add_complaint), room availability and read-only hotel data — and nothing that names a customer.

Clients

Verified with:

"mcp_servers": [{
  "type": "url",
  "name": "hotel",
  "url": "https://mcp.example.com/mcp/<owner>/<config>?CUSTOMER_NAME=SUZY%20BISHOP",
  "authorization_token": "<id>.<secret>"
}],
"tools": [{ "type": "mcp_toolset", "mcp_server_name": "hotel" }]

The API reaches your server from the internet, so the server needs a public HTTPS address. Anthropic’s requests come from 160.79.104.0/21, which you can allowlist.

Other clients have not been verified yet; a client that dropped the query string would show as every call refused for a missing parameter.

Not claude.ai

A custom connector in claude.ai — and in Claude Desktop and the Claude mobile apps, which add connectors the same way — is the wrong place for this feature, for two reasons.

The shape this feature is built for is your own application choosing the customer: it knows who has signed in, and it opens the connection with that customer on the URL, which the person never sees — the Claude API’s MCP connector, above, does exactly that.