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 LockMCP | Demo app | Repository |
|---|---|---|
Tenant Delegation (TENANT_DELEGATED) | JHipster banking sample | https://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:
- Turns the API into MCP tools. You upload the app's OpenAPI file. Every operation becomes a tool named
<connection>_<operation_id>, for examplepos_list_products. - Gives each app its own MCP endpoint, for example
https://mcp.lockmcp.ai/<tenant>/<connection>/mcp. - Authenticates the person behind the AI client. How it does this is the identity model, described below.
- 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.
- 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
| Term | Meaning |
|---|---|
| Tenant (workspace) | Your organisation in LockMCP. It has a slug, shown in your MCP URLs. |
| Connection | One app registered in LockMCP. It has its own slug, Base URL, identity model, tools and roles. |
| Identity model | How users of a connection sign in, and which credential LockMCP forwards to the app. |
| Connection role | A LockMCP role that belongs to one connection and grants tools on it. |
| Hub account | A user's LockMCP sign-in, held in LockMCP's Keycloak (auth.lockmcp.ai). |
The three identity models
| Tenant Delegation | Identity Brokering | Token Exchange | |
|---|---|---|---|
| Who shows the login page | Your app | LockMCP (Keycloak) | Your OIDC provider, federated into LockMCP's Keycloak |
| Account the user signs in with | Their account in your app | Their LockMCP hub account | Their account at your IdP, linked to an invited hub account |
| Credential forwarded to your API | Your app's own session token | The LockMCP (Keycloak) access token | The LockMCP token, or, when the hub's broker flag is on, your IdP's own token |
| How your API checks it | Its own session store | Calls LockMCP's /oauth/verify, or validates the Keycloak JWT | Your IdP (e.g. its /userinfo) |
| Signing between hub and app | HMAC-SHA256 on every back-channel call | none | none |
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
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
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
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 / account | Why |
|---|---|
| Git | Clone the demos |
Docker with Compose v2 (docker compose) | Databases, Dex and (optionally) the apps |
| cloudflared | Publish a local app over HTTPS |
| A domain whose DNS is managed by Cloudflare | Named tunnels need it, and a stable hostname is required (see below) |
| A LockMCP account at https://lockmcp.ai | Sign 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 client | Claude (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:
# 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 --versionPer demo
Install only what the demo you chose needs.
| JHipster (Tenant Delegation) | Real estate (Identity Brokering) | POS (Token Exchange) | |
|---|---|---|---|
| Runtime on your machine | JDK 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) |
| Database | H2 in memory (dev) or PostgreSQL (Docker / Render) | PostgreSQL 16 (in its Compose file) | PostgreSQL 16 (docker run) or a hosted Postgres |
| Identity provider | none, the app is its own | Dex (in its Compose file) + a GitHub OAuth App for the app's own browser login | Dex (docker run), with a static test user or a GitHub connector |
| Public hostnames | 1 (the app) | 2 (the app and Dex) | 2 (the app and Dex) |
| Optional | Render account | — | — |
Your values worksheet
Fill this in once. The rest of the guide refers to these names.
| Name in this guide | What it is | Example |
|---|---|---|
<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 domain | example.com |
<app-host> | The app's public hostname | jhipster.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>) | Method | Purpose | Signed? |
|---|---|---|---|
/mcp-login (dash, not /mcp/login) | GET (browser) | Login page LockMCP redirects users to | no |
/api/mcp/login | POST (form) | The login form's target; redirects back to LockMCP with code + state | no |
/api/mcp/exchange | POST | {code, code_verifier} → {user_id, email, session_token, expires_in, roles, toolScopes, display_name} | HMAC |
/api/mcp/validate | POST | {session_token} → {valid, remaining_seconds, roles, toolScopes} | HMAC |
/api/mcp/refresh | POST | {session_token} → {session_token, expires_in} (rotates the token) | HMAC |
/api/mcp/logout | POST | {session_token} → {ok: true} | HMAC |
/api/mcp/users | GET | Users directory: [{email, name}] | HMAC |
/api/mcp/roles | GET | Roles directory: [{name, toolScopes}] | no |
/.well-known/mcp-hub-verification.json | GET | {"token": "<your token>"} for domain verification | no |
/management/health | GET | Health check | no |
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_URLfromX-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:
| Login | Password | Roles | |
|---|---|---|---|
admin | admin | admin@localhost | ROLE_ADMIN, ROLE_USER |
user | user | user@localhost | ROLE_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:
| Role | In the database? | Scopes (operationIds) |
|---|---|---|
ROLE_ADMIN | yes | all 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_USER | yes | getAccount, saveAccount, changePassword, getAllUsers, createUser, updateUser, getUser, deleteUser |
ROLE_MANAGER | no, create it first (see 3.10) | getAccount, saveAccount, changePassword, getAllUsers |
- A user's
toolScopesis the union of their roles' scopes, soadmingetsROLE_ADMIN+ROLE_USER. /api/mcp/rolesreports only roles that exist in the database.- Scopes are not permissions. The app still enforces its own rules.
getAllUsers,createUser,updateUser,getUseranddeleteUsercall/api/admin/users, which requiresROLE_ADMIN. AROLE_USERsession is offered those tools but gets 403 Forbidden when it calls them. That is useful for testing (see section 8). ROLE_USERis given no bank-account, label or operation scopes. LockMCP will not showuserthose tools, even though the app itself would letuserread them.
3.3 Clone
Run on your machine (the host), in any terminal:
git clone https://github.com/aircwou/jhipster-mcp-demo.git
cd jhipster-mcp-demoWindows: 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.
# host
CYPRESS_INSTALL_BINARY=0 ./mvnw spring-boot:run -Pdev,webapp -ntp# host, PowerShell
$env:CYPRESS_INSTALL_BINARY = "0"; .\mvnw.cmd spring-boot:run "-Pdev,webapp" -ntpOpen 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:
| Option | Command | URL |
|---|---|---|
| Live reload (two terminals) | ./npmw run backend:start and ./npmw start | http://localhost:9000 |
prod profile as a jar | see 3.6 B | http://localhost:8080 |
Docker Compose (prod + PostgreSQL) | see 3.6 A | http://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.
Sign in at https://lockmcp.ai. Open Connections, then Add Connection:
Field Value Name jhipsterBase URL https://<app-host>(no trailing slash)Identity Model Tenant Delegation User List Endpoint leave empty (see the note below) Role List Endpoint /api/mcp/rolesClick Create Connection. The two list endpoints are paths relative to the Base URL.
Select the new connection, then Continue setup. This opens the onboarding wizard (Identity & access → Domain verification → Identity setup → Provision → Activate).
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.Step 2 · Domain verification: choose Well-known file. Copy the
tokenvalue from File contents. This isMCP_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:
# host (Git Bash, macOS, Linux)
openssl rand -base64 64 | tr -d '\n' # JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRETMCP_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.
# host, repo root
cp .env.example .envEdit .env:
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:
# 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.
# host
curl http://localhost:8080/management/health # expect {"status":"UP",...}
docker compose --env-file .env -f src/main/docker/app.yml logs -f appIn 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
# 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:8080C. Render (hosted, no tunnel needed)
Render has no native Java runtime, so it builds the repo's Dockerfile.
Push the repo to your own GitHub account; Render deploys from a remote.
Render → New → Postgres. Note the internal Hostname, Port, Database, Username and Password.
Render → New → Web Service. Choose Language Docker, Branch
main, Dockerfile Path./Dockerfile, the same region as the database, and at least 512 MB RAM.Environment variables:
Key Value SPRING_DATASOURCE_URLjdbc:postgresql://<internal-hostname>:5432/<database>(thejdbc:form, not Render'spostgresql://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.aiMCP_CONTRACT_DOMAIN_VERIFICATION_TOKENthe token from LockMCP Don't set
PORT; Render provides it.Advanced → Health Check Path:
/management/health.Deploy. Wait for
Application 'jhipsterSampleApplication' is running!in the logs. Your public base URL ishttps://<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.
# 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.comCreate ~/.cloudflared/config.yml (Windows: %USERPROFILE%\.cloudflared\config.yml):
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# host, keep it running
cloudflared tunnel ingress validate
cloudflared tunnel run jhipster-demoOne 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):
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):
# 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 wrong3.8 Finish the LockMCP connection
Back in the wizard (Connections → jhipster → Continue setup):
Step 2 · Domain verification: method Well-known file, then Check now. It should show Verified.
Step 3 · Identity setup: enter the endpoints, then Save endpoints:
Field Value Login URL https://<app-host>/mcp-loginExchange URL https://<app-host>/api/mcp/exchangeValidate URL https://<app-host>/api/mcp/validateClick Test endpoints. Each should show a green dot and an HTTP status.
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):jsawait 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.
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
200with hiddencallback_url/code_challengefields, 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.Step 5 · Activate: Activate connection. It should show Live — MCP endpoint serving.
3.9 Roles, role mapping and tools
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(slugrole-admin) andROLE_USER(slugrole-user), and stores each role'stoolScopes. Check them under Members & Access.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_ADMINROLE_USERROLE_USERThe mapping is an allowlist. A role the app returns from
exchangethat isn't mapped grants nothing in LockMCP.Upload the contract. API Specs → Upload Spec: choose connection
jhipster, paste the contents ofmcp-openapi.yamlfrom the repo root, and continue through the two steps. LockMCP creates 31 draft tools. The spec'sservers: http://localhost:8080doesn't matter: tool calls always go to the connection's Base URL.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 itstoolScopes.Users. Nothing to invite. When someone signs in through
/mcp-login, LockMCP creates the LockMCP user from theexchangeresponse (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)
- Sign in to the app as
admin. Open Administration → Authority (/authority) and createROLE_MANAGER. Don't editliquibase/data/authority.csvon an existing database; that causes a Liquibase checksum error. - Administration → User management → Edit user → Profiles: tick
ROLE_MANAGERfor a user. - 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. - 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:
| Path | Who uses it | Chain |
|---|---|---|
| The app's own browser login | People using the website | Browser → app (/login) → Dex → GitHub → Dex → ID token → app (/callback) → session cookie |
| LockMCP (Identity Brokering) | AI clients | AI 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 callsMCPHUB_VERIFY_URLwith the sameAuthorization: Bearerheader. LockMCP answers{"authorized": true, "email": "…"}only when the token is valid and the user holds a role on this connection. Otherwise it answers401 {"authorized": false}, and the app returns401 {"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/listingsand/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
# host
git clone https://github.com/aircwou/realestate-demo-oidc-mcp.git
cd realestate-demo-oidc-mcpHost 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:
| Field | Local setup | Public setup |
|---|---|---|
| Homepage URL | http://localhost:3001 | https://<app-host> |
| Authorization callback URL | http://localhost:5556/dex/callback | https://<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
# host
cp .env.example .env| Variable | Value | Used by |
|---|---|---|
PUBLIC_DEX_URL | http://localhost:5556 locally; https://<dex-host> when public (no trailing slash) | Dex issuer $PUBLIC_DEX_URL/dex, GitHub callback |
PUBLIC_APP_URL | http://localhost:3001 locally; https://<app-host> when public | app redirect URI $PUBLIC_APP_URL/callback |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | from 4.3 | Dex's GitHub connector |
DEX_CLIENT_SECRET | random | shared by the app and Dex client realestate-app |
SESSION_SECRET | random | the app's session cookie |
MCPHUB_DEX_CLIENT_SECRET, POS_DEX_CLIENT_SECRET, MCPHUB_POS_CLIENT_SECRET | random | other Dex clients this app doesn't use; they must be set or the render script refuses to run |
Generate each random value separately:
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:
# host (needs Node 18+)
node render-dex-config.js
# [render-dex-config] wrote dex/config.yaml from dex/config.yaml.template4.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:
MCPHUB_VERIFY_URL: https://mcp.lockmcp.ai/<tenant>/realestate/oauth/verify2. 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:
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
# 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:
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 --build4.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.
# 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:
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# host, keep it running
cloudflared tunnel run realestate-demoThen switch the configuration to the public URLs:
- GitHub OAuth App callback:
https://<dex-host>/dex/callback, Homepagehttps://<app-host>. Use a second OAuth App if you want to keep the local one. .env:PUBLIC_DEX_URL=https://<dex-host>andPUBLIC_APP_URL=https://<app-host>.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):
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)
Connections → Add Connection:
Field Value Name realestateBase URL https://<app-host>Identity Model Identity Brokering User / Role List Endpoint leave empty (the app has neither) Open the connection's panel and copy its MCP Server URL (
https://mcp.lockmcp.ai/<tenant>/realestate/mcp). Finish 4.5 with it, then rundocker compose up -d --build app.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
TXTrecord named_mcp-verify.<app-host>with the value shown, then Check now.
- Well-known file: put the token into
Step 3 · Identity setup shows "No extra setup — users sign in via the hub Keycloak realm."
Step 4 · Provision → Run provisioning. This creates this connection's clients in LockMCP's Keycloak. Then Step 5 · Activate connection.
Tools. API Specs → Upload Spec: connection
realestate, pastedexdemo-openapi.yaml. Then Tool Catalog: publish the four tools:Tool Calls Needs 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.
Members & Access → Create a role:
Browser: Applies torealestate. Tools:realestate_list_listings,realestate_get_listing.Saver: Applies torealestate. Tools: all four.
Leave the dashboard permissions empty; these roles only grant tools.
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:
realestatewithSaver. 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.
- You: Members & Access → your row → Role assignments →
A user with
Browseronly sees two tools. LockMCP also answers{"authorized": false}to/oauth/verifyfor 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.
| Setting | Value | Where it comes from |
|---|---|---|
| OIDC issuer | $PUBLIC_DEX_URL/dex | dex/config.yaml.template |
| Discovery URL (browser view) | $PUBLIC_DEX_URL/dex/.well-known/openid-configuration | standard Dex path |
| Discovery URL (app, inside Docker) | http://dex:5556/dex | OIDC_DISCOVERY_URL in docker-compose.yml |
| Client ID / secret | realestate-app / $DEX_CLIENT_SECRET | compose + template |
| App redirect URI | $PUBLIC_APP_URL/callback | OIDC_REDIRECT_URI; must equal the client's redirectURIs |
| Dex → GitHub callback | $PUBLIC_DEX_URL/dex/callback | GitHub OAuth App + connector redirectURI |
| Scopes requested | openid email profile | app/src/auth.js |
| Claims used | email (data key), sub, name, preferred_username | app/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 requestsopenid email profile, checks the ID token, and then looks up the email in its ownuserstable to get the role. - API / MCP calls: the app accepts
Authorization: Bearer <token>. It checks the token only by calling Dex'suserinfoendpoint. It then looks up the returned email inusers: an unknown email gets403, 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:
| Layer | What happens | Credential |
|---|---|---|
| Browser login (website only) | pos-app code flow against Dex | Dex ID token → app session cookie |
| OIDC authentication (AI client) | Keycloak brokers to Dex using client mcphub-pos | Dex tokens, kept by Keycloak |
| LockMCP authorization | Is 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 app | Dex access token |
| Backend authorization | POS 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.
# 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-alpineCreate dex-local.yaml in the repo root:
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"# 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.yamlIn 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:
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.
# 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 :3000Give the test login a role (in a second terminal):
# 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.
| Route | Cashier | Manager | OpenAPI 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:
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:
# 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# ~/.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# host, keep it running
cloudflared tunnel run pos-demo2. 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.
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-idpis 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>withopenssl rand -hex 24. - Use email addresses you can receive mail at for
<manager-email>and<cashier-email>(plus-addressing likeyou+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. memorystorage 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:
# 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:
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=3000Restart 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:
# 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:
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)
Connections → Add Connection:
Field Value Name posBase URL https://<app-host>Identity Model Token Exchange User / Role List Endpoint leave empty Continue setup → Step 2 · Domain verification. The POS app serves no well-known file, so use DNS TXT record. In Cloudflare DNS, add a
TXTrecord named_mcp-verify.<app-host>with the value shown. Check it propagated, then click Check now:bashnslookup -type=TXT _mcp-verify.<app-host> # Windows/macOS/Linux dig +short TXT _mcp-verify.<app-host> # macOS/LinuxStep 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 tohttps://<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.
- Discovery document URL:
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.Step 5 · Activate connection.
Tools. API Specs → Upload Spec (connection
pos, pastepos-openapi.yaml). Then Tool Catalog → publish all sevenpos_*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 |
|---|---|
Cashier | pos_list_products, pos_ring_up_sale, pos_list_my_sales |
Manager | all 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(orCashier). - 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.
| # | Step | Where in LockMCP | TD (JHipster) | IB (real estate) | TE (POS) |
|---|---|---|---|---|---|
| 1 | Sign in or sign up | https://lockmcp.ai (/signup creates a workspace) | ✔ | ✔ | ✔ |
| 2 | Workspace (tenant) | Created at signup or by invitation. Its slug is in your MCP URLs | ✔ | ✔ | ✔ |
| 3 | Invite users | Members & Access → Invite Member | not needed: users are created on first app login | invite, or use Role assignments on yourself | invite, or use Role assignments; the email must match Dex |
| 4 | Create the application | Connections → Add Connection (Name, Base URL https://…) | ✔ | ✔ | ✔ |
| 5 | Connection lifecycle | Continue setup: wizard steps 1–5. Status goes Not started → Verifying → Provisioning → Provisioned → Live | ✔ | ✔ | ✔ |
| 6 | Identity model | Add Connection dialog, or wizard step 1 | Tenant Delegation | Identity Brokering | Token Exchange |
| 7 | Provider details | Wizard step 3 | Login / Exchange / Validate URLs, HMAC key, role mapping | none | OIDC discovery, JWKS, auth + token URLs |
| 8 | Endpoint URLs | Step 3 (+ API for refresh/logout); Role List Endpoint in the connection dialog | 3 (+2 optional) + /api/mcp/roles | — | — |
| 9 | Client ID / secret | Step 3 | the HMAC key instead | none | mcphub-pos + secret |
| 10 | Enums | Tool Studio or AI Tool Access → Inject keys | optional, see 7 | not used | not used |
| 11 | Scopes | Role List Endpoint toolScopes (TD), or tools ticked per role | imported from the app | by hand | by hand |
| 12 | Roles / permissions | Members & Access → Create a role (Applies to: the connection) | imported + Role mapping | Browser, Saver | Cashier, Manager |
| 13 | Save and validate | Step 2 Check now, step 3 Test endpoints / Test connection, step 4 Run provisioning, step 5 Activate | ✔ | ✔ | ✔ |
| 14 | Authenticate as a test user | Connect an AI client to the connection's MCP URL (8.1) | app login (admin/admin) | LockMCP email + password | Dex login |
| 15 | Discover tools | the AI client's tool list (tools/list) | per mapped role | per role | per role |
| 16 | Call tools | the AI client, or MCP Inspector | ✔ | ✔ | ✔ (needs the broker flag) |
| 17 | Verify authorization | section 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 examplegetAllBankAccounts→jhipster_get_all_bank_accounts. - Tool calls go to the connection's Base URL plus the operation's path. The spec's
serversentry 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:
- Declared by the app (Tenant Delegation, JHipster). The Role List Endpoint returns each role with
toolScopes, a list of OpenAPIoperationIds. 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:getAllBankAccountsmatches tool operationIdget_all_bank_accounts. A role that declares no scopes keeps whatever you assigned by hand. - Assigned by hand (IB and TE demos). Tick the tools when you Create a role, or later in AI Tool Access.
| Demo | Role → tools (operationIds) | Source |
|---|---|---|
| JHipster | ROLE_ADMIN → all 31; ROLE_USER → getAccount, saveAccount, changePassword, getAllUsers, createUser, updateUser, getUser, deleteUser; ROLE_MANAGER → getAccount, saveAccount, changePassword, getAllUsers | application.yml → /api/mcp/roles |
| Real estate | Browser → listListings, getListing; Saver → all four | your choice (the app has no roles) |
| POS | Cashier → listProducts, ringUpSale, listMySales; Manager → all seven | mirrors 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 eightROLE_USERtools. curl https://<app-host>/api/mcp/rolesshows 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:
{
"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 model | Source |
|---|---|
| Tenant Delegation | enum_scopes returned by your app in its exchange / validate response |
| Identity Brokering / Token Exchange | a Keycloak user attribute, set from Members & Access → Record scope, the record-scope API, or the tenant IdP |
Per demo:
| Demo | Marker in the spec | Payload available? | Result |
|---|---|---|---|
| JHipster | x-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_account | Yes. 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 estate | none | — | not applicable |
| POS | none | — | not applicable |
So no enum configuration is required to run the demos. What JHipster sends looks like this:
"enum_scopes": {
"email": "user@localhost",
"accounts": [ { "accountId": 2, "roles": [ { "role": "ROLE_USER", "bankAccountIds": [1, 2] } ] } ]
}To see it work with JHipster:
- Give the user some accounts. In the JHipster UI as
admin: Entities → Bank Account, edit accounts 1 and 2, set User touser, save. The seeded accounts have no owner, so without thisbankAccountIdsis empty and every scoped call is refused. With thedevprofile the database is in memory: redo this after each restart. - Map the role. Connections →
jhipster→ Onboarding → step 3 → Role mapping:ROLE_USER→ROLE_USER(slugrole-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). - Grant the tool and the key.
ROLE_USER'stoolScopesinclude no bank-account operations, so grant it by hand: Tool Studio →jhipster_get_bank_account→ role access →ROLE_USER→ allow, Inject keysbankAccountIds. Publishing re-appliestoolScopesand removes this grant; grant it again after a publish, or add the operations under'[ROLE_USER]'in the app'sapplication.yml. - Reconnect the AI client and sign in as
user/user.
| Try | Expected |
|---|---|
list_available_tools | jhipster_get_bank_account: id has enum: [1, 2] and is required |
jhipster_get_bank_account, id 1 | account 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 again | account 3: each session check sends the current list |
Update tools:
updateBankAccountandpartialUpdateBankAccounthave anidin 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 likegetanddelete. A spec imported before this was handled maps thatidto the body only and every call fails: re-sync the spec and publish.Not scoped:
jhipster_get_all_bank_accountshas 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):Choose transport Streamable HTTP, paste the MCP URL, and connect. It runs the OAuth sign-in in your browser.bash
npx @modelcontextprotocol/inspector
What the sign-in looks like:
| Demo | Browser goes to | Sign in as |
|---|---|---|
| JHipster | https://<app-host>/mcp-login?... (the app's own page) | admin / admin or user / user |
| Real estate | LockMCP's sign-in page (auth.lockmcp.ai) | your LockMCP email and password |
| POS | LockMCP'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.
curl -i https://mcp.lockmcp.ai/<tenant>/pos/mcp # → 401 + WWW-Authenticate: Bearer resource_metadata="…"The bodies a client sends after initialising the session:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }{ "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 listed | all 31 jhipster_* tools | 8: 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:
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 Saver | Role Browser | |
|---|---|---|
| Tools listed | realestate_list_listings, realestate_get_listing, realestate_list_my_saved_listings, realestate_toggle_saved_listing | realestate_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"}.
curl -i https://mcp.lockmcp.ai/<tenant>/realestate/oauth/verify
# HTTP/1.1 401 ... {"authorized":false}8.5 POS (Token Exchange)
LockMCP Cashier + POS CASHIER | LockMCP Manager + POS MANAGER | |
|---|---|---|
| Tools listed | pos_list_products, pos_ring_up_sale, pos_list_my_sales | all 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
| # | Steps | Expected |
|---|---|---|
| TD-1 | Connect an AI client to /<tenant>/jhipster/mcp and sign in as admin | The browser shows the app's /mcp-login page. After sign-in it returns to the client. Sessions in LockMCP shows a new session. |
| TD-2 | List tools as admin | 31 tools |
| TD-3 | Call jhipster_get_all_bank_accounts | 200, bank-account JSON |
| TD-4 | Reconnect as user, list tools | 8 tools, none for bank accounts / labels / operations |
| TD-5 | As user, call jhipster_get_all_users | The app returns 403 |
| TD-6 | Rotate the HMAC key in LockMCP (step 3) without updating the app, then sign in again | Login fails: the exchange is rejected (401 from the app → "The tenant rejected the exchange") |
| TD-7 | Remove lockmcp.ai from MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS, restart, sign in | The app shows "The callback URL is not permitted for this tenant." |
| TD-8 | Remove the ROLE_USER row from Role mapping, sign in as user | No mapped roles, so no tools listed |
9.2 Identity Brokering
| # | Steps | Expected |
|---|---|---|
| IB-1 | Connect to /<tenant>/realestate/mcp, sign in with your LockMCP account | LockMCP sign-in page; no Dex or GitHub involved |
| IB-2 | With Saver: toggle listing 1, then list saved | {"saved":true,"listingId":1}, then listing 1 appears |
| IB-3 | Sign in to https://<app-host> with a GitHub account that has the same email | Listing 1 is in Saved listings |
| IB-4 | Switch your role to Browser, reconnect | Only 2 tools listed |
| IB-5 | Remove every role on realestate, reconnect | No tools; /oauth/verify answers {"authorized":false} |
| IB-6 | Set a wrong MCPHUB_VERIFY_URL (e.g. another connection slug), rebuild the app | Saved-listing tools return 401 {"error":"Invalid or expired token"}; public listing tools still work |
9.3 Token Exchange
| # | Steps | Expected |
|---|---|---|
| TE-1 | Connect to /<tenant>/pos/mcp | The browser goes through LockMCP's Keycloak straight to https://<dex-host>/dex |
| TE-2 | Sign in at Dex as <cashier-email> (invited, accepted, LockMCP role Cashier) | Back in the client, with 3 tools listed |
| TE-3 | Call pos_list_products | 200, 12 seeded products (broker flag on) |
| TE-4 | Call pos_ring_up_sale, then pos_list_my_sales | 201, then the sale is listed |
| TE-5 | Sign in at Dex with an email that has no LockMCP invitation | Sign-in fails at Keycloak; no account is created |
| TE-6 | LockMCP Manager, POS CASHIER: call pos_get_sales_report | App 403 "requires the MANAGER role" |
| TE-7 | Delete the user's row from users, call any tool | App 403 "is not a registered user" |
| TE-8 | Restart Dex (memory storage), call a tool without signing in again | Fails: 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'shttps://auth.lockmcp.ai/realms/mcp-hub/broker/<tenant>-pos-idp/endpoint(registered in Dex formcphub-pos). - Fix: correct the one in the error. Real estate: update
.env, runnode render-dex-config.js, then recreate the Dex volume. POS: edit the Dex YAML, thendocker rm -f pos-dexand 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
dexdataSQLite volume and ignores later config edits. - Fix:
docker compose stop dex && docker compose rm -f dex, thendocker volume rm <folder>_dexdata, thendocker compose up -d --build. - Verify:
docker compose logs dexshows a fresh start, and the new client works.
OIDC discovery fails (OIDC discovery attempt 5/5 failed, app exits)
- Cause:
OIDC_DISCOVERY_URLis unreachable or isn't the issuer URL, or Dex isn't up. - Fix: open
<OIDC_DISCOVERY_URL>/.well-known/openid-configurationfrom where the app runs. Inside Docker (real estate) that ishttp://dex:5556/dex. For POS it is the public URL. Checkdocker 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 stillhttp://localhost:5556/dex, so LockMCP's Keycloak can't use it, and theissin 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-configurationshows 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 formcphub-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
ingresspoints 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 fixconfig.ymland restartcloudflared 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
cloudflaredrunning. 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 withoutMCP_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.jsonmust print{"token":"<exactly the value shown>"}. For DNS, check withnslookup -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 indev, whereapplication-secret-samples.ymloverrides 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
prodprofile. - 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_URLis 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
toolScopesdoesn'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 / body | Meaning | Fix |
|---|---|---|
| LockMCP refuses (tool not in your list) | no role grants it | grant the tool to a role you hold |
401 {"error":"session_expired"} (JHipster) | app session gone | sign 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 no | fix MCPHUB_VERIFY_URL; give the user a connection role |
401 {"error":"Invalid or expired token"} (POS) | Dex didn't accept the token | broker flag / sign in again |
403 … requires the MANAGER role (POS) | POS role is CASHIER | change users.role |
403 … not a registered user (POS) | email missing from users | insert it |
Enum/scope mismatch: a role's imported toolScopes matches nothing
- Cause: an operationId in
application.ymldoesn't exist in the uploaded spec, or the tools aren't published yet. - Fix: compare
curl https://<app-host>/api/mcp/roleswith the operationIds inmcp-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
idcarrybankAccountIds(see 7.2). - Scoped tool lists
enum: []: the user owns no bank accounts (JHipster's in-memory database lost the ownership on restart), orROLE_USERisn'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, rendereddex/config.yaml,dex-public.yamlor any secret. All three repos git-ignore.env. Real estate also ignoresdex/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/adminanduser/userexist in every profile, and the Dex sample hash ispassword. - 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
issuerequals 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_USERon admin endpoints, and POS still checksusers.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/rolesand 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
| Feature | Tenant Delegation | Identity Brokering | Token Exchange |
|---|---|---|---|
| Demo application | JHipster 9 banking sample (Java 21, Spring Boot 4, Angular) | Real-estate listings (Node, Express, PostgreSQL) | Point of sale (Node 20.6+, Express, PostgreSQL) |
| Identity provider | The app itself (its own users and passwords) | LockMCP's Keycloak (hub accounts). Dex + GitHub only for the website | Your Dex (static users, or GitHub via a connector), federated into LockMCP's Keycloak |
| Main authentication flow | LockMCP → app /mcp-login → code → HMAC-signed exchange | LockMCP sign-in with an invited hub account | LockMCP Keycloak → Dex → linked to the invited hub account |
| Token flow | The app's session token mcp:<hex> is forwarded; LockMCP re-validates it with signed validate | The LockMCP token is forwarded; the app asks /<tenant>/<conn>/oauth/verify | The stored Dex access token is forwarded (broker flag on); the app checks Dex /userinfo |
| User management | In the app (Administration → User management); LockMCP users are created on first login | LockMCP invitations or Role assignments | LockMCP invitations + Dex users + the POS users table (same email in all three) |
| Roles | ROLE_ADMIN, ROLE_USER (+ optional ROLE_MANAGER), imported and mapped | None in the app; LockMCP roles you define (e.g. Browser, Saver) | CASHIER, MANAGER in the app; mirrored as LockMCP roles |
| Scopes | toolScopes per role from /api/mcp/roles → automatic tool grants | Tools ticked per LockMCP role | Tools ticked per LockMCP role |
| Enum injection | Marker bankAccountIds; app sends enum_scopes (accounts the user owns), used once a role has the inject key (7.2) | not used | not used |
| Public deployment | Docker Compose, jar, or Render; one hostname | Docker Compose; two hostnames (app + Dex); Render+Neon sketched only | App via Node or Compose + Dex via docker run; two hostnames |
| Cloudflare Tunnel | 1 ingress rule → localhost:8080 (prod) / 8085 (dev) | 2 rules → localhost:3001, localhost:5556 | 2 rules → localhost:3000 (or 3002 with Compose), localhost:5556 |
| Domain verification | Well-known file (MCP_CONTRACT_DOMAIN_VERIFICATION_TOKEN) | Well-known file (edit server.js) or DNS TXT | DNS TXT only |
| LockMCP configuration | Login/Exchange/Validate URLs (+ Refresh/Logout via API), HMAC key, Role List Endpoint, role mapping | Base URL only; roles and tools by hand | OIDC discovery/JWKS/auth/token URLs, client mcphub-pos + secret; roles and tools by hand |
| Hub-side prerequisites | none | none | MCP_SERVER_BROKER_TOKEN_EXCHANGE_ENABLED=true, then re-provision and sign in again |