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.
- An agent cannot reach another customer: there is no argument to change, and nothing it can call sets the context.
- Anyone who can edit the MCP client’s configuration can change the URL, and with it the customer. For a customer-facing deployment, that configuration must be yours, not the customer’s.
- A value here is never a secret. URLs end up in proxy logs, access logs and screenshots. Use an identifier, never a password or a token.
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 required | A 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 refused | Claude 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 session | Each 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 bytes | SUZY%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:
- Claude Code: the query string is sent on every request, and
${VAR}in the URL is expanded from the environment. - Cursor: the query string is sent on every request, and a space typed into the URL is encoded
for you. Cursor’s variable syntax is
${env:NAME}—?CUSTOMER_NAME=${env:HOTEL_CUSTOMER}— and an unset variable becomes an empty value, which the server refuses as missing. Two things to know:- After changing the URL, quit Cursor (⌘Q) and start it again. Reloading the window, or switching the server off and on, is not enough: in our test the chat went on using the previous connection — and the previous customer — until Cursor was restarted.
- Cursor switches a project’s MCP server off whenever its
.cursor/mcp.jsonchanges. Turn it back on in Cursor Settings → MCP.
- The Claude API’s MCP connector (the Messages API’s
mcp_servers): the query string is kept on every request the API makes to your server, and a space typed into the URL is encoded for you. Put the API token inauthorization_token. This is the client the feature is built for: your application builds the URL for whichever customer has signed in, request by request, and the person never sees it.
"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 wrong person writes the URL. On a personal plan, the person using the connector types its URL, so they can put any customer on it — exactly what the first section says this feature cannot stop. On a Team or Enterprise plan, an Owner adds the connector once for the whole organisation, so every member connects with the same URL and is the same customer. Neither pins each connection to its own customer.
- It may not be able to connect at all. The console’s
/mcp/<owner>/<config>endpoint needs an API token in anAuthorization: Bearerheader. A custom connector signs in with OAuth, with no sign-in, or with fixed request headers — and Anthropic describes request headers as a beta available to a limited set of organisations. If the Add custom connector dialog shows no Request headers section, the connector cannot send your token.
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.