Contract Specification

This page is the normative source of truth for the Tenant Delegated integration contract. The other documentation pages (Endpoints, HMAC Signing, Session Tokens, Login Page) are how-to guides with per-language examples — where they differ from this page, this page wins.

If your connection uses Identity Brokering or Token Exchange, none of this HMAC login/exchange/validate contract applies to you — those models need no auth endpoints on your side. But if their tools call your own API (the common case), that API still receives the forwarded LockMCP token on every call and must verify it independently — see Verifying the forwarded token in your own API.

Contract version: 2. Every hub-to-tenant request carries an X-Hub-Contract-Version header. A tenant implementation built against this page implements contract version 2.

Versioning

RuleDetail
HeaderThe hub sends X-Hub-Contract-Version: 2 on every server-to-server request.
Tenant behaviourA tenant MAY reject a request whose X-Hub-Contract-Version it does not recognise with 400 unsupported_contract_version. A tenant MUST NOT silently treat an unknown version as version 1.
CompatibilityBreaking changes to any schema, the HMAC formula, or token formats increment the version. Additive, backward-compatible changes do not.

Changes in v2

Version 2 adds the optional, additive toolScopes field to the POST /api/mcp/exchange, POST /api/mcp/validate, and GET /api/mcp/roles responses. The field is backward-compatible: a v1 tenant that omits it remains valid. See Tool scopes.

Endpoint contract

The tenant application MUST expose these six routes at its registered base URL over HTTPS. Two additional optional endpoints MAY be implemented to enable LockMCP to auto-populate the member and role directories.

Method & pathHMAC signedCallerPurpose
GET /mcp-loginNoBrowserHTML login page
POST /api/mcp/loginNoBrowserForm POST — authenticates user, issues auth code
POST /api/mcp/exchangeYesHubAuth code + PKCE verifier → session token
POST /api/mcp/validateYesHubValidate session token → TTL + roles
POST /api/mcp/refreshYesHubAtomically rotate session token
POST /api/mcp/logoutYesHubRevoke session token
GET /api/mcp/usersTENANT_DELEGATED onlyHubOptional. Return list of tenant users — populates the LockMCP Members tab
GET /api/mcp/rolesTENANT_DELEGATED onlyHubOptional. Return list of tenant roles — populates the LockMCP Roles tab

All HMAC-signed requests use Content-Type: application/json. POST /api/mcp/login uses application/x-www-form-urlencoded.

Request & response schemas

GET /mcp-login

Query parameters appended by the hub:

ParameterRequiredValue
callback_urlYesHub callback URL — MUST be validated against the allowlist
stateYesOpaque string — echoed back unchanged
code_challengeYesPKCE S256 challenge — MUST match ^[A-Za-z0-9_-]{43}$
code_challenge_methodYesAlways S256; any other value MUST be rejected
tenant_slugNoTenant identifier for UI context
login_hintNoEmail address to pre-fill the form

Returns 200 with an HTML form. If callback_url is not allowlisted or code_challenge_method is not S256, the tenant MUST return an error page and MUST NOT render a usable form.

POST /api/mcp/login

Form fields: email, password, callback_url, state, code_challenge (code_challenge_method and tenant_slug MAY also be present).

  • Success: 302 → {callback_url}?state={state}&code={code}
  • Failure (bad credentials, disallowed callback_url, malformed code_challenge): 302 → {callback_url}?state={state}&error=access_denied

The tenant MUST validate callback_url against the allowlist before redirecting. If callback_url itself is not allowlisted, the tenant MUST render an error page rather than redirect.

POST /api/mcp/exchange

Request:

json
{ "code": "<auth code>", "code_verifier": "<PKCE verifier>" }

Success — 200:

json
{
  "user_id": "user-uuid",
  "email": "user@example.com",
  "session_token": "mcp:<64 hex chars>",
  "expires_in": 3600,
  "roles": ["admin"],
  "display_name": "Jane Doe"
}

Errors:

  • 400 missing_fields — code or code_verifier absent
  • 401 invalid_code — code unknown, expired, already used, or PKCE verifier mismatch
  • 401 — HMAC verification failed (see HMAC)

The auth code MUST be single-use: a successful exchange invalidates it.

POST /api/mcp/validate

Request:

json
{ "session_token": "mcp:<64 hex chars>" }

Valid — 200:

json
{ "valid": true, "remaining_seconds": 3542, "roles": ["admin"], "toolScopes": ["createCourse"] }

toolScopes is optional (contract 2+) — same semantics as in exchange. See Tool scopes.

Invalid or expired — 200 (NOT an error status):

json
{ "valid": false, "remaining_seconds": 0 }

400 missing_fields if session_token is absent.

POST /api/mcp/refresh

Request:

json
{ "session_token": "mcp:<old token>" }

Success — 200:

json
{ "session_token": "mcp:<new token>", "expires_in": 3600 }

Error — 401:

json
{ "error": "session_expired" }

Rotation MUST be atomic: the old token is deleted and the new token created in a single transaction, so the two never coexist. 400 missing_fields if session_token is absent.

POST /api/mcp/logout

Request:

json
{ "session_token": "mcp:<token>" }

Response — 200:

json
{ "ok": true }

Logout MUST be idempotent: revoking an unknown or already-revoked token still returns 200 {"ok": true}. 400 missing_fields if session_token is absent.

GET /api/mcp/users

Optional. Configure a path (e.g. /api/users) as the userListEndpoint on the LockMCP connection. The hub calls {baseUrl}{userListEndpoint} server-to-server to sync the tenant user list into the LockMCP member directory. Synced users appear in the tenant admin's Members tab with source badge Auto.

When identityModel = TENANT_DELEGATED, the hub signs the request with the tenant's HMAC key — the same X-Hub-Timestamp, X-Hub-Nonce, and X-Hub-Signature-256 headers as the other server-to-server endpoints, with an empty-string body hash (SHA256("")). For other identity models the request is unauthenticated — protect with IP allowlisting if the data is sensitive.

Response — 200 — top-level array:

json
[
  { "email": "alice@example.com", "name": "Alice Nguyen" },
  { "email": "bob@example.com", "displayName": "Bob Smith" }
]

Also accepted — object wrapper:

json
{ "users": [ { "email": "alice@example.com", "name": "Alice Nguyen" } ] }

Fields per user:

FieldRequiredDescription
emailYesUser's email address
nameNoDisplay name — also accepted as displayName

Responses with more than 500 entries are ignored. Users already present in LockMCP are skipped (idempotent).

GET /api/mcp/roles

Optional. Configure a path (e.g. /api/roles) as the roleListEndpoint on the LockMCP connection. The hub calls {baseUrl}{roleListEndpoint} server-to-server to sync the tenant role list into the LockMCP Roles tab.

Same transport rules as userListEndpoint — HMAC-signed for TENANT_DELEGATED, unauthenticated for other identity models.

Response — 200 — top-level array:

json
[
  { "name": "admin",      "description": "Full access to all features",       "toolScopes": ["createCourse", "deleteCourse"] },
  { "name": "instructor", "description": "Can manage courses and students",   "toolScopes": ["createCourse"] },
  { "name": "student",    "description": "Can enrol and view courses",        "toolScopes": [] }
]

Also accepted — object wrapper:

json
{ "roles": [ { "name": "admin", "description": "Full access" } ] }

Fields per role:

FieldRequiredDescription
nameYesHuman-readable role name — used to derive the LockMCP role slug
descriptionNoOptional description shown in the Roles tab
toolScopesNoArray of OpenAPI operationIds this role may invoke (contract 2+). Used for tool-level access control. See Tool scopes.

Responses with more than 200 entries are ignored. Roles already present (by slug) are skipped (idempotent).

Tool scopes (v2)

Contract version 2 introduces an optional toolScopes field that lets a tenant declare, per role, exactly which tools a user may invoke. It appears in three responses: POST /api/mcp/exchange, POST /api/mcp/validate (the union of scopes across the user's roles), and GET /api/mcp/roles (the scopes for each individual role).

AspectDetail
IdentifierEach entry is an OpenAPI operationId from the tenant's uploaded spec (e.g. createCourse). operationId is the stable tool key — it survives spec re-imports, unlike hub-generated tool IDs.
TypeJSON array of strings. An empty array [] or an omitted field both mean "no tool-level restriction declared".
CompatibilityOptional and additive. A tenant that never sends toolScopes is fully conformant — the field defaults to absent and the hub falls back to role-level access.
ConsumptionThe hub records declared scopes during role sync. Tool-level enforcement based on toolScopes is applied where supported; tenants should treat the field as the source of truth for which operationIds a role grants.

Enum injection (optional)

Enum injection is an opt-in capability that lets the hub dynamically constrain tool parameter values to the specific resource IDs the current user may access. See the Enum Injection guide for the full how-to.

Bootstrap payload

There is no separate bootstrap endpoint or registered URL to call out to — the payload is embedded directly in responses your server already returns, and the hub stores it on the session record:

json
{
  "email": "user@example.com",
  "accounts": [
    {
      "accountId": 456,
      "roles": [
        { "role": "LEASING_AGENT", "propertyIds": [101, 102] },
        { "role": "ACCOUNTANT",    "propertyIds": [101] }
      ]
    }
  ]
}
Identity modelHow the payload reaches the hub
TENANT_DELEGATEDAdd an enum_scopes key with this shape to your POST /api/mcp/exchange and POST /api/mcp/validate responses (and optionally /refresh). The hub stores it on the session: from the exchange at login, and each validate or refresh that includes it replaces it. Returning it from the exchange means the user's first tool listing already shows their allowed values.
TOKEN_EXCHANGE / IDENTITY_BROKERINGCarried as an x-mcp-bootstrap claim on the access token. Your identity provider emits it, Keycloak maps it through, and the hub reads it from the verified token. You can also set it per member from the dashboard, or push it from your backend — see Record Scope.
RuleDetail
Missing/omittedTools without a scoped role are unaffected. A tool whose scoped parameter a role of the user owns gets an empty list, and every call to it is refused, until a payload arrives. See Enum Injection.
UpdatesA validate or refresh response without enum_scopes keeps the stored payload; one with it replaces it. To withdraw every id, send the role with an empty list.
SizeNot limited; keep it small. The IDs become the list of allowed values on each scoped tool in the AI client's tool listing.
Multi-accountInclude one entry per account in accounts[]. Hub unions IDs for the same role across accounts.
Role namesUse the same role names your exchange and validate return in roles. Through the connection's role mapping they match the LockMCP roles they map to; a LockMCP role name also works. A name neither mapped nor a LockMCP role the user holds matches nothing.

x-mcp-inject OpenAPI marker

Mark any parameter schema in your OpenAPI file that should receive an enum at session time:

yaml
schema:
  type: integer
  x-mcp-inject: "propertyIds"   # relay looks up this key in the bootstrap payload

The value must exactly match a key returned in accounts[].roles[] of the bootstrap payload. The marker is stripped before the schema reaches any MCP client — it is a relay-internal directive.

Injection rules

ScenarioBehaviour
Single roleenum = that role's IDs for the given key
Multiple roles, different toolsEach tool gets the enum for its owning role — no cross-role leakage
Multiple roles, shared toolenum = union of all applicable roles' IDs
Same role across multiple accountsenum = union across accounts
Role present, no IDsenum: [] — tool is structurally uncallable for this user

HMAC signing

All four server-to-server endpoints MUST verify an HMAC-SHA256 signature on every request.

Headers

HeaderValue
X-Hub-TimestampUnix epoch seconds when the request was signed
X-Hub-NonceRandom per-request string
X-Hub-Signature-256sha256=<hex HMAC-SHA256>

String to sign

{METHOD}\n{FULL_URL}\n{SHA256(body)}\n{timestamp}\n{nonce}
  • METHOD — uppercase HTTP method (always POST)
  • FULL_URL — the complete request URL as received by the tenant, including query string
  • SHA256(body) — lowercase hex-encoded SHA-256 of the raw request body
  • timestamp — the X-Hub-Timestamp value, verbatim
  • nonce — the X-Hub-Nonce value, verbatim

Rules

RuleRequirement
Key decodingThe HMAC key is a 64-character hex string. It MUST be decoded to 32 raw bytes before use. The hex string MUST NOT be used directly as the key.
Replay windowRequests whose X-Hub-Timestamp differs from the tenant's current time by more than 300 seconds MUST be rejected with 401.
ComparisonSignatures MUST be compared with a constant-time function. == / .equals() MUST NOT be used.
Missing headersAny missing HMAC header → 401.
Failure statusAny HMAC failure (missing header, bad timestamp, signature mismatch) → 401, before any business logic runs.

Token & code formats

ArtifactFormatNotes
Session tokenmcp: + 64 lowercase hex chars32 random bytes, hex-encoded, mcp: prefix. Total length 68. The prefix is mandatory and lets the tenant distinguish these from its own tokens.
Auth codebase64url, unpadded, of 32 random bytes (~43 chars)Single-use. Opaque to the hub.
PKCE challenge^[A-Za-z0-9_-]{43}$Unpadded base64url of SHA-256(verifier). Method is always S256.
PKCE verificationBASE64URL(SHA256(code_verifier)) == code_challengeComputed at exchange time against the challenge stored at login time.

TTLs

SettingCanonical defaultConfigurable via
Session token TTL3600 secondsMCP_CONTRACT_SESSION_TTL_SEC
Auth code TTL60 secondsMCP_CONTRACT_AUTH_CODE_TTL_SEC

The expires_in returned by exchange and refresh MUST equal the tenant's configured session TTL. Auth codes MUST expire no later than their TTL and are additionally consumed on first use.

Configuration

Environment variableRequiredDescription
MCP_CONTRACT_HMAC_KEYYes64-char hex shared secret from the hub admin panel. MUST come from the environment — never a committed default.
MCP_CONTRACT_SESSION_TTL_SECNo (default 3600)Session token lifetime
MCP_CONTRACT_AUTH_CODE_TTL_SECNo (default 60)Auth code lifetime
MCP_CONTRACT_ALLOWED_CALLBACK_HOSTSYesComma-separated allowlist of hub callback hosts

A tenant MUST refuse to start in a non-development profile if MCP_CONTRACT_HMAC_KEY is absent or set to a placeholder value. The HMAC key MUST NOT appear in version control.

Domain verification

Before activation the tenant MUST prove domain ownership via one of:

  • DNS TXT — _mcp-verify.<domain> TXT "mcp-hub-verify=<token>"
  • Well-known file — GET https://<base-url>/.well-known/mcp-hub-verification.json returning application/json over HTTPS with no redirect:
    json
    { "token": "mcp-hub-verify=<token>" }

Error vocabulary

error valueStatusMeaning
missing_fields400A required field is absent from the request body
invalid_code401Auth code unknown, expired, used, or PKCE mismatch
session_expired401Session token unknown or expired (refresh only)
unsupported_contract_version400X-Hub-Contract-Version not recognised
access_denied—Login failure — surfaced as a redirect query param, not a JSON body

Conformance

A tenant implementation is conformant when it passes the hub's automated conformance check, run by the hub administrator during onboarding as a gate before the tenant is activated. The check exercises:

  1. No-credential checks — bad/missing HMAC → 401; out-of-window timestamp → 401; missing body fields → 400; unknown token to validate → 200 {"valid": false}; non-allowlisted callback_url on /mcp-login → error page; all response shapes match this page.
  2. One credentialed happy-path — a tenant-supplied test account drives the full flow: login → exchange → validate → refresh → logout, asserting token format, expires_in, and role resolution.

The same assertions are available to tenant developers as a local self-test to run before handing endpoints to the hub administrator.