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
| Rule | Detail |
|---|---|
| Header | The hub sends X-Hub-Contract-Version: 2 on every server-to-server request. |
| Tenant behaviour | A 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. |
| Compatibility | Breaking 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 & path | HMAC signed | Caller | Purpose |
|---|---|---|---|
GET /mcp-login | No | Browser | HTML login page |
POST /api/mcp/login | No | Browser | Form POST — authenticates user, issues auth code |
POST /api/mcp/exchange | Yes | Hub | Auth code + PKCE verifier → session token |
POST /api/mcp/validate | Yes | Hub | Validate session token → TTL + roles |
POST /api/mcp/refresh | Yes | Hub | Atomically rotate session token |
POST /api/mcp/logout | Yes | Hub | Revoke session token |
GET /api/mcp/users | TENANT_DELEGATED only | Hub | Optional. Return list of tenant users — populates the LockMCP Members tab |
GET /api/mcp/roles | TENANT_DELEGATED only | Hub | Optional. 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:
| Parameter | Required | Value |
|---|---|---|
callback_url | Yes | Hub callback URL — MUST be validated against the allowlist |
state | Yes | Opaque string — echoed back unchanged |
code_challenge | Yes | PKCE S256 challenge — MUST match ^[A-Za-z0-9_-]{43}$ |
code_challenge_method | Yes | Always S256; any other value MUST be rejected |
tenant_slug | No | Tenant identifier for UI context |
login_hint | No | Email 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, malformedcode_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:
{ "code": "<auth code>", "code_verifier": "<PKCE verifier>" }Success — 200:
{
"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—codeorcode_verifierabsent401 invalid_code— code unknown, expired, already used, or PKCE verifier mismatch401— HMAC verification failed (see HMAC)
The auth code MUST be single-use: a successful exchange invalidates it.
POST /api/mcp/validate
Request:
{ "session_token": "mcp:<64 hex chars>" }Valid — 200:
{ "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):
{ "valid": false, "remaining_seconds": 0 }400 missing_fields if session_token is absent.
POST /api/mcp/refresh
Request:
{ "session_token": "mcp:<old token>" }Success — 200:
{ "session_token": "mcp:<new token>", "expires_in": 3600 }Error — 401:
{ "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:
{ "session_token": "mcp:<token>" }Response — 200:
{ "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:
[
{ "email": "alice@example.com", "name": "Alice Nguyen" },
{ "email": "bob@example.com", "displayName": "Bob Smith" }
]Also accepted — object wrapper:
{ "users": [ { "email": "alice@example.com", "name": "Alice Nguyen" } ] }Fields per user:
| Field | Required | Description |
|---|---|---|
email | Yes | User's email address |
name | No | Display 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:
[
{ "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:
{ "roles": [ { "name": "admin", "description": "Full access" } ] }Fields per role:
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable role name — used to derive the LockMCP role slug |
description | No | Optional description shown in the Roles tab |
toolScopes | No | Array 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).
| Aspect | Detail |
|---|---|
| Identifier | Each 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. |
| Type | JSON array of strings. An empty array [] or an omitted field both mean "no tool-level restriction declared". |
| Compatibility | Optional 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. |
| Consumption | The 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:
{
"email": "user@example.com",
"accounts": [
{
"accountId": 456,
"roles": [
{ "role": "LEASING_AGENT", "propertyIds": [101, 102] },
{ "role": "ACCOUNTANT", "propertyIds": [101] }
]
}
]
}| Identity model | How the payload reaches the hub |
|---|---|
TENANT_DELEGATED | Add 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_BROKERING | Carried 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. |
| Rule | Detail |
|---|---|
| Missing/omitted | Tools 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. |
| Updates | A 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. |
| Size | Not limited; keep it small. The IDs become the list of allowed values on each scoped tool in the AI client's tool listing. |
| Multi-account | Include one entry per account in accounts[]. Hub unions IDs for the same role across accounts. |
| Role names | Use 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:
schema:
type: integer
x-mcp-inject: "propertyIds" # relay looks up this key in the bootstrap payloadThe 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
| Scenario | Behaviour |
|---|---|
| Single role | enum = that role's IDs for the given key |
| Multiple roles, different tools | Each tool gets the enum for its owning role — no cross-role leakage |
| Multiple roles, shared tool | enum = union of all applicable roles' IDs |
| Same role across multiple accounts | enum = union across accounts |
| Role present, no IDs | enum: [] — 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
| Header | Value |
|---|---|
X-Hub-Timestamp | Unix epoch seconds when the request was signed |
X-Hub-Nonce | Random per-request string |
X-Hub-Signature-256 | sha256=<hex HMAC-SHA256> |
String to sign
{METHOD}\n{FULL_URL}\n{SHA256(body)}\n{timestamp}\n{nonce}METHOD— uppercase HTTP method (alwaysPOST)FULL_URL— the complete request URL as received by the tenant, including query stringSHA256(body)— lowercase hex-encoded SHA-256 of the raw request bodytimestamp— theX-Hub-Timestampvalue, verbatimnonce— theX-Hub-Noncevalue, verbatim
Rules
| Rule | Requirement |
|---|---|
| Key decoding | The 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 window | Requests whose X-Hub-Timestamp differs from the tenant's current time by more than 300 seconds MUST be rejected with 401. |
| Comparison | Signatures MUST be compared with a constant-time function. == / .equals() MUST NOT be used. |
| Missing headers | Any missing HMAC header → 401. |
| Failure status | Any HMAC failure (missing header, bad timestamp, signature mismatch) → 401, before any business logic runs. |
Token & code formats
| Artifact | Format | Notes |
|---|---|---|
| Session token | mcp: + 64 lowercase hex chars | 32 random bytes, hex-encoded, mcp: prefix. Total length 68. The prefix is mandatory and lets the tenant distinguish these from its own tokens. |
| Auth code | base64url, 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 verification | BASE64URL(SHA256(code_verifier)) == code_challenge | Computed at exchange time against the challenge stored at login time. |
TTLs
| Setting | Canonical default | Configurable via |
|---|---|---|
| Session token TTL | 3600 seconds | MCP_CONTRACT_SESSION_TTL_SEC |
| Auth code TTL | 60 seconds | MCP_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 variable | Required | Description |
|---|---|---|
MCP_CONTRACT_HMAC_KEY | Yes | 64-char hex shared secret from the hub admin panel. MUST come from the environment — never a committed default. |
MCP_CONTRACT_SESSION_TTL_SEC | No (default 3600) | Session token lifetime |
MCP_CONTRACT_AUTH_CODE_TTL_SEC | No (default 60) | Auth code lifetime |
MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS | Yes | Comma-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.jsonreturningapplication/jsonover HTTPS with no redirect:json{ "token": "mcp-hub-verify=<token>" }
Error vocabulary
error value | Status | Meaning |
|---|---|---|
missing_fields | 400 | A required field is absent from the request body |
invalid_code | 401 | Auth code unknown, expired, used, or PKCE mismatch |
session_expired | 401 | Session token unknown or expired (refresh only) |
unsupported_contract_version | 400 | X-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:
- No-credential checks — bad/missing HMAC →
401; out-of-window timestamp →401; missing body fields →400; unknown token tovalidate→200 {"valid": false}; non-allowlistedcallback_urlon/mcp-login→ error page; all response shapes match this page. - 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.