Enum Injection
Role-scoped enum injection is an optional LockMCP feature that dynamically constrains tool parameters to the specific resource IDs the current user is authorised to access. Instead of Claude guessing which IDs to use or discovering boundaries through 403 errors, it receives a tool schema with an enum listing only the IDs that are valid for this user — making the first call structurally correct.
This feature is entirely opt-in. Tenants that do not configure it continue to work exactly as before. It does not replace backend RBAC — your API remains the authoritative enforcement layer. Enum injection is a quality-of-life improvement for the AI client.
Why it matters
| Without enum injection | With enum injection |
|---|---|
AI discovers scope limits through 403 errors | AI cannot construct an out-of-scope call — enum prevents it |
| Wasted tokens on invalid calls and retries | First call is always valid — no retries needed |
| AI must ask the user which resource to use | AI self-selects from the enum in the tool schema |
| Every user sees the same static tool schemas | Each user's session receives schemas tailored to their exact scope |
Architecture
┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐
│ MCP Client │ │ LockMCP │ │ Your Platform │
│ (Claude, etc)│ │ Data Plane │ │ │
└──────────────┘ └──────────────┘ └──────────────────────────┘
│ │ │
│ list_tools │ │
│ ─────────────► │ │
│ │ Read bootstrap_payload │
│ │ from session record │
│ │ (written at auth time) │
│ │ │
│ │ { accounts: [{roles: │
│ │ [{role, propertyIds}]}]}
│ │ │
│ │ Rewrite schemas: │
│ │ property_id → enum:[101,102]
│ │ Strip x-mcp-inject │
│ │ │
│ Tools with enums │ │
│ ◄───────────── │ │How to enable it (3 steps)
Step 1 — Mark parameters in your OpenAPI spec
In your OpenAPI file, add the x-mcp-inject extension to any parameter schema that should be scope-constrained. The value is the key the hub looks up in your bootstrap response:
# openapi.yaml
paths:
/leases:
get:
parameters:
- name: property_id
in: query
required: true
schema:
type: integer
x-mcp-inject: "propertyIds" # hub fills this with the user's scoped IDs
/financials:
get:
parameters:
- name: property_id
in: query
required: true
schema:
type: integer
x-mcp-inject: "propertyIds" # same marker, different enum per rolex-mcp-inject is a relay directive — Claude never sees it. By the time the schema reaches an MCP client, the marker is replaced with enum: [101, 102, ...] and stripped.
The key name is arbitrary — use whatever your enum_scopes payload returns. Common patterns:
| Platform type | Parameter | x-mcp-inject value |
|---|---|---|
| Property management | property_id | propertyIds |
| Project management | project_id | projectIds |
| Healthcare | organisation_id | organisationIds |
| E-commerce | store_id | storeIds |
Step 2 — Configure inject keys per tool and role
Go to Dashboard → Tool Studio, select a tool, and scroll to Role Access. Check the roles that should have access, then enter the inject key(s) for each role in the text input below the checkbox:
propertyIdsMultiple keys are comma-separated (e.g. propertyIds, accountIds). The key must exactly match the key name in your enum_scopes payload.
Step 3 — Return the bootstrap payload at auth time
How the payload reaches the hub depends on your identity model. In both cases it rides on something your systems already send — there is no separate endpoint for the hub to call out to.
TENANT_DELEGATED: include an "enum_scopes" key in your exchange and validate (and optionally refresh) response bodies. The hub stores it on the session on receipt; from the exchange, the user's first tool listing already has it. The validate example:
// POST /your-validate-url — add "enum_scopes" to existing response
{
"valid": true,
"remaining_seconds": 3540,
"roles": ["LEASING_AGENT"],
"enum_scopes": {
"email": "user@example.com",
"accounts": [
{
"accountId": 456,
"roles": [
{ "role": "LEASING_AGENT", "propertyIds": [101, 102] }
]
}
]
}
}TOKEN_EXCHANGE / IDENTITY_BROKERING: the payload travels as an x-mcp-bootstrap claim on the access token. Your identity provider emits it at login, Keycloak carries it through, and the hub reads it from the token it has already verified — so it cannot be forged by the AI client and cannot go stale relative to the credential it authorises:
{
"sub": "user-id",
"iss": "https://auth.yourcompany.com",
"x-mcp-bootstrap": {
"email": "user@example.com",
"accounts": [
{
"accountId": 456,
"roles": [{ "role": "ACCOUNTANT", "propertyIds": [101] }]
}
]
}
}Three ways to get that claim onto the token, in descending order of preference:
| Approach | Who maintains it | Stays current? |
|---|---|---|
| Your IdP emits it at login | nobody — it is computed per user | yes, at every login |
| Your backend pushes it | your application, when scope changes | yes, within ~5 minutes |
| A workspace admin sets it | a person, in the dashboard | no — it is a fixed list |
The first is the goal; the other two exist because not every identity provider can emit a custom claim. See Record Scope for how to set each of them up.
Payload structure:
| Field | Required | Description |
|---|---|---|
email | No | User identifier (informational) |
accounts | Yes | Array of accounts the user can access |
accounts[].accountId | No | Account identifier (opaque to the hub) |
accounts[].roles | Yes | Roles the user holds within this account |
accounts[].roles[].role | Yes | Role name — the LockMCP role that owns the inject key (exact, case-sensitive). On a Tenant Delegated connection it may instead be your app's own role name, as listed in the connection's role mapping: it then also matches the LockMCP role it maps to, provided the user holds that role |
accounts[].roles[].<key> | Yes | Array of scoped IDs for this role (integers or strings) |
Rules:
- The
enum_scopeskey is optional, but leaving it out does not lift the restriction. If a role the user holds has an inject key configured for a tool, the tool's scoped parameter gets an empty list and every call is refused ("not within your authorized scope"). Tools stay unrestricted only where no role of the user has an inject key for them. - The
accounts[]structure handles single-account users (one entry) and multi-account users (multiple entries) uniformly. - IDs must be globally unique across accounts if you have multi-account users — the hub unions IDs across accounts for the same role.
Injection scenarios
Single role — simple case
A user with one role receives a straightforward enum on all tools for that role:
Bootstrap payload: { role: "ACCOUNTANT", propertyIds: [101] }
OpenAPI parameter: property_id (x-mcp-inject: "propertyIds")
Tool schema Claude sees: property_id → enum: [101]Multiple roles — per-tool isolation
When a user holds multiple roles, each tool gets the enum for the role that owns that tool — not a union across all roles. This keeps tool-level scope tight:
User roles: LEASING_AGENT → propertyIds: [101, 102]
ACCOUNTANT → propertyIds: [101]
get_leases (LEASING_AGENT tool) → property_id enum: [101, 102]
get_financials (ACCOUNTANT tool) → property_id enum: [101]
Claude cannot call get_financials with property_id: 102
even though the user has LEASING_AGENT access to 102.Shared tools — union of IDs
When two roles both include the same tool, the hub injects the union of their scoped IDs:
LEASING_AGENT → propertyIds: [101, 102]
PROPERTY_MANAGER → propertyIds: [103]
Both roles include: get_leases
get_leases → property_id enum: [101, 102, 103]Multi-account users
Users invited into multiple accounts have multiple entries in accounts[]. The hub unions IDs for the same role across accounts:
{
"accounts": [
{ "accountId": 456, "roles": [{ "role": "LEASING_AGENT", "propertyIds": [101, 102] }] },
{ "accountId": 789, "roles": [{ "role": "LEASING_AGENT", "propertyIds": [201, 202] }] }
]
}get_leases → property_id enum: [101, 102, 201, 202]How scope is applied, and when it fails closed
LockMCP checks every call against the same scoped schema it advertises, so a parameter's scope is enforced, not just suggested. What happens depends on whether a role the user holds carries the inject key for that tool:
| Condition | Behaviour |
|---|---|
| No role of the user has an inject key for the tool | Marker stripped, no enum — the parameter is unrestricted |
| A role has the key, and the payload has IDs for it | enum: [...] of those IDs, the parameter is required, and any other value is refused |
A role has the key, but the payload is missing (no enum_scopes, no x-mcp-bootstrap claim) or has no IDs for it | enum: [] — every call is refused until scope arrives |
| A scoped parameter is left out, or sent as an empty list | Refused: an omitted filter would otherwise return every record |
A scoped array parameter (items carry the marker) | Every element must be in scope |
| The user holds a scoped role and an unscoped one for the same tool | The scoped role wins: the restriction applies |
Parameter has no x-mcp-inject marker | Parameter returned unchanged |
Only top-level parameters and array items are enforced on the call. A marker nested inside an object parameter narrows what the AI is shown, but is not checked.
Backend enforcement
Enum injection constrains the AI client's tool-call options at the schema layer. Your platform's backend must still enforce authorisation independently — reject requests for resources outside the user's scope with 403, regardless of what the tool schema says.
The two layers complement each other:
- Enum injection — prevents the AI from constructing an invalid call in the first place, and LockMCP refuses an out-of-scope or missing scoped value before calling your API (logged as a record scope denial).
- Backend RBAC — authoritative enforcement that remains correct even if a client ignores the schema.