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 injectionWith enum injection
AI discovers scope limits through 403 errorsAI cannot construct an out-of-scope call — enum prevents it
Wasted tokens on invalid calls and retriesFirst call is always valid — no retries needed
AI must ask the user which resource to useAI self-selects from the enum in the tool schema
Every user sees the same static tool schemasEach 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:

yaml
# 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 role

x-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 typeParameterx-mcp-inject value
Property managementproperty_idpropertyIds
Project managementproject_idprojectIds
Healthcareorganisation_idorganisationIds
E-commercestore_idstoreIds

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:

propertyIds

Multiple 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:

json
// 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:

json
{
  "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:

ApproachWho maintains itStays current?
Your IdP emits it at loginnobody — it is computed per useryes, at every login
Your backend pushes ityour application, when scope changesyes, within ~5 minutes
A workspace admin sets ita person, in the dashboardno — 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:

FieldRequiredDescription
emailNoUser identifier (informational)
accountsYesArray of accounts the user can access
accounts[].accountIdNoAccount identifier (opaque to the hub)
accounts[].rolesYesRoles the user holds within this account
accounts[].roles[].roleYesRole 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>YesArray of scoped IDs for this role (integers or strings)

Rules:

  • The enum_scopes key 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:

json
{
  "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:

ConditionBehaviour
No role of the user has an inject key for the toolMarker stripped, no enum — the parameter is unrestricted
A role has the key, and the payload has IDs for itenum: [...] 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 itenum: [] — every call is refused until scope arrives
A scoped parameter is left out, or sent as an empty listRefused: 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 toolThe scoped role wins: the restriction applies
Parameter has no x-mcp-inject markerParameter 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.