Demo apps on LockMCP: an end-to-end guide

This guide takes you from git clone to an AI client calling MCP tools through LockMCP, for three public demo applications, one per identity model:

Identity model in LockMCPDemo appRepository
Tenant Delegation (TENANT_DELEGATED)JHipster banking samplehttps://github.com/aircwou/jhipster-mcp-demo
Identity Brokering (IDENTITY_BROKERING)Real-estate listings (Express + Dex)https://github.com/aircwou/realestate-demo-oidc-mcp
Token Exchange (TOKEN_EXCHANGE, OIDC)Point of sale (Express + Dex)https://github.com/aircwou/pos-demo-mcp-ib

Each demo section stands on its own. Pick one, then follow it from start to finish:

Clone → configure → run → make it public → configure identity → configure LockMCP → users and roles → connect an AI client → call tools → verify authorization.

Naming. The demo repositories call the hub MCPHub. That is LockMCP's former name. Every "MCPHub" in those READMEs, code comments and log lines means LockMCP.

Accuracy. Everything here was checked against the repositories' main branches and the LockMCP source as of 2026-10-01. Where a demo's code and its labels disagree, this guide follows the code and says so. Hostnames assume the production LockMCP service: dashboard https://lockmcp.ai, sign-in (Keycloak) https://auth.lockmcp.ai, MCP endpoints https://mcp.lockmcp.ai. Always copy your MCP URL from the dashboard rather than typing it.

1. Introduction

What LockMCP does in these demos

Each demo is an ordinary web application with an HTTP API. It knows nothing about the Model Context Protocol (MCP). LockMCP sits between an AI client (Claude, ChatGPT, MCP Inspector) and the app:

  1. Turns the API into MCP tools. You upload the app's OpenAPI file. Every operation becomes a tool named <connection>_<operation_id>, for example pos_list_products.
  2. Gives each app its own MCP endpoint, for example https://mcp.lockmcp.ai/<tenant>/<connection>/mcp.
  3. Authenticates the person behind the AI client. How it does this is the identity model, described below.
  4. Authorizes per tool. A user only sees and can only call the tools that one of their roles on that connection grants. Tools with no role grant are hidden from everyone.
  5. Calls the app on the user's behalf, forwarding a credential the app can check. The app then applies its own rules as well.

There are two layers of authorization, and the demos are built to show both. LockMCP decides which tools you may call. The app decides what that call may do.

Terms used in this guide

TermMeaning
Tenant (workspace)Your organisation in LockMCP. It has a slug, shown in your MCP URLs.
ConnectionOne app registered in LockMCP. It has its own slug, Base URL, identity model, tools and roles.
Identity modelHow users of a connection sign in, and which credential LockMCP forwards to the app.
Connection roleA LockMCP role that belongs to one connection and grants tools on it.
Hub accountA user's LockMCP sign-in, held in LockMCP's Keycloak (auth.lockmcp.ai).

The three identity models

Tenant DelegationIdentity BrokeringToken Exchange
Who shows the login pageYour appLockMCP (Keycloak)Your OIDC provider, federated into LockMCP's Keycloak
Account the user signs in withTheir account in your appTheir LockMCP hub accountTheir account at your IdP, linked to an invited hub account
Credential forwarded to your APIYour app's own session tokenThe LockMCP (Keycloak) access tokenThe LockMCP token, or, when the hub's broker flag is on, your IdP's own token
How your API checks itIts own session storeCalls LockMCP's /oauth/verify, or validates the Keycloak JWTYour IdP (e.g. its /userinfo)
Signing between hub and appHMAC-SHA256 on every back-channel callnonenone

The model names can mislead. In LockMCP, Identity Brokering does not federate an external identity provider; users sign in with hub accounts. Token Exchange does not perform an RFC 8693 token exchange. It federates your IdP into LockMCP's Keycloak and, when enabled, forwards the token your IdP issued.

When to use each:

  • Tenant Delegation: your app already owns its users, passwords and sessions, and you don't want to run an OIDC provider. Your app implements a small signed contract (login, exchange, validate). Example: the JHipster demo.
  • Identity Brokering: the simplest option. Users get LockMCP accounts by invitation, and your API trusts LockMCP's answer about who is calling. Example: the real-estate demo.
  • Token Exchange: users must sign in with your organisation's own IdP (OIDC or SAML), and your API only accepts that IdP's tokens. Example: the POS demo with Dex.

Architecture per demo

Tenant Delegation: JHipster

text
User + AI client          LockMCP                          JHipster app (https://<app-host>)
      |-- connect /<tenant>/jhipster/mcp -->|
      |<-- redirect to Login URL -----------|   /mcp-login?callback_url&state&code_challenge
      |-- sign in (admin / admin) ------------------------------------------------>|
      |<-- 302 lockmcp.ai/api/auth/delegated-callback?code&state ------------------|
      |-- callback ------------------------>|
      |                                     |-- POST /api/mcp/exchange (HMAC) ---->|
      |                                     |<-- {session_token "mcp:...", roles} -|
      |<-- LockMCP token -------------------|
      |-- tools/call jhipster_get_all_bank_accounts -->|
      |                                     |-- POST /api/mcp/validate (HMAC) ---->|
      |                                     |-- GET /api/bank-accounts             |
      |                                     |   Authorization: Bearer mcp:... ---->|
      |<-- tool result ---------------------|<-- JSON (app enforces its roles) ----|

Identity Brokering: real estate

text
User + AI client      LockMCP sign-in          LockMCP MCP endpoint        Real-estate app
                      (auth.lockmcp.ai)        (mcp.lockmcp.ai)
      |-- connect /<tenant>/realestate/mcp ---------->|
      |<-- redirect to LockMCP sign-in ---------------|
      |-- email + LockMCP password -->|
      |<-- LockMCP token -------------|
      |-- tools/call realestate_list_my_saved_listings ->|
      |                                               |-- GET /saved-listings ------->|
      |                                               |   Bearer <LockMCP token>      |
      |                                               |<-- GET /<tenant>/realestate/oauth/verify
      |                                               |-- {authorized: true, email} ->|
      |<-- that email's saved listings ---------------|<-- JSON ----------------------|

The app's own browser login (app → Dex → GitHub → Dex → app) is a separate path. It is not part of the LockMCP sign-in. The two identities meet on email: a GitHub user and a LockMCP user with the same email address see the same saved listings.

Token Exchange: POS

text
User + AI client    LockMCP Keycloak     Your Dex                 LockMCP MCP endpoint    POS app
                    (auth.lockmcp.ai)    (https://<dex-host>/dex)
      |-- connect /<tenant>/pos/mcp ------------------------------------->|
      |<-- redirect to Keycloak, which goes straight to Dex --------------|
      |-- sign in at Dex (e.g. manager@example.com / password) -->|
      |                      |<-- code, then Dex tokens (stored) -|
      |<-- LockMCP token (linked to the invited hub account) -----|
      |-- tools/call pos_get_sales_report ------------------------------->|
      |                      |<-- fetch stored Dex token (broker flag on) |
      |                                                                   |-- GET /reports/summary -->|
      |                                                                   |   Bearer <Dex token>      |
      |                                          |<-- GET /dex/userinfo -------------------------------|
      |                                          |-- {email} ---------------------------------------->|
      |<-- result: role from POS users table, 200 or 403 -----------------|<--------------------------|

2. Prerequisites

Everyone

Tool / accountWhy
GitClone the demos
Docker with Compose v2 (docker compose)Databases, Dex and (optionally) the apps
cloudflaredPublish a local app over HTTPS
A domain whose DNS is managed by CloudflareNamed tunnels need it, and a stable hostname is required (see below)
A LockMCP account at https://lockmcp.aiSign up at /signup, or ask your LockMCP admin for an invitation. You need permission to manage connections and members (the workspace Admin role has it).
An MCP clientClaude (desktop or claude.ai), ChatGPT (developer mode), or MCP Inspector

Why localhost and quick tunnels are not enough. LockMCP refuses any Base URL that isn't https://. Domain verification refuses hosts that resolve to private or loopback addresses. Tenant Delegation certification requires HTTPS on every endpoint. A Cloudflare quick tunnel (*.trycloudflare.com) is HTTPS, but its hostname changes every restart, and you cannot add DNS records to it. Use a named tunnel on your own domain, or a hosted service such as Render.

Install cloudflared:

bash
# Windows (PowerShell)
winget install --id Cloudflare.cloudflared

# macOS
brew install cloudflared

# Debian / Ubuntu (amd64)
curl -L -o cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb

cloudflared --version

Per demo

Install only what the demo you chose needs.

JHipster (Tenant Delegation)Real estate (Identity Brokering)POS (Token Exchange)
Runtime on your machineJDK 21 (only to run outside Docker; the Maven wrapper is included)Node.js 18+ (only to run render-dex-config.js)Node.js 20.6+ (node --env-file)
DatabaseH2 in memory (dev) or PostgreSQL (Docker / Render)PostgreSQL 16 (in its Compose file)PostgreSQL 16 (docker run) or a hosted Postgres
Identity providernone, the app is its ownDex (in its Compose file) + a GitHub OAuth App for the app's own browser loginDex (docker run), with a static test user or a GitHub connector
Public hostnames1 (the app)2 (the app and Dex)2 (the app and Dex)
OptionalRender account——

Your values worksheet

Fill this in once. The rest of the guide refers to these names.

Name in this guideWhat it isExample
<tenant>Your LockMCP workspace slug. It appears in the MCP URLs on the Connect page.acme
<conn>The connection slug. It is derived from the connection Name, so use the names below.jhipster, realestate, pos
<your-domain>Your Cloudflare-managed domainexample.com
<app-host>The app's public hostnamejhipster.example.com
<dex-host>Dex's public hostname (real estate and POS only)dex.example.com

Name your connections exactly jhipster, realestate and pos. The connection name sets both the slug (/<tenant>/pos/mcp) and the tool-name prefix (pos_list_products). The examples in this guide assume those names.


3. Tenant Delegation: JHipster demo

Repository: https://github.com/aircwou/jhipster-mcp-demo. A JHipster 9 app (Spring Boot 4 + Angular) that implements LockMCP's Tenant Delegation contract. LockMCP sends users to the app's own login page, then exchanges and validates sessions through HMAC-signed calls.

3.1 What the app provides

Endpoint (on https://<app-host>)MethodPurposeSigned?
/mcp-login (dash, not /mcp/login)GET (browser)Login page LockMCP redirects users tono
/api/mcp/loginPOST (form)The login form's target; redirects back to LockMCP with code + stateno
/api/mcp/exchangePOST{code, code_verifier} → {user_id, email, session_token, expires_in, roles, toolScopes, display_name}HMAC
/api/mcp/validatePOST{session_token} → {valid, remaining_seconds, roles, toolScopes}HMAC
/api/mcp/refreshPOST{session_token} → {session_token, expires_in} (rotates the token)HMAC
/api/mcp/logoutPOST{session_token} → {ok: true}HMAC
/api/mcp/usersGETUsers directory: [{email, name}]HMAC
/api/mcp/rolesGETRoles directory: [{name, toolScopes}]no
/.well-known/mcp-hub-verification.jsonGET{"token": "<your token>"} for domain verificationno
/management/healthGETHealth checkno

HMAC details (from HmacVerificationService.java and LockMCP's delegated-endpoint-certification.ts). These must agree:

  • Headers: X-Hub-Timestamp (epoch seconds), X-Hub-Nonce, X-Hub-Signature-256: sha256=<hex>, X-Hub-Contract-Version: 2.
  • Signed string: METHOD\nFULL_URL\nhex(sha256(body))\ntimestamp\nnonce.
  • Key: the 64-hex-character key decoded to 32 bytes.
  • The app accepts timestamps within ±300 seconds of its own clock.
  • The app rebuilds FULL_URL from X-Forwarded-Proto / X-Forwarded-Host. The URL you register in LockMCP must therefore match the public URL exactly: scheme, host (www. or not) and path.

Tool calls reach the app's normal REST API with Authorization: Bearer mcp:<64 hex>. That is the session token from exchange, which the app checks against its mcp_session table.

3.2 Users, roles and tool scopes in the app

Sample accounts, created in every profile:

LoginPasswordEmailRoles
adminadminadmin@localhostROLE_ADMIN, ROLE_USER
useruseruser@localhostROLE_USER

Tool scopes are declared in src/main/resources/config/application.yml under mcp.contract.tool-scopes. They are keyed by role, and each entry is an operationId from mcp-openapi.yaml:

RoleIn the database?Scopes (operationIds)
ROLE_ADMINyesall 31: getAccount, saveAccount, changePassword, getAllUsers, createUser, updateUser, getUser, deleteUser, getAllPublicUsers, getAllBankAccounts, createBankAccount, getBankAccount, updateBankAccount, partialUpdateBankAccount, deleteBankAccount, getAllLabels, createLabel, getLabel, updateLabel, partialUpdateLabel, deleteLabel, getAllOperations, createOperation, getOperation, updateOperation, partialUpdateOperation, deleteOperation, getAllAuthorities, createAuthority, getAuthority, deleteAuthority
ROLE_USERyesgetAccount, saveAccount, changePassword, getAllUsers, createUser, updateUser, getUser, deleteUser
ROLE_MANAGERno, create it first (see 3.10)getAccount, saveAccount, changePassword, getAllUsers
  • A user's toolScopes is the union of their roles' scopes, so admin gets ROLE_ADMIN + ROLE_USER.
  • /api/mcp/roles reports only roles that exist in the database.
  • Scopes are not permissions. The app still enforces its own rules. getAllUsers, createUser, updateUser, getUser and deleteUser call /api/admin/users, which requires ROLE_ADMIN. A ROLE_USER session is offered those tools but gets 403 Forbidden when it calls them. That is useful for testing (see section 8).
  • ROLE_USER is given no bank-account, label or operation scopes. LockMCP will not show user those tools, even though the app itself would let user read them.

3.3 Clone

Run on your machine (the host), in any terminal:

bash
git clone https://github.com/aircwou/jhipster-mcp-demo.git
cd jhipster-mcp-demo

Windows: the repo's commands are bash. In Git Bash they work as written. In PowerShell, use .\mvnw.cmd for ./mvnw, .\npmw.cmd for ./npmw, and $env:NAME = "value" for export NAME=value.

3.4 Run it locally first (optional sanity check)

This uses the dev profile: H2 in memory, port 8085. Data, including MCP sessions, is lost on restart.

bash
# host
CYPRESS_INSTALL_BINARY=0 ./mvnw spring-boot:run -Pdev,webapp -ntp
powershell
# host, PowerShell
$env:CYPRESS_INSTALL_BINARY = "0"; .\mvnw.cmd spring-boot:run "-Pdev,webapp" -ntp

Open http://localhost:8085 and sign in as admin / admin. If you see the JHipster home page signed in, the app works. Stop it with Ctrl+C.

Don't use dev for LockMCP. In dev, the file src/main/resources/config/application-secret-samples.yml overrides the MCP_CONTRACT_* environment variables, and it contains sample keys that are published on GitHub. For the LockMCP integration, use the prod profile (Docker Compose, the jar, or Render) below, where the values come from your environment. If you must use dev, edit that file and put your own key in it.

Other local options from the repo:

OptionCommandURL
Live reload (two terminals)./npmw run backend:start and ./npmw starthttp://localhost:9000
prod profile as a jarsee 3.6 Bhttp://localhost:8080
Docker Compose (prod + PostgreSQL)see 3.6 Ahttp://localhost:8080

3.5 Create the LockMCP connection (to get the HMAC key)

The prod profile refuses to start without a valid HMAC key, and LockMCP generates that key. So create the connection first, then start the app. Decide your public hostname now (for example jhipster.example.com). The tunnel can be created in 3.7; the hostname just has to be fixed.

  1. Sign in at https://lockmcp.ai. Open Connections, then Add Connection:

    FieldValue
    Namejhipster
    Base URLhttps://<app-host> (no trailing slash)
    Identity ModelTenant Delegation
    User List Endpointleave empty (see the note below)
    Role List Endpoint/api/mcp/roles

    Click Create Connection. The two list endpoints are paths relative to the Base URL.

  2. Select the new connection, then Continue setup. This opens the onboarding wizard (Identity & access → Domain verification → Identity setup → Provision → Activate).

  3. Step 3 · Identity setup: click Generate HMAC key. Copy the 64-character hex value now; it is shown once. This is MCP_CONTRACT_HMAC_KEY.

  4. Step 2 · Domain verification: choose Well-known file. Copy the token value from File contents. This is MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN. Don't click Check now yet; the app isn't reachable.

Why not the User List Endpoint? LockMCP can import users from /api/mcp/users, but that request is currently signed with your workspace-level delegation key, not this connection's key. The app's HMAC filter then answers 401, and nothing is imported. You don't need it: Tenant Delegation users are created automatically on their first sign-in.

3.6 Run it with the prod profile

You need two secrets in addition to the two values from LockMCP:

bash
# host (Git Bash, macOS, Linux)
openssl rand -base64 64 | tr -d '\n'   # JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET

MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS lists the hosts the app may redirect a login back to. LockMCP's callback is https://lockmcp.ai/api/auth/delegated-callback, so use lockmcp.ai,www.lockmcp.ai. Host names only, with no https:// and no path.

A. Docker Compose (recommended)

src/main/docker/app.yml runs the app (prod profile, plus api-docs) and PostgreSQL 18.3. Both ports are bound to 127.0.0.1.

bash
# host, repo root
cp .env.example .env

Edit .env:

dotenv
JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET=<output of openssl rand -base64 64>
MCP_CONTRACT_HMAC_KEY=<64-hex key from LockMCP, step 3.5.3>
MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS=lockmcp.ai,www.lockmcp.ai
MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN=<token from LockMCP, step 3.5.4>

Build the image, then start it. --env-file .env is required, because the compose file lives in another folder:

bash
# host, repo root
docker build -t jhipstersampleapplication .
docker compose --env-file .env -f src/main/docker/app.yml up -d --wait

--wait returns once /management/health passes. The first start takes about 30–60 seconds while Liquibase creates the schema.

bash
# host
curl http://localhost:8080/management/health        # expect {"status":"UP",...}
docker compose --env-file .env -f src/main/docker/app.yml logs -f app

In the logs, expect MCPHub contract HMAC key validated for non-development profile.

To stop: docker compose --env-file .env -f src/main/docker/app.yml down. Add -v to also delete the database.

B. The jar, on your machine

bash
# host
./npmw run docker:db:up                      # PostgreSQL in Docker on 127.0.0.1:5432
./mvnw -Pprod -DskipTests -ntp clean verify  # builds target/*.jar

export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/jhipsterSampleApplication
export SPRING_DATASOURCE_USERNAME=jhipsterSampleApplication
export JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET="$(openssl rand -base64 64 | tr -d '\n')"
export MCP_CONTRACT_HMAC_KEY=<64-hex key from LockMCP>
export MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS=lockmcp.ai,www.lockmcp.ai
export MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN=<token from LockMCP>

java -jar target/*.jar                       # http://localhost:8080

C. Render (hosted, no tunnel needed)

Render has no native Java runtime, so it builds the repo's Dockerfile.

  1. Push the repo to your own GitHub account; Render deploys from a remote.

  2. Render → New → Postgres. Note the internal Hostname, Port, Database, Username and Password.

  3. Render → New → Web Service. Choose Language Docker, Branch main, Dockerfile Path ./Dockerfile, the same region as the database, and at least 512 MB RAM.

  4. Environment variables:

    KeyValue
    SPRING_DATASOURCE_URLjdbc:postgresql://<internal-hostname>:5432/<database> (the jdbc: form, not Render's postgresql:// URL)
    SPRING_DATASOURCE_USERNAME / SPRING_DATASOURCE_PASSWORDfrom step 2
    JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRETopenssl rand -base64 64 | tr -d '\n'
    MCP_CONTRACT_HMAC_KEYthe key from LockMCP
    MCP_CONTRACT_ALLOWED_CALLBACK_HOSTSlockmcp.ai,www.lockmcp.ai
    MCP_CONTRACT_DOMAIN_VERIFICATION_TOKENthe token from LockMCP

    Don't set PORT; Render provides it.

  5. Advanced → Health Check Path: /management/health.

  6. Deploy. Wait for Application 'jhipsterSampleApplication' is running! in the logs. Your public base URL is https://<service-name>.onrender.com, or a custom domain. Use that as the connection's Base URL. If it differs from what you entered in 3.5, change it in the wizard's Domain verification step and click Save.

Render's free tier sleeps when idle. The first request afterwards takes about a minute, and LockMCP calls made during spin-up can time out (certification requires the validate endpoint to answer within 2 s). Use a paid instance for anything beyond a quick look.

3.7 Make it public

Skip this if you deployed to Render. Expose port 8080 (prod). Use 8085 only if you run dev.

bash
# host
cloudflared tunnel login                                      # pick <your-domain> in the browser
cloudflared tunnel create jhipster-demo                       # prints a tunnel UUID, writes ~/.cloudflared/<UUID>.json
cloudflared tunnel route dns jhipster-demo <app-host>         # e.g. jhipster.example.com

Create ~/.cloudflared/config.yml (Windows: %USERPROFILE%\.cloudflared\config.yml):

yaml
tunnel: jhipster-demo
credentials-file: /home/<you>/.cloudflared/<UUID>.json   # Windows: C:\Users\<you>\.cloudflared\<UUID>.json
ingress:
  - hostname: <app-host>
    service: http://localhost:8080
  - service: http_status:404
bash
# host, keep it running
cloudflared tunnel ingress validate
cloudflared tunnel run jhipster-demo

One config.yml serves one tunnel by default. If you run several demos, add all their hostnames to one tunnel's ingress list, or pass --config <file> per tunnel.

Verify from anywhere (public):

bash
curl https://<app-host>/management/health
# {"status":"UP",...}

curl https://<app-host>/.well-known/mcp-hub-verification.json
# {"token":"<the token from LockMCP>"}

curl https://<app-host>/api/mcp/roles
# [{"name":"ROLE_ADMIN","toolScopes":[...31 ids...]},{"name":"ROLE_USER","toolScopes":[...8 ids...]}]

curl -s -X POST https://<app-host>/api/mcp/validate -H 'Content-Type: application/json' -d '{"session_token":"x"}'
# {"error":"unauthorized"}   (correct: the request is unsigned)

Open https://<app-host>/mcp-login?callback_url=https://lockmcp.ai/api/auth/delegated-callback&state=test in a browser. You should see a username/password form. If you see "The callback URL is not permitted for this tenant.", fix MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS.

Optional: prove the HMAC key matches before LockMCP tries. This signs a validate call exactly the way LockMCP does (bash + openssl; Git Bash on Windows works):

bash
# host
KEY=<64-hex key>; URL=https://<app-host>/api/mcp/validate; BODY='{"session_token":"unknown"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
BH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')
SIG=$(printf 'POST\n%s\n%s\n%s\n%s' "$URL" "$BH" "$TS" "$NONCE" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" | awk '{print $NF}')
curl -s -X POST "$URL" -H 'Content-Type: application/json' \
  -H "X-Hub-Timestamp: $TS" -H "X-Hub-Nonce: $NONCE" -H "X-Hub-Signature-256: sha256=$SIG" -H 'X-Hub-Contract-Version: 2' \
  -d "$BODY"
# {"valid":false,"remaining_seconds":0}   → key, URL and clock all match
# {"error":"unauthorized"}                → key, URL or clock is wrong

3.8 Finish the LockMCP connection

Back in the wizard (Connections → jhipster → Continue setup):

  1. Step 2 · Domain verification: method Well-known file, then Check now. It should show Verified.

  2. Step 3 · Identity setup: enter the endpoints, then Save endpoints:

    FieldValue
    Login URLhttps://<app-host>/mcp-login
    Exchange URLhttps://<app-host>/api/mcp/exchange
    Validate URLhttps://<app-host>/api/mcp/validate

    Click Test endpoints. Each should show a green dot and an HTTP status.

  3. Optional: Refresh and Logout URLs. The wizard has no fields for these, but the API accepts them. With a Refresh URL set, LockMCP renews the app's session in place when it expires, instead of ending the AI client's session after the app's 1-hour session TTL. While signed in to lockmcp.ai, run this in the browser's developer console. The connection id is in the address bar (/dashboard/connections/<id>/onboarding):

    js
    await fetch("/api/connections/<id>/identity-config", {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        delegatedRefreshUrl: "https://<app-host>/api/mcp/refresh",
        delegatedLogoutUrl: "https://<app-host>/api/mcp/logout",
      }),
    }).then((r) => r.json());

    Changing any delegated URL clears the certification, so do this before step 4.

  4. Step 4 · Provision: Run provisioning. For Tenant Delegation this runs endpoint certification, not Keycloak setup. Every URL must be HTTPS. The login URL must answer 200 with hidden callback_url / code_challenge fields, or a 302. Exchange must reject a fake code with 4xx. Validate must answer {valid:false} in under 2 s. On success it shows Provisioned.

  5. Step 5 · Activate: Activate connection. It should show Live — MCP endpoint serving.

3.9 Roles, role mapping and tools

  1. Import the roles. The Role List Endpoint is fetched when the connection is saved, and the app wasn't running when you created it. Go to Connections, select jhipster, open the panel’s ⋯ menu → Edit, change nothing and click Save Changes. This re-runs the import. LockMCP creates connection roles from /api/mcp/roles: ROLE_ADMIN (slug role-admin) and ROLE_USER (slug role-user), and stores each role's toolScopes. Check them under Members & Access.

  2. Map the app's roles to LockMCP roles. In the wizard's Step 3, Role mapping, add two rows, then Save role mapping:

    your-app role→ hub role
    ROLE_ADMINROLE_ADMIN
    ROLE_USERROLE_USER

    The mapping is an allowlist. A role the app returns from exchange that isn't mapped grants nothing in LockMCP.

  3. Upload the contract. API Specs → Upload Spec: choose connection jhipster, paste the contents of mcp-openapi.yaml from the repo root, and continue through the two steps. LockMCP creates 31 draft tools. The spec's servers: http://localhost:8080 doesn't matter: tool calls always go to the connection's Base URL.

  4. Publish the tools. Tool Catalog: select the jhipster_* tools and click Publish N tools. Publishing re-applies role scopes. Each role is granted exactly the published tools whose operationId is in its toolScopes.

  5. Users. Nothing to invite. When someone signs in through /mcp-login, LockMCP creates the LockMCP user from the exchange response (email, display_name) and gives them the mapped roles for this connection. The delegated flow never grants workspace admin.

3.10 Adding a role (ROLE_MANAGER)

  1. Sign in to the app as admin. Open Administration → Authority (/authority) and create ROLE_MANAGER. Don't edit liquibase/data/authority.csv on an existing database; that causes a Liquibase checksum error.
  2. Administration → User management → Edit user → Profiles: tick ROLE_MANAGER for a user.
  3. Its scopes are already in application.yml ('[ROLE_MANAGER]': getAccount, saveAccount, changePassword, getAllUsers). Change them there if you want, then restart the app. Keep the '[ROLE_…]' bracket-and-quotes form.
  4. In LockMCP, open the connection’s ⋯ → Edit and Save Changes again to re-import roles. Add a role mapping row ROLE_MANAGER → ROLE_MANAGER, and save.

4. Identity Brokering: real-estate demo

Repository: https://github.com/aircwou/realestate-demo-oidc-mcp. An Express + PostgreSQL listings app. Its browser login uses Dex, federated to GitHub.

4.1 How identity works in this demo

There are two separate sign-in paths, and it matters which is which:

PathWho uses itChain
The app's own browser loginPeople using the websiteBrowser → app (/login) → Dex → GitHub → Dex → ID token → app (/callback) → session cookie
LockMCP (Identity Brokering)AI clientsAI client → LockMCP sign-in with a LockMCP hub account → LockMCP token → app, which asks LockMCP's /oauth/verify who it is
  • Dex and GitHub are not involved in the LockMCP path. In Identity Brokering, users sign in to LockMCP with an invited hub account. LockMCP registers no external identity provider.
  • The app accepts a forwarded LockMCP token only on /saved-listings. It doesn't validate the JWT itself. It calls MCPHUB_VERIFY_URL with the same Authorization: Bearer header. LockMCP answers {"authorized": true, "email": "…"} only when the token is valid and the user holds a role on this connection. Otherwise it answers 401 {"authorized": false}, and the app returns 401 {"error":"Invalid or expired token"}.
  • The app has no roles. Data is keyed by email: a browser user (GitHub email) and an AI-client user (LockMCP email) with the same address share the same saved listings.
  • /api/listings and /api/listings/{id} are public. /saved-listings (GET) and /saved-listings/{id} (POST, toggle) need a signed-in user.

About the repo's own labels. The repo's comments and spec call this connection TOKEN_EXCHANGE, and its Dex template has a mcphub-dexdemo client for federating Dex into LockMCP. This guide uses Identity Brokering because that works with this app's /oauth/verify check in every hub configuration. If you federate Dex instead (Token Exchange), it only keeps working while the hub's broker-token flag is off. With it on, LockMCP forwards a Dex token, which /oauth/verify does not accept.

4.2 Clone

bash
# host
git clone https://github.com/aircwou/realestate-demo-oidc-mcp.git
cd realestate-demo-oidc-mcp

Host ports 3001 (app), 5556 (Dex) and 5433 (Postgres) must be free.

4.3 Create a GitHub OAuth App (for the app's browser login)

https://github.com/settings/developers → OAuth Apps → New OAuth App:

FieldLocal setupPublic setup
Homepage URLhttp://localhost:3001https://<app-host>
Authorization callback URLhttp://localhost:5556/dex/callbackhttps://<dex-host>/dex/callback

The callback is Dex's, not the app's. This is the most common setup mistake. A GitHub OAuth App has only one callback URL, so create a second app if you want both local and public setups. Click Register application, then Generate a new client secret, and copy the Client ID and Secret.

4.4 Configure .env and render the Dex config

bash
# host
cp .env.example .env
VariableValueUsed by
PUBLIC_DEX_URLhttp://localhost:5556 locally; https://<dex-host> when public (no trailing slash)Dex issuer $PUBLIC_DEX_URL/dex, GitHub callback
PUBLIC_APP_URLhttp://localhost:3001 locally; https://<app-host> when publicapp redirect URI $PUBLIC_APP_URL/callback
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETfrom 4.3Dex's GitHub connector
DEX_CLIENT_SECRETrandomshared by the app and Dex client realestate-app
SESSION_SECRETrandomthe app's session cookie
MCPHUB_DEX_CLIENT_SECRET, POS_DEX_CLIENT_SECRET, MCPHUB_POS_CLIENT_SECRETrandomother Dex clients this app doesn't use; they must be set or the render script refuses to run

Generate each random value separately:

bash
openssl rand -hex 24
# or, without openssl (e.g. Windows):
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"

Render dex/config.yaml from dex/config.yaml.template + .env. Re-run this whenever .env changes, and never hand-edit dex/config.yaml:

bash
# host (needs Node 18+)
node render-dex-config.js
# [render-dex-config] wrote dex/config.yaml from dex/config.yaml.template

4.5 Point the app at LockMCP

Two values are hard-coded in the repo for the author's own hub. Change both.

1. MCPHUB_VERIFY_URL in docker-compose.yml. Take your connection's MCP URL (created in 4.8, shown under Connections) and replace the trailing /mcp with /oauth/verify:

yaml
      MCPHUB_VERIFY_URL: https://mcp.lockmcp.ai/<tenant>/realestate/oauth/verify

2. The domain-verification token in app/src/server.js, in the /.well-known/mcp-hub-verification.json route. Replace the hard-coded string with the token LockMCP shows you in 4.8:

js
        res.json({ token: "<token from LockMCP>" });

Or leave the code alone and verify with a DNS TXT record (see 4.8).

4.6 Start it

bash
# host, repo root
docker compose up -d --build
docker compose ps            # dex, postgres and app should all be running
docker compose logs -f app   # ends with "[server] listening on :3000"

The app listens on 3000 inside its container, mapped to 3001 on the host. The first start creates the tables and seeds 8 listings from app/seed/listings.json.

Verify locally: open http://localhost:3001. Listings appear without logging in. Click Sign in: you go to Dex, then GitHub, then back to /saved-listings. Your GitHub account needs a verified primary email.

Changed .env after Dex first started? Dex keeps its client config in the dexdata volume and doesn't reliably reload it. After re-rendering:

bash
docker compose stop dex
docker compose rm -f dex
docker volume rm realestate-demo-oidc-mcp_dexdata   # <folder-name>_dexdata; check: docker volume ls
docker compose up -d --build

4.7 Make it public (two hostnames)

Both the app and Dex must be reachable from browsers: the app's login redirects the browser to Dex, and GitHub redirects back to Dex. LockMCP itself only needs to reach the app.

bash
# host
cloudflared tunnel login
cloudflared tunnel create realestate-demo
cloudflared tunnel route dns realestate-demo <app-host>     # e.g. realestate.example.com
cloudflared tunnel route dns realestate-demo <dex-host>     # e.g. dex.example.com

~/.cloudflared/config.yml:

yaml
tunnel: <TUNNEL_ID>
credentials-file: /path/to/home/.cloudflared/<TUNNEL_ID>.json
ingress:
  - hostname: <app-host>
    service: http://localhost:3001
  - hostname: <dex-host>
    service: http://localhost:5556
  - service: http_status:404
bash
# host, keep it running
cloudflared tunnel run realestate-demo

Then switch the configuration to the public URLs:

  1. GitHub OAuth App callback: https://<dex-host>/dex/callback, Homepage https://<app-host>. Use a second OAuth App if you want to keep the local one.
  2. .env: PUBLIC_DEX_URL=https://<dex-host> and PUBLIC_APP_URL=https://<app-host>.
  3. node render-dex-config.js, then recreate the Dex volume and rebuild (the commands in the box above).

Only browser-facing URLs change. Inside Docker the app still reaches Dex at http://dex:5556. It rewrites the token and JWKS endpoints from discovery to that internal origin.

Verify (public):

bash
curl https://<dex-host>/dex/.well-known/openid-configuration   # issuer = https://<dex-host>/dex
curl https://<app-host>/api/listings                           # JSON array of 8 listings
curl https://<app-host>/.well-known/mcp-hub-verification.json  # {"token":"..."}
curl -i https://<app-host>/saved-listings -H 'Authorization: Bearer x' -H 'Accept: application/json'
# HTTP/1.1 401 ... {"error":"Invalid or expired token"}

Then sign in at https://<app-host>: the browser should go to https://<dex-host>, then GitHub, then back.

Hosted instead of a tunnel: the repo's README sketches Render + Neon. Point the app's DATABASE_URL at Neon (?sslmode=require), deploy app/ as a Render Web Service, and deploy Dex as a second service. Then set PUBLIC_* and the GitHub callback to the two services' public URLs. The repo has no Render blueprint or Dex service definition, so you assemble those yourself.

4.8 LockMCP connection (Identity Brokering)

  1. Connections → Add Connection:

    FieldValue
    Namerealestate
    Base URLhttps://<app-host>
    Identity ModelIdentity Brokering
    User / Role List Endpointleave empty (the app has neither)
  2. Open the connection's panel and copy its MCP Server URL (https://mcp.lockmcp.ai/<tenant>/realestate/mcp). Finish 4.5 with it, then run docker compose up -d --build app.

  3. Continue setup → Step 2 · Domain verification. Pick one:

    • Well-known file: put the token into app/src/server.js (4.5), rebuild the app, then Check now.
    • DNS TXT record: in Cloudflare DNS, add a TXT record named _mcp-verify.<app-host> with the value shown, then Check now.
  4. Step 3 · Identity setup shows "No extra setup — users sign in via the hub Keycloak realm."

  5. Step 4 · Provision → Run provisioning. This creates this connection's clients in LockMCP's Keycloak. Then Step 5 · Activate connection.

  6. Tools. API Specs → Upload Spec: connection realestate, paste dexdemo-openapi.yaml. Then Tool Catalog: publish the four tools:

    ToolCallsNeeds a signed-in user?
    realestate_list_listingsGET /api/listingsno
    realestate_get_listingGET /api/listings/{id}no
    realestate_list_my_saved_listingsGET /saved-listingsyes (verified via /oauth/verify)
    realestate_toggle_saved_listingPOST /saved-listings/{id}yes

4.9 Users and roles

LockMCP grants tools through connection roles. The app has no roles, so this demo is the place to see LockMCP-side authorization on its own. Create two roles to see the difference.

  1. Members & Access → Create a role:

    • Browser: Applies to realestate. Tools: realestate_list_listings, realestate_get_listing.
    • Saver: Applies to realestate. Tools: all four.

    Leave the dashboard permissions empty; these roles only grant tools.

  2. Give a user a role. Either:

    • You: Members & Access → your row → Role assignments → realestate: Saver. This takes effect immediately, with no email needed.
    • A teammate: Invite Member → their email → Platform access: No platform access → Connection access: realestate with Saver. They receive an email, set their LockMCP password, and accept. Access is granted only when the invitation is accepted.

    Use the same email as the GitHub account you sign in to the website with, if you want the AI client and the browser to share saved listings.

  3. A user with Browser only sees two tools. LockMCP also answers {"authorized": false} to /oauth/verify for anyone with no role on the connection, so the app refuses them even if they call it directly with a valid LockMCP token.

4.10 OIDC values reference (the app's own login)

These configure the app's browser login against Dex. None of them is entered in LockMCP for Identity Brokering.

SettingValueWhere it comes from
OIDC issuer$PUBLIC_DEX_URL/dexdex/config.yaml.template
Discovery URL (browser view)$PUBLIC_DEX_URL/dex/.well-known/openid-configurationstandard Dex path
Discovery URL (app, inside Docker)http://dex:5556/dexOIDC_DISCOVERY_URL in docker-compose.yml
Client ID / secretrealestate-app / $DEX_CLIENT_SECRETcompose + template
App redirect URI$PUBLIC_APP_URL/callbackOIDC_REDIRECT_URI; must equal the client's redirectURIs
Dex → GitHub callback$PUBLIC_DEX_URL/dex/callbackGitHub OAuth App + connector redirectURI
Scopes requestedopenid email profileapp/src/auth.js
Claims usedemail (data key), sub, name, preferred_usernameapp/src/auth.js

5. Token Exchange: POS demo

Repository: https://github.com/aircwou/pos-demo-mcp-ib. An Express + PostgreSQL point-of-sale app with two roles, Cashier and Manager. Users sign in with OIDC through Dex.

5.1 How identity works in this demo

  • Browser login: the app is an OIDC client of Dex (pos-app). It requests openid email profile, checks the ID token, and then looks up the email in its own users table to get the role.
  • API / MCP calls: the app accepts Authorization: Bearer <token>. It checks the token only by calling Dex's userinfo endpoint. It then looks up the returned email in users: an unknown email gets 403, otherwise the user gets that row's role. The app never validates a LockMCP token.
  • In LockMCP (Token Exchange, OIDC): LockMCP's Keycloak federates your Dex as this connection's identity provider. Signing in to the connection sends the user to Dex. Keycloak links the Dex login to the invited LockMCP account with the same email, and stores the Dex tokens. On each tool call, LockMCP fetches the user's stored Dex access token and forwards that to the app.

So the four layers are:

LayerWhat happensCredential
Browser login (website only)pos-app code flow against DexDex ID token → app session cookie
OIDC authentication (AI client)Keycloak brokers to Dex using client mcphub-posDex tokens, kept by Keycloak
LockMCP authorizationIs the user invited, and which connection roles do they hold?LockMCP (Keycloak) access token held by the AI client
"Token exchange"LockMCP swaps its own token for the stored Dex token before calling the appDex access token
Backend authorizationPOS checks the token at Dex /userinfo, then the role in users—

Hub prerequisite: broker-token forwarding. The swap to the Dex token is controlled by a LockMCP server setting, MCP_SERVER_BROKER_TOKEN_EXCHANGE_ENABLED. It is off by default. Ask your LockMCP operator whether it is on. When it is off, LockMCP forwards its own Keycloak token. Dex's userinfo rejects that, so every POS tool call returns 401 {"error":"Invalid or expired token"}, even though sign-in and tool listing work. When the operator turns it on, re-provision the connection and sign in again, so Keycloak has a stored Dex token for you.

5.2 Run it locally (quick start)

From the repo's README. Nothing here is public yet.

bash
# host
git clone https://github.com/aircwou/pos-demo-mcp-ib.git
cd pos-demo-mcp-ib

docker run -d --name pos-postgres \
  -e POSTGRES_USER=pos -e POSTGRES_PASSWORD=pos -e POSTGRES_DB=pos \
  -p 5432:5432 postgres:16-alpine

Create dex-local.yaml in the repo root:

yaml
issuer: http://localhost:5556/dex
storage:
  type: memory
web:
  http: 0.0.0.0:5556
oauth2:
  skipApprovalScreen: true
staticClients:
  - id: pos-app
    name: POS Demo
    secret: local-dev-secret
    redirectURIs:
      - http://localhost:3000/callback
enablePasswordDB: true
staticPasswords:
  - email: manager@example.com
    # bcrypt hash of "password"
    hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
    username: manager
    userID: "11111111-1111-1111-1111-111111111111"
bash
# host
docker run -d --name pos-dex -p 5556:5556 \
  -v "$PWD/dex-local.yaml:/etc/dex/config.yaml" \
  dexidp/dex:v2.41.1 dex serve /etc/dex/config.yaml

In Git Bash on Windows, prefix with MSYS_NO_PATHCONV=1. In PowerShell, use ${PWD} instead of $PWD.

Check http://localhost:5556/dex/.well-known/openid-configuration returns JSON. Then create .env in the repo root:

dotenv
DATABASE_URL=postgresql://pos:pos@localhost:5432/pos?sslmode=disable
OIDC_DISCOVERY_URL=http://localhost:5556/dex
OIDC_CLIENT_ID=pos-app
OIDC_CLIENT_SECRET=local-dev-secret
OIDC_REDIRECT_URI=http://localhost:3000/callback
SESSION_SECRET=change-me-to-a-long-random-string
PORT=3000

?sslmode=disable is required for a local Postgres: the app always asks for TLS otherwise.

bash
# host
cd app
npm install
node --env-file=../.env src/server.js
# [db] seeded 12 products
# [auth] discovered OIDC issuer: http://localhost:5556/dex
# [server] listening on :3000

Give the test login a role (in a second terminal):

bash
# host; runs psql inside the pos-postgres container
docker exec pos-postgres psql -U pos -d pos -c \
  "INSERT INTO users (email, role) VALUES ('manager@example.com', 'MANAGER')
   ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role;"

Open http://localhost:3000 and sign in as manager@example.com / password. You land on Checkout, and the Manager dashboard is at /manager.

Clean up later with docker rm -f pos-postgres pos-dex.

5.3 Roles and permissions

Roles live in the users.role column (CASHIER or MANAGER, enforced by a CHECK constraint). The app looks them up by email and never reads them from a token.

RouteCashierManagerOpenAPI operationId → LockMCP tool
GET / (checkout page)✅✅—
GET /products✅✅listProducts → pos_list_products
POST /sales✅✅ringUpSale → pos_ring_up_sale
GET /sales/mine (own sales only)✅✅listMySales → pos_list_my_sales
GET /manager❌✅—
POST /products❌✅addProduct → pos_add_product
POST /products/:id/update❌✅not in the spec
POST /products/:id/delete❌✅not in the spec
GET /sales (everyone's sales)❌✅listAllSales → pos_list_all_sales
POST /sales/:id/void❌✅voidSale → pos_void_sale
GET /reports/summary❌✅getSalesReport → pos_get_sales_report

Responses to API clients (requests with Accept: application/json, which LockMCP sends) are JSON:

  • no or bad token: 401 {"error":"Invalid or expired token"}
  • email not in users: 403 {"error":"<email> is not a registered user of this POS demo."}
  • wrong role: 403 {"error":"This endpoint requires the MANAGER role."}

Manage users with SQL; there is no admin UI:

sql
INSERT INTO users (email, role) VALUES ('someone@example.com', 'CASHIER')
ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role;

app/src/db.js upserts two built-in demo accounts on every start and resets their roles. Change the code, not the table, if you want different roles for those two emails. A browser session caches the role at sign-in, so sign out at /logout after changing a role. API calls look the role up on every request.

5.4 Make it public

LockMCP's Keycloak (at auth.lockmcp.ai) has to reach your Dex for discovery, JWKS and token calls, and browsers have to reach both Dex and the app. So both need public HTTPS hostnames, and Dex's issuer must be its public URL.

1. Tunnel both ports:

bash
# host
cloudflared tunnel login
cloudflared tunnel create pos-demo
cloudflared tunnel route dns pos-demo <app-host>     # e.g. pos.example.com
cloudflared tunnel route dns pos-demo <dex-host>     # e.g. posdex.example.com
yaml
# ~/.cloudflared/config.yml
tunnel: <TUNNEL_ID>
credentials-file: /path/to/home/.cloudflared/<TUNNEL_ID>.json
ingress:
  - hostname: <app-host>
    service: http://localhost:3000
  - hostname: <dex-host>
    service: http://localhost:5556
  - service: http_status:404
bash
# host, keep it running
cloudflared tunnel run pos-demo

2. A public Dex config with a second client for LockMCP. Create dex-public.yaml. It combines the quick-start config with the pattern the real-estate repo uses for its mcphub-pos client: a separate Dex client whose only redirect URI is LockMCP's Keycloak broker endpoint for this connection.

yaml
issuer: https://<dex-host>/dex
storage:
  type: memory
web:
  http: 0.0.0.0:5556
oauth2:
  skipApprovalScreen: true
staticClients:
  - id: pos-app                      # the app's own browser login
    name: POS Demo
    secret: <POS_APP_SECRET>
    redirectURIs:
      - https://<app-host>/callback
  - id: mcphub-pos                   # LockMCP's Keycloak, federating Dex for this connection
    name: LockMCP pos connection
    secret: <MCPHUB_POS_SECRET>
    redirectURIs:
      - https://auth.lockmcp.ai/realms/mcp-hub/broker/<tenant>-pos-idp/endpoint
enablePasswordDB: true
staticPasswords:
  - email: <manager-email>
    hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"   # "password"
    username: manager
    userID: "11111111-1111-1111-1111-111111111111"
  - email: <cashier-email>
    hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"   # also "password"
    username: cashier
    userID: "22222222-2222-2222-2222-222222222222"
  • <tenant>-pos-idp is the identity-provider alias LockMCP creates when it provisions the connection: <tenant slug>-<connection slug>-idp. The redirect URI must match exactly, or Dex answers Unregistered redirect_uri.
  • Generate <POS_APP_SECRET> and <MCPHUB_POS_SECRET> with openssl rand -hex 24.
  • Use email addresses you can receive mail at for <manager-email> and <cashier-email> (plus-addressing like you+manager@… works). LockMCP links a Dex login only to an existing, invited LockMCP account with the same email, and invitations are accepted by email. Your own LockMCP email is the easiest choice for one of them.
  • Both test users share the sample hash, so both passwords are password. Change it before exposing this beyond a test.
  • memory storage means a Dex restart forgets sessions and rotates signing keys. Any Dex token LockMCP stored before the restart then stops working, so sign in again after restarting Dex.

Restart Dex with it:

bash
# host, repo root
docker rm -f pos-dex
docker run -d --name pos-dex -p 5556:5556 \
  -v "$PWD/dex-public.yaml:/etc/dex/config.yaml" \
  dexidp/dex:v2.41.1 dex serve /etc/dex/config.yaml
curl https://<dex-host>/dex/.well-known/openid-configuration   # "issuer": "https://<dex-host>/dex"

3. Point the app at the public Dex (.env). As in the repo's hosted docker-compose.yml, the app reaches Dex through the public URL for both discovery and token calls:

dotenv
DATABASE_URL=postgresql://pos:pos@localhost:5432/pos?sslmode=disable
OIDC_DISCOVERY_URL=https://<dex-host>/dex
OIDC_CLIENT_ID=pos-app
OIDC_CLIENT_SECRET=<POS_APP_SECRET>
OIDC_REDIRECT_URI=https://<app-host>/callback
SESSION_SECRET=<openssl rand -hex 24>
PORT=3000

Restart the app (node --env-file=../.env src/server.js from app/). Expect [auth] discovered OIDC issuer: https://<dex-host>/dex.

4. Give both test users a POS role:

bash
# host
docker exec pos-postgres psql -U pos -d pos -c \
  "INSERT INTO users (email, role) VALUES ('<manager-email>', 'MANAGER'), ('<cashier-email>', 'CASHIER')
   ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role;"

Verify (public): open https://<app-host> and sign in through https://<dex-host> as <cashier-email> / password. Then:

bash
curl -i https://<app-host>/products -H 'Authorization: Bearer x' -H 'Accept: application/json'
# HTTP/1.1 401 ... {"error":"Invalid or expired token"}   (Dex rejected the token, as expected)

Docker Compose for the app. The repo's docker-compose.yml builds only the app, publishes it on host port 3002, and hard-codes the author's Dex and hostname. To use it, edit OIDC_DISCOVERY_URL, OIDC_REDIRECT_URI and OIDC_CLIENT_ID, and point the tunnel at localhost:3002. It expects a hosted Postgres (DATABASE_URL with sslmode=require, e.g. Neon) and reads the client secret from POS_DEX_CLIENT_SECRET in .env.

5.5 LockMCP connection (Token Exchange, OIDC)

  1. Connections → Add Connection:

    FieldValue
    Namepos
    Base URLhttps://<app-host>
    Identity ModelToken Exchange
    User / Role List Endpointleave empty
  2. Continue setup → Step 2 · Domain verification. The POS app serves no well-known file, so use DNS TXT record. In Cloudflare DNS, add a TXT record named _mcp-verify.<app-host> with the value shown. Check it propagated, then click Check now:

    bash
    nslookup -type=TXT _mcp-verify.<app-host>      # Windows/macOS/Linux
    dig +short TXT _mcp-verify.<app-host>          # macOS/Linux
  3. Step 3 · Identity setup, Federation protocol: OIDC.

    • Discovery document URL: https://<dex-host>/dex/.well-known/openid-configuration, then Import from discovery. This fills in JWKS URI (https://<dex-host>/dex/keys), Authorization URL (…/dex/auth) and Token URL (…/dex/token), and sets the issuer field to https://<dex-host>/dex.
    • Client ID: mcphub-pos.
    • Client secret: <MCPHUB_POS_SECRET>. It is stored encrypted and never shown again.
    • Save, then Test connection. Expect Reachable.

    All six values are required. Provisioning refuses to run with any of them missing.

  4. Step 4 · Provision → Run provisioning. This registers your Dex in LockMCP's Keycloak as identity provider <tenant>-pos-idp. It stores the Dex tokens at login (needed for forwarding), and it only links Dex logins to existing invited accounts. It never creates accounts.

  5. Step 5 · Activate connection.

  6. Tools. API Specs → Upload Spec (connection pos, paste pos-openapi.yaml). Then Tool Catalog → publish all seven pos_* tools.

5.6 LockMCP roles that mirror the POS roles

LockMCP decides which tools each user sees; POS decides what each call may do. Mirror the POS roles so the two agree:

LockMCP connection role (Applies to pos)Tools to grant
Cashierpos_list_products, pos_ring_up_sale, pos_list_my_sales
Managerall seven: the three above + pos_add_product, pos_list_all_sales, pos_void_sale, pos_get_sales_report

Create them under Members & Access → Create a role. You can change a role's tools later with AI Tool Access. Then give each test email its role:

  • Your own account (if it's one of the Dex emails): your row → Role assignments → pos → Manager (or Cashier).
  • Another address: Invite Member → email → No platform access → Connection access pos → Cashier. The invitee must open the email, set a LockMCP password and accept before signing in through Dex. Until then there is no account for Keycloak to link to.

Deliberate mismatch test. Give <cashier-email> the LockMCP Manager role while POS still says CASHIER. The AI client then sees pos_get_sales_report, but calling it returns 403 {"error":"This endpoint requires the MANAGER role."}. That is the app enforcing its own rules behind LockMCP.


6. LockMCP setup reference

This section is the LockMCP side on its own, in order. The demo sections above give the exact values.

#StepWhere in LockMCPTD (JHipster)IB (real estate)TE (POS)
1Sign in or sign uphttps://lockmcp.ai (/signup creates a workspace)✔✔✔
2Workspace (tenant)Created at signup or by invitation. Its slug is in your MCP URLs✔✔✔
3Invite usersMembers & Access → Invite Membernot needed: users are created on first app logininvite, or use Role assignments on yourselfinvite, or use Role assignments; the email must match Dex
4Create the applicationConnections → Add Connection (Name, Base URL https://…)✔✔✔
5Connection lifecycleContinue setup: wizard steps 1–5. Status goes Not started → Verifying → Provisioning → Provisioned → Live✔✔✔
6Identity modelAdd Connection dialog, or wizard step 1Tenant DelegationIdentity BrokeringToken Exchange
7Provider detailsWizard step 3Login / Exchange / Validate URLs, HMAC key, role mappingnoneOIDC discovery, JWKS, auth + token URLs
8Endpoint URLsStep 3 (+ API for refresh/logout); Role List Endpoint in the connection dialog3 (+2 optional) + /api/mcp/roles——
9Client ID / secretStep 3the HMAC key insteadnonemcphub-pos + secret
10EnumsTool Studio or AI Tool Access → Inject keysoptional, see 7not usednot used
11ScopesRole List Endpoint toolScopes (TD), or tools ticked per roleimported from the appby handby hand
12Roles / permissionsMembers & Access → Create a role (Applies to: the connection)imported + Role mappingBrowser, SaverCashier, Manager
13Save and validateStep 2 Check now, step 3 Test endpoints / Test connection, step 4 Run provisioning, step 5 Activate✔✔✔
14Authenticate as a test userConnect an AI client to the connection's MCP URL (8.1)app login (admin/admin)LockMCP email + passwordDex login
15Discover toolsthe AI client's tool list (tools/list)per mapped roleper roleper role
16Call toolsthe AI client, or MCP Inspector✔✔✔ (needs the broker flag)
17Verify authorizationsection 8; Audit Log, Sessions✔✔✔

Rules that apply to every connection:

  • Tools are created as drafts from an uploaded spec. Only published tools are served.
  • A published tool is visible to a user only if one of their roles on that connection grants it. A tool with no role grant is hidden from everyone, admins included.
  • Tool names are <connection name>_<operationId in snake_case>, for example getAllBankAccounts → jhipster_get_all_bank_accounts.
  • Tool calls go to the connection's Base URL plus the operation's path. The spec's servers entry is ignored.
  • The connection serves MCP only while it is Live (Active). Changing the identity model sends it back to Verifying. Re-provision after changing identity settings.
  • Use the per-connection MCP URL, https://mcp.lockmcp.ai/<tenant>/<conn>/mcp. Tenant Delegation login needs to know the connection, and per-connection URLs scope the tool list to one app.

7. Enum and scope configuration

7.1 Scopes: which tools a role gets

In LockMCP, a "scope" for these demos is simply which tools a connection role grants. There are two ways to set it:

  1. Declared by the app (Tenant Delegation, JHipster). The Role List Endpoint returns each role with toolScopes, a list of OpenAPI operationIds. LockMCP stores them on the imported role. Whenever tools are published, or the roles are re-imported, it replaces that role's tool grants with exactly the published tools whose operationId matches. The match ignores case style: getAllBankAccounts matches tool operationId get_all_bank_accounts. A role that declares no scopes keeps whatever you assigned by hand.
  2. Assigned by hand (IB and TE demos). Tick the tools when you Create a role, or later in AI Tool Access.
DemoRole → tools (operationIds)Source
JHipsterROLE_ADMIN → all 31; ROLE_USER → getAccount, saveAccount, changePassword, getAllUsers, createUser, updateUser, getUser, deleteUser; ROLE_MANAGER → getAccount, saveAccount, changePassword, getAllUsersapplication.yml → /api/mcp/roles
Real estateBrowser → listListings, getListing; Saver → all fouryour choice (the app has no roles)
POSCashier → listProducts, ringUpSale, listMySales; Manager → all sevenmirrors the app's permission matrix

To change JHipster scopes: edit mcp.contract.tool-scopes in src/main/resources/config/application.yml (keep the '[ROLE_X]': keys), rebuild or restart the app, then Save Changes on the LockMCP connection to re-import.

To verify:

  • Members & Access: open the role. AI Tool Access lists the granted tools.
  • Ask the AI client to list tools. Signed in as user, a JHipster connection shows only the eight ROLE_USER tools.
  • curl https://<app-host>/api/mcp/roles shows exactly what LockMCP imports.

7.2 Enums: narrowing a parameter to the user's own records

What it is. A tool parameter marked x-mcp-inject: "<key>" in the OpenAPI spec can be narrowed per user. When LockMCP lists tools, it looks up <key> in the user's record-scope payload and sets enum: [...] on that parameter, so the AI client is only offered IDs the user may use. The marker is removed before the schema reaches the client. This is defense in depth: the app's own checks stay authoritative.

The payload has this shape:

json
{
  "email": "user@example.com",
  "accounts": [
    { "accountId": 456, "roles": [ { "role": "LEASING_AGENT", "propertyIds": [101, 102] } ] }
  ]
}

For a tool and a user, LockMCP unions the IDs found under <key> for the roles that own the tool. Which keys a role injects is set per tool and role: Tool Studio → the tool → role access → Inject keys (comma-separated), or Members & Access → AI Tool Access → Enum Injection Keys.

Where the payload comes from:

Identity modelSource
Tenant Delegationenum_scopes returned by your app in its exchange / validate response
Identity Brokering / Token Exchangea Keycloak user attribute, set from Members & Access → Record scope, the record-scope API, or the tenant IdP

Per demo:

DemoMarker in the specPayload available?Result
JHipsterx-mcp-inject: "bankAccountIds" on the id path parameter. After import, the four tools that take a bank-account id carry it: jhipster_get_bank_account, jhipster_update_bank_account, jhipster_partial_update_bank_account, jhipster_delete_bank_accountYes. exchange and validate return enum_scopes: one entry per JHipster authority (ROLE_USER, ROLE_ADMIN), with bankAccountIds = the accounts the user owns (every account for ROLE_ADMIN)Off until a role has the inject key. Then id is limited to those IDs
Real estatenone—not applicable
POSnone—not applicable

So no enum configuration is required to run the demos. What JHipster sends looks like this:

json
"enum_scopes": {
  "email": "user@localhost",
  "accounts": [ { "accountId": 2, "roles": [ { "role": "ROLE_USER", "bankAccountIds": [1, 2] } ] } ]
}

To see it work with JHipster:

  1. Give the user some accounts. In the JHipster UI as admin: Entities → Bank Account, edit accounts 1 and 2, set User to user, save. The seeded accounts have no owner, so without this bankAccountIds is empty and every scoped call is refused. With the dev profile the database is in memory: redo this after each restart.
  2. Map the role. Connections → jhipster → Onboarding → step 3 → Role mapping: ROLE_USER → ROLE_USER (slug role-user) → Save role mapping. The payload uses JHipster's role names; the mapping is what matches them to the LockMCP role, so renaming that role later doesn't break scope. With a mapping, sign-in sets the connection's roles (they can't be assigned in Members).
  3. Grant the tool and the key. ROLE_USER's toolScopes include no bank-account operations, so grant it by hand: Tool Studio → jhipster_get_bank_account → role access → ROLE_USER → allow, Inject keys bankAccountIds. Publishing re-applies toolScopes and removes this grant; grant it again after a publish, or add the operations under '[ROLE_USER]' in the app's application.yml.
  4. Reconnect the AI client and sign in as user / user.
TryExpected
list_available_toolsjhipster_get_bank_account: id has enum: [1, 2] and is required
jhipster_get_bank_account, id 1account 1
jhipster_get_bank_account, id 3"Access denied - the value supplied for 'id' is not within your authorized scope." LockMCP refuses it without calling the app
Give account 3 to user, wait 30 s, id 3 againaccount 3: each session check sends the current list

Update tools: updateBankAccount and partialUpdateBankAccount have an id in the request body as well as the path. LockMCP makes it one argument sent to both places, as the app requires, so those tools are narrowed like get and delete. A spec imported before this was handled maps that id to the body only and every call fails: re-sync the spec and publish.

Not scoped: jhipster_get_all_bank_accounts has no scoped parameter at all: what it returns is up to the app.


8. Calling MCP tools

8.1 Connect an AI client

Copy the MCP Server URL from the connection’s panel under Connections. It looks like https://mcp.lockmcp.ai/<tenant>/<conn>/mcp. The Connect page has client instructions.

  • Claude (claude.ai or desktop): add a custom connector with the MCP URL. Claude opens a browser for the sign-in and registers itself. There is no client ID or secret to enter.
  • ChatGPT: Settings → Security and login → Developer mode (Plus, Pro, Business, Enterprise or Edu), then add the MCP URL as a connector.
  • MCP Inspector (good for testing, shows raw requests):
    bash
    npx @modelcontextprotocol/inspector
    Choose transport Streamable HTTP, paste the MCP URL, and connect. It runs the OAuth sign-in in your browser.

What the sign-in looks like:

DemoBrowser goes toSign in as
JHipsterhttps://<app-host>/mcp-login?... (the app's own page)admin / admin or user / user
Real estateLockMCP's sign-in page (auth.lockmcp.ai)your LockMCP email and password
POSLockMCP's Keycloak, which goes straight to https://<dex-host>/dex<manager-email> or <cashier-email> / password

To switch users, disconnect and reconnect the connector. For POS and real estate you may also need to sign out of the browser session at auth.lockmcp.ai (or use a private window).

8.2 Raw MCP requests (reference)

Clients talk JSON-RPC over HTTP POST to the MCP URL, with Authorization: Bearer <LockMCP token>. The token comes from the OAuth sign-in above. Without one, LockMCP answers 401 with a WWW-Authenticate header that points to its OAuth metadata, which is how clients discover the sign-in.

bash
curl -i https://mcp.lockmcp.ai/<tenant>/pos/mcp      # → 401 + WWW-Authenticate: Bearer resource_metadata="…"

The bodies a client sends after initialising the session:

json
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
json
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "name": "pos_ring_up_sale", "arguments": { "items": [ { "product_id": 1, "quantity": 2 } ] } } }

Use MCP Inspector to see these with a real token.

8.3 JHipster (Tenant Delegation)

admin (ROLE_ADMIN + ROLE_USER)user (ROLE_USER)
Tools listedall 31 jhipster_* tools8: jhipster_get_account, jhipster_save_account, jhipster_change_password, jhipster_get_all_users, jhipster_create_user, jhipster_update_user, jhipster_get_user, jhipster_delete_user

Allowed call. As admin, "List all bank accounts" calls jhipster_get_all_bank_accounts (GET /api/bank-accounts?eagerload=true). Expect 200 with a JSON array of bank accounts (id, name, balance, …) from the demo data.

Allowed call. As user, jhipster_get_account (GET /api/account) returns the account (login: "user", email: "user@localhost", authorities: ["ROLE_USER"]).

Refused by LockMCP. As user, ask for bank accounts. jhipster_get_all_bank_accounts isn't in the tool list, because ROLE_USER has no such scope, so the client can't call it.

Refused by the app. As user, call jhipster_get_all_users (GET /api/admin/users). LockMCP lists and forwards it, but JHipster answers 403 Forbidden because /api/admin/** requires ROLE_ADMIN.

Direct check (no LockMCP): the app rejects calls without a valid session:

bash
curl -i https://<app-host>/api/account -H 'Authorization: Bearer mcp:0000000000000000000000000000000000000000000000000000000000000000'
# HTTP/1.1 401 ... WWW-Authenticate: Bearer error="invalid_token" ... {"error":"session_expired"}

8.4 Real estate (Identity Brokering)

Role SaverRole Browser
Tools listedrealestate_list_listings, realestate_get_listing, realestate_list_my_saved_listings, realestate_toggle_saved_listingrealestate_list_listings, realestate_get_listing

Allowed call. realestate_toggle_saved_listing with {"id": 1} → 200 {"saved": true, "listingId": 1}. Calling it again returns {"saved": false, …}. Then realestate_list_my_saved_listings returns the saved listings for your LockMCP email. Sign in to the website with a GitHub account that has the same email, and the same saves appear under Saved listings.

Refused by LockMCP. As Browser, the saved-listing tools are not listed.

Refused by the app. Remove all your roles on realestate (Role assignments), then reconnect. LockMCP lists no tools. A LockMCP token sent straight to the app is refused too: /oauth/verify says {"authorized": false}, so the app answers 401 {"error":"Invalid or expired token"}.

bash
curl -i https://mcp.lockmcp.ai/<tenant>/realestate/oauth/verify
# HTTP/1.1 401 ... {"authorized":false}

8.5 POS (Token Exchange)

LockMCP Cashier + POS CASHIERLockMCP Manager + POS MANAGER
Tools listedpos_list_products, pos_ring_up_sale, pos_list_my_salesall seven

Allowed call. As the cashier, pos_ring_up_sale with {"items": [{"product_id": 1, "quantity": 2}]} → 201 {"saleId": …, "totalCents": …}. Then pos_list_my_sales shows that sale.

Allowed call. As the manager, pos_get_sales_report → {"totals": {"completed_count", "voided_count", "revenue_cents"}, "topProducts": [...]}. Then pos_void_sale with {"id": <saleId>} → {"success": true, "id": …, "status": "VOIDED"}.

Refused by LockMCP. As the cashier, pos_get_sales_report isn't listed.

Refused by the app. Use the mismatch from 5.6: LockMCP Manager, POS CASHIER. Calling pos_get_sales_report returns 403 {"error":"This endpoint requires the MANAGER role."}. Remove the email from POS's users table and every call returns 403 {"error":"<email> is not a registered user of this POS demo."}.

If every call returns 401 {"error":"Invalid or expired token"}, LockMCP is forwarding its own token instead of the Dex token: the broker flag is off, or you haven't signed in again since it was turned on. See 5.1.


9. End-to-end test scenarios

Run each one after its demo is Live and its tools are published.

9.1 Tenant Delegation

#StepsExpected
TD-1Connect an AI client to /<tenant>/jhipster/mcp and sign in as adminThe browser shows the app's /mcp-login page. After sign-in it returns to the client. Sessions in LockMCP shows a new session.
TD-2List tools as admin31 tools
TD-3Call jhipster_get_all_bank_accounts200, bank-account JSON
TD-4Reconnect as user, list tools8 tools, none for bank accounts / labels / operations
TD-5As user, call jhipster_get_all_usersThe app returns 403
TD-6Rotate the HMAC key in LockMCP (step 3) without updating the app, then sign in againLogin fails: the exchange is rejected (401 from the app → "The tenant rejected the exchange")
TD-7Remove lockmcp.ai from MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS, restart, sign inThe app shows "The callback URL is not permitted for this tenant."
TD-8Remove the ROLE_USER row from Role mapping, sign in as userNo mapped roles, so no tools listed

9.2 Identity Brokering

#StepsExpected
IB-1Connect to /<tenant>/realestate/mcp, sign in with your LockMCP accountLockMCP sign-in page; no Dex or GitHub involved
IB-2With Saver: toggle listing 1, then list saved{"saved":true,"listingId":1}, then listing 1 appears
IB-3Sign in to https://<app-host> with a GitHub account that has the same emailListing 1 is in Saved listings
IB-4Switch your role to Browser, reconnectOnly 2 tools listed
IB-5Remove every role on realestate, reconnectNo tools; /oauth/verify answers {"authorized":false}
IB-6Set a wrong MCPHUB_VERIFY_URL (e.g. another connection slug), rebuild the appSaved-listing tools return 401 {"error":"Invalid or expired token"}; public listing tools still work

9.3 Token Exchange

#StepsExpected
TE-1Connect to /<tenant>/pos/mcpThe browser goes through LockMCP's Keycloak straight to https://<dex-host>/dex
TE-2Sign in at Dex as <cashier-email> (invited, accepted, LockMCP role Cashier)Back in the client, with 3 tools listed
TE-3Call pos_list_products200, 12 seeded products (broker flag on)
TE-4Call pos_ring_up_sale, then pos_list_my_sales201, then the sale is listed
TE-5Sign in at Dex with an email that has no LockMCP invitationSign-in fails at Keycloak; no account is created
TE-6LockMCP Manager, POS CASHIER: call pos_get_sales_reportApp 403 "requires the MANAGER role"
TE-7Delete the user's row from users, call any toolApp 403 "is not a registered user"
TE-8Restart Dex (memory storage), call a tool without signing in againFails: the stored Dex token no longer validates. Sign in again to fix it.

10. Troubleshooting

Each entry is Symptom → Cause → Fix → Verify.

Identity provider and OIDC

Dex: "Unregistered redirect_uri" / GitHub: redirect_uri_mismatch

  • Cause: the redirect URI sent doesn't match a registered one exactly (scheme, host, port, path). There are three different redirect URIs in these demos: the app's …/callback (registered in Dex), Dex's …/dex/callback (registered in GitHub), and LockMCP's https://auth.lockmcp.ai/realms/mcp-hub/broker/<tenant>-pos-idp/endpoint (registered in Dex for mcphub-pos).
  • Fix: correct the one in the error. Real estate: update .env, run node render-dex-config.js, then recreate the Dex volume. POS: edit the Dex YAML, then docker rm -f pos-dex and run it again.
  • Verify: sign in again; the error page doesn't appear.

Dex config change has no effect (real estate)

  • Cause: Dex keeps clients in the dexdata SQLite volume and ignores later config edits.
  • Fix: docker compose stop dex && docker compose rm -f dex, then docker volume rm <folder>_dexdata, then docker compose up -d --build.
  • Verify: docker compose logs dex shows a fresh start, and the new client works.

OIDC discovery fails (OIDC discovery attempt 5/5 failed, app exits)

  • Cause: OIDC_DISCOVERY_URL is unreachable or isn't the issuer URL, or Dex isn't up.
  • Fix: open <OIDC_DISCOVERY_URL>/.well-known/openid-configuration from where the app runs. Inside Docker (real estate) that is http://dex:5556/dex. For POS it is the public URL. Check docker compose logs dex / docker logs pos-dex.
  • Verify: the app logs [auth] discovered OIDC issuer: ….

Invalid issuer / "Import from discovery" fails or fills the wrong values (POS)

  • Cause: Dex's issuer: is still http://localhost:5556/dex, so LockMCP's Keycloak can't use it, and the iss in its tokens doesn't match the public URL.
  • Fix: set issuer: https://<dex-host>/dex, restart Dex, then Import from discovery again and Re-provision.
  • Verify: curl https://<dex-host>/dex/.well-known/openid-configuration shows the public issuer, and Test connection says Reachable.

Incorrect client secret (invalid_client)

  • Cause: the secret differs between Dex and its client: the app (DEX_CLIENT_SECRET / OIDC_CLIENT_SECRET) or LockMCP (step 3 Client secret for mcphub-pos).
  • Fix: put the same value in both places. In LockMCP, enter it again and Save (a blank field keeps the stored secret), then Re-provision. For real estate, also re-render and recreate Dex.
  • Verify: sign-in completes.

TE sign-in ends on a Keycloak error after Dex

  • Cause: no invited LockMCP account has the Dex email, or the invitation wasn't accepted. LockMCP only links federated logins to existing accounts.
  • Fix: invite that exact email, accept the invitation, and sign in again.
  • Verify: Members & Access lists the user as active.

Reachability

Cloudflare 502 Bad Gateway / "Unable to reach the origin service"

  • Cause: the app or Dex isn't running, or ingress points at the wrong port (JHipster 8080 prod / 8085 dev; real estate 3001 and 5556; POS 3000 and 5556).
  • Fix: start the service. Check curl http://localhost:<port> on the host, then fix config.yml and restart cloudflared tunnel run.
  • Verify: curl https://<host>/… returns the app's response.

LockMCP can't reach the app (domain check fails, Test endpoints red, tool calls time out)

  • Cause: the Base URL is not public HTTPS, resolves to a private IP, the tunnel is down, or a Render free instance is asleep.
  • Fix: use the named-tunnel or Render hostname as the Base URL (wizard step 2, Save). Keep cloudflared running. Warm up Render first.
  • Verify: curl https://<app-host>/… from another network; Test endpoints shows green.

Domain verification fails

  • Cause (well-known): the token doesn't match, the response isn't application/json, the request redirects (redirects are refused), or it returns 404 (JHipster without MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN).
  • Cause (DNS): the record isn't at _mcp-verify.<app-host>, or hasn't propagated.
  • Fix: curl https://<app-host>/.well-known/mcp-hub-verification.json must print {"token":"<exactly the value shown>"}. For DNS, check with nslookup -type=TXT.
  • Verify: Check now shows Verified.

Tenant Delegation

HMAC mismatch: exchange/validate return 401 {"error":"unauthorized"}; provisioning fails with "rejected the hub's HMAC signature … Check that MCP_CONTRACT_HMAC_KEY…"

  • Cause: (a) the key differs from LockMCP's (rotated, or a typo), (b) the URL registered in LockMCP differs from the one the app sees (www., http, path), (c) the clock is more than 300 s off, or (d) the app runs in dev, where application-secret-samples.yml overrides your environment variables.
  • Fix: use the key exactly as LockMCP showed it. If it was lost, Rotate HMAC key and update the app. Register URLs exactly as public. Sync the clock. Use the prod profile.
  • Verify: the signed-request snippet in 3.7 returns {"valid":false,"remaining_seconds":0}.

App won't start: MCP_CONTRACT_HMAC_KEY is not set / …must be exactly 64 hexadecimal characters / …still the sample placeholder value

  • Fix: set the 64-hex key from LockMCP, with no quotes or spaces.
  • Verify: the log shows MCPHub contract HMAC key validated for non-development profile.

"The callback URL is not permitted for this tenant." on /mcp-login

  • Fix: set MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS=lockmcp.ai,www.lockmcp.ai (hosts only), then restart.
  • Verify: the login form appears.

/mcp/login shows the normal JHipster UI

  • Cause: wrong path. The Login URL is /mcp-login.

Certification fails on latency

  • Cause: validate took over 2 s (Render cold start, slow tunnel).
  • Fix: warm the app up and Re-provision.

Users were not imported from /api/mcp/users

  • Cause: that request isn't signed with the connection's key, so the app returns 401 (see 3.5).
  • Fix: leave User List Endpoint empty. Users appear on first sign-in.

Tokens, roles, tools

Invalid or expired token

  • TD: the app session expired (MCP_CONTRACT_SESSION_TTL_SEC, default 3600). Set the Refresh URL (3.8) or sign in again.
  • IB: MCPHUB_VERIFY_URL is wrong, or the user has no role on the connection.
  • TE: the broker flag is off, the user hasn't signed in since it was enabled, or Dex restarted (memory storage).
  • Verify: the corresponding call in section 8 succeeds.

Tool not visible

  • Causes, in order of likelihood: the tool isn't published; no role of yours on this connection grants it; you connected to another connection's URL; for TD the app's role isn't in Role mapping, or its toolScopes doesn't include that operationId; the connection isn't Live.
  • Fix: Tool Catalog (publish), AI Tool Access / Role assignments, or Role mapping. For TD, Save Changes on the connection to re-import roles.
  • Verify: reconnect the client. Tool lists are cached briefly, so if it doesn't appear at once, reconnect again.

Tool call returns 401 / 403

Status / bodyMeaningFix
LockMCP refuses (tool not in your list)no role grants itgrant the tool to a role you hold
401 {"error":"session_expired"} (JHipster)app session gonesign in again; set the Refresh URL
403 (JHipster)app role lacks access (e.g. ROLE_USER on /api/admin/**)expected; or remove the scope from that role
401 {"error":"Invalid or expired token"} (real estate)/oauth/verify said nofix MCPHUB_VERIFY_URL; give the user a connection role
401 {"error":"Invalid or expired token"} (POS)Dex didn't accept the tokenbroker flag / sign in again
403 … requires the MANAGER role (POS)POS role is CASHIERchange users.role
403 … not a registered user (POS)email missing from usersinsert it

Enum/scope mismatch: a role's imported toolScopes matches nothing

  • Cause: an operationId in application.yml doesn't exist in the uploaded spec, or the tools aren't published yet.
  • Fix: compare curl https://<app-host>/api/mcp/roles with the operationIds in mcp-openapi.yaml, then publish and re-save the connection.
  • Saved, but … matches no x-mcp-inject marker (Tool Studio): the inject key you typed doesn't exist on that tool's parameters. Only the four bank-account tools that take an id carry bankAccountIds (see 7.2).
  • Scoped tool lists enum: []: the user owns no bank accounts (JHipster's in-memory database lost the ownership on restart), or ROLE_USER isn't in the connection's role mapping, or the AI client is still on a session from before the app restarted (reconnect).

Databases and Docker

The server does not support SSL connections (POS): add ?sslmode=disable to DATABASE_URL for a local Postgres.

required variable … is missing a value: set it in .env (JHipster Compose): run Compose with --env-file .env.

port is already allocated / EADDRINUSE: another process holds the port (JHipster 8080/8085/5432, real estate 3001/5556/5433, POS 3000/5556/5432). Real estate and POS both use 5556 for Dex and can't run side by side unchanged. Change the host-side port, then update the matching PUBLIC_* URL, redirect URI and tunnel.

render-dex-config: X is referenced in the template but missing from .env: add X to .env; compare with .env.example.

Render: Connection refused / UnknownHostException to the DB: use the internal hostname, in the same region, in jdbc:postgresql:// form.

/bin/sh^M: bad interpreter running ./mvnw: Windows line endings; run sed -i 's/\r$//' mvnw.

Container restarts or unhealthy: check docker compose ps, then docker compose logs <service>. The first JHipster start takes 30–60 s.


11. Security notes

  • Never commit .env, rendered dex/config.yaml, dex-public.yaml or any secret. All three repos git-ignore .env. Real estate also ignores dex/config.yaml.
  • Don't reuse secrets published in the demo repos. The JHipster repo contains sample HMAC keys, a JWT secret and verification tokens in application-secret-samples.yml, and the real-estate app hard-codes a verification token. Generate your own (openssl rand -hex 32 / -hex 24 / -base64 64). Rotate any secret that was ever pushed: deleting it later doesn't remove it from git history.
  • HMAC key handling. LockMCP shows the key once. Store it only in the app's environment (Render variables or .env). If it leaks, Rotate HMAC key in LockMCP and update the app. Existing sessions stop working until both sides match.
  • Change demo credentials before anything is publicly reachable: JHipster admin/admin and user/user exist in every profile, and the Dex sample hash is password.
  • HTTPS everywhere public. LockMCP requires https:// Base URLs and Tenant Delegation endpoints. The tunnel or Render terminates TLS.
  • Exact redirect URIs and issuers. Register exact values, never wildcards. Check Dex's issuer equals the public URL LockMCP uses.
  • Protect client secrets. mcphub-pos's secret lives in Dex's config and in LockMCP (stored encrypted, never shown again). Treat both as credentials.
  • Least privilege. Give each connection role only the tools it needs. Remember that tool access in LockMCP doesn't replace the app's own checks: JHipster still 403s ROLE_USER on admin endpoints, and POS still checks users.role.
  • Authentication vs authorization. Signing in proves who (app login, LockMCP account, or Dex). LockMCP roles decide which tools. The app decides what data. All three layers must agree.
  • Unauthenticated endpoints by design: JHipster /api/mcp/roles and the well-known file; real-estate /api/listings*. Consider an IP allowlist at your proxy for directory endpoints.
  • Debug logging in the JHipster demo: some MCP filters and controllers log session tokens and auth codes at INFO (marked DEBUG ONLY in the code). Don't ship those logs anywhere shared, and remove the lines before any real deployment.
  • Nonce reuse: the JHipster demo doesn't track nonces, so replay protection is only the ±300 s timestamp window.

12. Comparison table

FeatureTenant DelegationIdentity BrokeringToken Exchange
Demo applicationJHipster 9 banking sample (Java 21, Spring Boot 4, Angular)Real-estate listings (Node, Express, PostgreSQL)Point of sale (Node 20.6+, Express, PostgreSQL)
Identity providerThe app itself (its own users and passwords)LockMCP's Keycloak (hub accounts). Dex + GitHub only for the websiteYour Dex (static users, or GitHub via a connector), federated into LockMCP's Keycloak
Main authentication flowLockMCP → app /mcp-login → code → HMAC-signed exchangeLockMCP sign-in with an invited hub accountLockMCP Keycloak → Dex → linked to the invited hub account
Token flowThe app's session token mcp:<hex> is forwarded; LockMCP re-validates it with signed validateThe LockMCP token is forwarded; the app asks /<tenant>/<conn>/oauth/verifyThe stored Dex access token is forwarded (broker flag on); the app checks Dex /userinfo
User managementIn the app (Administration → User management); LockMCP users are created on first loginLockMCP invitations or Role assignmentsLockMCP invitations + Dex users + the POS users table (same email in all three)
RolesROLE_ADMIN, ROLE_USER (+ optional ROLE_MANAGER), imported and mappedNone in the app; LockMCP roles you define (e.g. Browser, Saver)CASHIER, MANAGER in the app; mirrored as LockMCP roles
ScopestoolScopes per role from /api/mcp/roles → automatic tool grantsTools ticked per LockMCP roleTools ticked per LockMCP role
Enum injectionMarker bankAccountIds; app sends enum_scopes (accounts the user owns), used once a role has the inject key (7.2)not usednot used
Public deploymentDocker Compose, jar, or Render; one hostnameDocker Compose; two hostnames (app + Dex); Render+Neon sketched onlyApp via Node or Compose + Dex via docker run; two hostnames
Cloudflare Tunnel1 ingress rule → localhost:8080 (prod) / 8085 (dev)2 rules → localhost:3001, localhost:55562 rules → localhost:3000 (or 3002 with Compose), localhost:5556
Domain verificationWell-known file (MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN)Well-known file (edit server.js) or DNS TXTDNS TXT only
LockMCP configurationLogin/Exchange/Validate URLs (+ Refresh/Logout via API), HMAC key, Role List Endpoint, role mappingBase URL only; roles and tools by handOIDC discovery/JWKS/auth/token URLs, client mcphub-pos + secret; roles and tools by hand
Hub-side prerequisitesnonenoneMCP_SERVER_BROKER_TOKEN_EXCHANGE_ENABLED=true, then re-provision and sign in again