Complete Setup Guide

End-to-end guide for deploying suitecrm-mcp v5.x from scratch.

Overview

Auth0 / Azure AD Identity Provider MCP Clients Claude Desktop Claude Code OpenClaw suitecrm-mcp gateway OAuth2 · API keys · SSE SuiteCRM Instances CRM A CRM B CRM X confirms identity (OAuth2) issues API key Bearer token Hybrid v8 GraphQL (v4.1 Fallback)

What the gateway does:

  • Handles OAuth2 login (Authorization Code flow)
  • Issues personal, revocable API keys to authenticated users
  • Provisions CRM accounts via SSH on first login (if configured)
  • Proxies MCP tool calls to the appropriate SuiteCRM instance
  • Stateless Persistence: Auth sessions and user profiles are cached in Redis, enabling horizontal scaling, global rate limiting, and zero-downtime restarts.
  • Smart Hybrid Routing: Automatically routes basic CRUD operations through the blazing-fast v8 GraphQL API, with a transparent failover to the v4.1 REST API for complex SQL searches.

Steps 1-4: Install

1
Identity Provider

Set up Auth0 or Azure AD before installing the gateway. The installer will prompt for the credentials you collect here.

See Identity Provider Setup for step-by-step instructions. Minimum required:

  • Auth0 domain
  • Auth0 client ID and client secret
  • Redirect URI registered: https://mcp.yourcompany.com/auth/callback
2
Prepare the Gateway VM

Requirements: Ubuntu 20.04+ (or Debian 11+), public IP or domain, ports 80 and 443 open, SSH access to CRM VMs if using SSH provisioning.

bash
git clone https://github.com/Anirudhx7/suitecrm-mcp.git
cd suitecrm-mcp
3
Configure Entities
bash
cp entities.example.json entities.json

Edit entities.json:

json
{
  "crm1": {
    "label": "Main CRM",
    "endpoint": "https://crm.yourcompany.com/legacy/service/v4_1/rest.php",
    "port": 3101,
    "group": "CRM-Main"
  }
}
  • endpoint - full REST API URL. If unsure, leave as the base URL and the installer auto-detects the correct path.
  • group - the JWT claim value a user must have to access this entity. Must match a role/group in your identity provider.
  • port - each entity gets its own port (3101, 3102, ...). With nginx these are internal-only.

Finding your REST API path:

bash
for path in /service/v4_1/rest.php /legacy/service/v4_1/rest.php /crm/service/v4_1/rest.php; do
  curl -sf -X POST "https://crm.example.com$path" \
    --data-urlencode 'method=get_server_info' \
    --data-urlencode 'input_type=JSON' \
    --data-urlencode 'response_type=JSON' \
    --data-urlencode 'rest_data={}' | python3 -m json.tool && echo "FOUND: $path" && break
done
4
Install the Gateway
bash
sudo python3 install.py \
  --config entities.json \
  --domain mcp.yourcompany.com \
  --email [email protected]

The installer will: install Node.js, nginx, and certbot; prompt for OAuth2 configuration; generate a random API_KEY_SECRET; write env files to /etc/suitecrm-mcp/; create and start systemd services; configure nginx with entity routing; and obtain a Let's Encrypt certificate.

Non-interactive install (CI/automation):

bash
sudo python3 install.py \
  --config entities.json \
  --domain mcp.yourcompany.com \
  --email [email protected] \
  --oauth-issuer https://your-tenant.auth0.com \
  --oauth-client-id YOUR_CLIENT_ID \
  --oauth-client-secret YOUR_CLIENT_SECRET \
  --oauth-audience https://your-tenant.auth0.com/api/v2/ \
  --gateway-url https://mcp.yourcompany.com

Steps 5-6: CRM Prep

5
Prepare CRM Accounts (LDAP/SSO users only)

If users authenticate via LDAP or SSO, they have no local SuiteCRM password. The gateway needs a local password to call the REST API on their behalf.

Note: For local SuiteCRM users, ensure API access is enabled in SuiteCRM Admin > User Management > Edit User > Advanced tab > API Access = Yes.

Copy tools/crm-provision-user.sh to each CRM VM and run as root:

bash
# Single user
sudo crm-provision-user alice SecurePass123

# Bulk from CSV (username,password)
sudo crm-provision-user --csv users.csv
6
Configure SSH Provisioning (optional)

Lets the gateway automatically provision CRM accounts when users first log in via OAuth.

bash
# On the gateway VM, generate a key if you don't have one
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""

# Copy to each CRM VM
ssh-copy-id -i /root/.ssh/id_ed25519.pub [email protected]

Create /etc/suitecrm-mcp/crm-hosts.json:

json
{
  "crm1": {
    "ssh_host": "crm1.internal",
    "ssh_user": "ubuntu",
    "ssh_key": "/etc/suitecrm-mcp/crm-ssh-key"
  }
}

Add to the entity env file (/etc/suitecrm-mcp/crm1.env):

env
CRM_HOSTS_FILE=/etc/suitecrm-mcp/crm-hosts.json

Restart: sudo systemctl restart suitecrm-mcp-crm1


Steps 7-8: Test & Connect

7
Test the Auth Flow
bash
# Check services are running
sudo python3 install.py --status

# Test gateway health
curl https://mcp.yourcompany.com/health

# Test auth redirect (should return 302 Location: /auth/login)
curl -I https://mcp.yourcompany.com/

# Test OIDC discovery
curl https://YOUR_DOMAIN/.well-known/openid-configuration

Visit https://mcp.yourcompany.com in a browser and complete the login flow. You should see the success page with your API key.

8
Connect Clients

CRM-side Setup

One-time configuration on your SuiteCRM instance before connecting the gateway. Run these steps once per CRM - regardless of how many gateway entities you configure.

Enable API Access per User

Before any user can authenticate via the gateway, their SuiteCRM account must have API access explicitly enabled. Without this, all login attempts return Invalid Login with no useful error message.

  1. Log into SuiteCRM as admin
  2. Go to Admin → User Management → (select the user)
  3. Scroll to "Password" section → tick "Allow API Access" → Save
Don't forget: The mcp_acl_reader service account also needs this enabled if you use ACL enforcement.

Find the REST API Endpoint

SuiteCRM's REST API path varies by installation. Run this probe from your gateway server:

bash
# Replace crm.example.com with your CRM hostname
for path in \
  "/service/v4_1/rest.php" \
  "/legacy/service/v4_1/rest.php" \
  "/crm/legacy/service/v4_1/rest.php" \
  "/crm/public/legacy/service/v4_1/rest.php"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST "https://crm.example.com${path}" \
    -d 'method=get_server_info&input_type=JSON&response_type=JSON&rest_data=%7B%7D')
  echo "$code  $path"
done

The first path returning 200 is your SUITECRM_ENDPOINT.

LDAP / SSO Users

SuiteCRM's v4_1 REST API only authenticates against local database passwords. LDAP and SSO users have no local password and will fail to log in via the gateway.

Use crm-provision-user on the CRM VM (deployed by install.py --setup-crm-host) to set a local API password without affecting the user's LDAP/SSO login:

bash
# SSH into the CRM VM, then:

# Single user
sudo crm-provision-user john.doe secretpassword

# Bulk from CSV (user_name,password)
sudo crm-provision-user --csv /path/to/users.csv

Fix OAuth2 Token Lifetime

SuiteCRM's default OAuth2 token lifetime is 1 minute. MCP sessions run for hours or days and will break constantly unless you extend this.

First, collect your DB credentials from config.php:

bash
grep -E "db_host_name|db_name|db_user_name|db_password" /path/to/suitecrm/public/legacy/config.php

Then set the token lifetime to 30 days:

bash
mysql -h <DB_HOST> -u <DB_USER> -p'<DB_PASS>' <DB_NAME> -e "
UPDATE oauth2clients
SET duration_value=30, duration_amount=30, duration_unit='day'
WHERE deleted=0;"

Full reference including mcp_acl_reader setup: docs/suitecrm-prep.md

Identity Provider Setup

The gateway supports any OIDC-compliant provider. This guide covers Auth0 and Azure AD, the two most common configurations.

Auth0

1. Create a Regular Web Application

  1. Log in to the Auth0 Dashboard
  2. Go to Applications > Applications > Create Application
  3. Name it (e.g. MCP Gateway Client)
  4. Choose Regular Web Application - required for Authorization Code flow
  5. Click Create

2. Configure the application

On the Settings tab:

FieldValue
Allowed Callback URLshttps://mcp.yourcompany.com/auth/callback
Allowed Logout URLshttps://mcp.yourcompany.com
Allowed Web Originshttps://mcp.yourcompany.com

Under Advanced Settings > Grant Types, ensure Authorization Code and Refresh Token are checked. Under Advanced Settings > OAuth, set JWT Signature Algorithm to RS256. Click Save Changes.

On the Connections tab, disable any connections you do not need (e.g. Username-Password, Google) and leave only the enterprise connection you will configure in Step 5 enabled.

3. Create a custom API

Go to Applications > APIs > Create API. Fill in:

FieldValue
NameMCP Gateway (or any descriptive name)
Identifierhttps://mcp.yourcompany.com - this becomes your AUTH0_AUDIENCE
Signing AlgorithmRS256

Click Create. After creating the API, go back to your application, open the API Access tab, and confirm the new API appears with permissions granted.

Why a custom API? Using the Auth0 Management API as audience gives the gateway unnecessary admin permissions. A dedicated API scoped to your gateway is the correct pattern.

4. Note your credentials

From the application Settings tab, copy:

  • Domain (e.g. your-tenant.auth0.com) - this is your AUTH0_DOMAIN
  • Client ID - this is your AUTH0_CLIENT_ID
  • Client Secret - this is your AUTH0_CLIENT_SECRET

For AUTH0_AUDIENCE, use the Identifier you set on your custom API in Step 3 (e.g. https://mcp.yourcompany.com).

5. Configure groups claim

Auth0 does not include roles/group membership in JWTs by default. Add a custom Action to inject them:

  1. Go to Actions > Library
  2. Click Create Action > Build from scratch
  3. Name it (e.g. Add Custom Claims), set trigger to Login / Post Login, runtime Node 18
  4. Paste this code and click Deploy:
javascript
exports.onExecutePostLogin = async (event, api) => {
  // Must match AUTH0_AUDIENCE — the API identifier you set in Step 3
  const ns = 'https://mcp.yourcompany.com/';

  const sam = event.user.upn
    || event.user.preferred_username
    || event.user.email;

  const groups = event.user.groups || [];

  api.accessToken.setCustomClaim(ns + 'samaccountname', sam || '');
  api.accessToken.setCustomClaim(ns + 'groups', groups);
  api.idToken.setCustomClaim(ns + 'samaccountname', sam || '');
  api.idToken.setCustomClaim(ns + 'groups', groups);
};
Replace https://mcp.yourcompany.com/ with the Auth0 API identifier you set in Step 3 — this is the same value as AUTH0_AUDIENCE in your gateway .env. The namespace must match exactly or the gateway will not find the claims.
  1. Go to Actions > Triggers > post-login
  2. Drag your new action from the right panel into the flow and click Apply

Then set OAUTH_GROUPS_CLAIM=https://mcp.yourcompany.com/groups in the gateway env file (replace with your actual namespace).

This action reads event.user.groups (Azure AD groups passed through the enterprise connection) and the user's UPN/email. In Auth0 > User Management > Roles, ensure roles matching the group field in your entities.json are assigned to users, or map incoming Azure AD groups to those role names.

6. Connect Azure AD (optional - corporate SSO)

  1. Go to Authentication > Enterprise > Microsoft Azure AD and click Create Connection
  2. Fill in your Azure AD tenant domain, client ID, and client secret from the Azure Portal app registration
  3. Save the connection, then open it and go to its Applications tab
  4. Toggle your MCP Gateway Client application to enabled
  5. Users will see "Log in with Microsoft" on the Auth0 login page
After enabling the connection, go to your application's Connections tab (Step 2) to confirm the Azure AD connection shows as active and any unused connections are disabled.

Azure AD (direct, without Auth0)

Use this if you want to skip Auth0 and authenticate directly against Azure AD.

1. Register an application

  1. Go to Azure Portal > App registrations > New registration
  2. Name: SuiteCRM MCP Gateway
  3. Supported account types: Accounts in this organizational directory only
  4. Redirect URI: Web - https://mcp.yourcompany.com/auth/callback
  5. Click Register

2. Add a client secret

Certificates & secrets > New client secret - set an expiry and copy the value immediately.

3. Configure token claims

Token configuration > Add optional claim > ID token: add email, preferred_username.

For groups: Token configuration > Add groups claim. Select Security groups (or All groups). Under each token type, choose Group ID (object ID) or sAMAccountName if synced from on-prem AD.

Note: Azure AD sends group object IDs by default, not names. Set REQUIRED_GROUP in the entity env to the group's object ID, or configure optional claims to emit onpremisessecurityidentifier.

4. Note your credentials

Gateway env varAzure AD value
AUTH0_DOMAINlogin.microsoftonline.com/YOUR_TENANT_ID/v2.0
AUTH0_CLIENT_IDApplication (client) ID
AUTH0_CLIENT_SECRETClient secret value
AUTH0_AUDIENCEApplication (client) ID (same as client ID)
OAUTH_GROUPS_CLAIMgroups

Installer Prompts Reference

When you run sudo python3 install.py, the OAuth section asks:

PromptWhat to enter
Auth0 domainAuth0: your-tenant.auth0.com / Azure: login.microsoftonline.com/TENANT_ID/v2.0
Auth0 client IDFrom your app registration
Auth0 client secretFrom your app registration (keep this secret)
Auth0 audienceAuth0: your API identifier / Azure AD: your client ID
Gateway public URLhttps://mcp.yourcompany.com - must match the registered callback origin
JWT groups claimAuth0 with custom action: https://mcp.yourcompany.com/groups (your gateway URL + /groups) / Azure AD direct: groups

Verification

After installation, visit https://mcp.yourcompany.com/auth/login. You should be redirected to your identity provider's login page. After logging in, you should see the success page with your API key.

If you see an error, check:

  • The callback URL registered in your identity provider matches exactly
  • AUTH0_DOMAIN has no trailing slash
  • The /.well-known/openid-configuration endpoint is reachable from the gateway VM: curl https://YOUR_DOMAIN/.well-known/openid-configuration

Connecting Claude Desktop

Claude Desktop connects directly to the gateway via SSE using a gateway-issued API key. No CRM credentials are stored on your machine.

How authentication works

  1. Your admin installs the gateway and configures an identity provider
  2. You visit the gateway URL in your browser and log in with your corporate account
  3. The success page shows your personal API key - copy it
  4. Paste the key into the Claude Desktop config below

Your API key is tied to your identity. The gateway uses it to look up your CRM account and connect on your behalf. Keys expire after 30 days (configurable by your admin).

Prerequisites

  • Gateway v5.x installed and running
  • Claude Desktop installed
  • Your API key from https://mcp.yourcompany.com/auth/login

Get your API key

  1. Visit https://mcp.yourcompany.com/auth/login (or just https://mcp.yourcompany.com - it redirects)
  2. Log in with your corporate account
  3. On the success page, expand Claude Desktop and copy the config block shown
The success page shows the exact JSON block ready to paste - you do not need to construct it manually.

Config file location

OSPath
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json

The entire setup is just 3 steps:

Step 1 - Install Node.js

Download from nodejs.org (if not already installed)

Step 2 - Edit claude_desktop_config.json

Add the following to your config file, replacing <token> with your API key, and using your gateway URL:

json
{
  "mcpServers": {
    "suitecrm_crm1": {
      "command": "cmd",
      "args": [
        "/C",
        "npx",
        "-y",
        "mcp-remote",
        "https://mcp.yourcompany.com/crm1/sse",
        "--transport",
        "sse-only",
        "--header",
        "Authorization:Bearer YOUR_API_KEY_HERE"
      ]
    },
    "suitecrm_crm2": {
      "command": "cmd",
      "args": [
        "/C",
        "npx",
        "-y",
        "mcp-remote",
        "https://mcp.yourcompany.com/crm2/sse",
        "--transport",
        "sse-only",
        "--header",
        "Authorization:Bearer YOUR_API_KEY_HERE"
      ]
    }
  }
}
Note for macOS users: Replace "command": "cmd", "args": ["/C", "npx"...] with "command": "npx", "args": ["mcp-remote"...]

Step 3 - Restart Claude Desktop

Fully quit and relaunch Claude Desktop (menu bar > Quit, then reopen). Done.

Verify

Click the hammer icon in the bottom-left of the chat window. You should see 24 tools: suitecrm_search, suitecrm_get, etc. (or suitecrm_crm1_search for multi entity).

Test prompt: "List the first 5 accounts in the CRM" - Claude should call suitecrm_search automatically.

Rotating your API key

  1. Visit https://mcp.yourcompany.com/auth/login again - a new key is issued automatically
  2. Update Authorization in the config file
  3. Restart Claude Desktop

Your admin can also revoke a key immediately via mcp-admin revoke --email [email protected].

Troubleshooting

SymptomCauseFix
No hammer icon / tools missingConfig file path wrong or JSON syntax errorValidate JSON, check file path
HTTP 401 UnauthorizedAPI key invalid or expiredRe-authenticate at /auth/login and update the config
HTTP 403 ForbiddenNot in the required group for this entityAsk your admin to check your group membership
Connection refusedGateway not runningCheck with your admin: systemctl status suitecrm-mcp
429 Too Many RequestsRate limit hit on /sse (20 req/15 min)Wait 15 minutes

Connecting Claude Code (CLI)

Claude Code connects directly to the gateway via SSE using a gateway-issued API key. No CRM credentials are stored on your machine.

How authentication works

  1. Visit the gateway URL in your browser and log in with your corporate account
  2. The success page shows your personal API key
  3. Run the claude mcp add command shown on the success page - or paste the key into the command below

Prerequisites

  • Gateway v5.x installed and running
  • Claude Code CLI installed: npm install -g @anthropic-ai/claude-code
  • Your API key from https://mcp.yourcompany.com/auth/login

Get your API key

  1. Visit https://mcp.yourcompany.com/auth/login
  2. Log in with your corporate account
  3. On the success page, expand Claude Code and copy the ready-to-run command

Commands

Single entity

bash
claude mcp add suitecrm \
  --transport sse \
  --header "Authorization:Bearer YOUR_API_KEY_HERE" \
  https://mcp.yourcompany.com/sse

Multi entity

Run once per entity. Each gets its own MCP server entry:

bash
claude mcp add suitecrm_crm1 \
  --transport sse \
  --header "Authorization:Bearer YOUR_API_KEY_HERE" \
  https://mcp.yourcompany.com/crm1/sse

claude mcp add suitecrm_crm2 \
  --transport sse \
  --header "Authorization:Bearer YOUR_API_KEY_HERE" \
  https://mcp.yourcompany.com/crm2/sse

Verify

bash
claude mcp list

Start a session and test:

bash
claude
> List the first 5 accounts in the CRM

Claude should call suitecrm_search automatically.

Manage entries

bash
# List all MCP servers
claude mcp list

# Remove an entry
claude mcp remove suitecrm

Rotating your API key

bash
# Remove the old entry
claude mcp remove suitecrm

# Re-authenticate, then re-add with the new key
claude mcp add suitecrm \
  --transport sse \
  --header "Authorization:Bearer YOUR_NEW_API_KEY" \
  https://mcp.yourcompany.com/sse

Troubleshooting

SymptomCauseFix
HTTP 401 UnauthorizedAPI key invalid or expiredRe-authenticate at /auth/login and re-add the entry
HTTP 403 ForbiddenNot in the required group for this entityAsk your admin to check your group membership
Connection refusedGateway not runningsystemctl status suitecrm-mcp on the gateway machine
429 Too Many RequestsRate limit hit on /sse (20 req/15 min)Wait 15 minutes
TLS error with self-signed certNODE_TLS_REJECT_UNAUTHORIZED not setAdd --tls-skip during gateway install

Connecting OpenClaw

OpenClaw uses a two-machine architecture: a remote gateway and a local bridge plugin.

Architecture

OpenClaw Machine OpenClaw runtime suitecrm-{code} plugin ~/.suitecrm-mcp/gateway.token SSE Gateway Machine suitecrm-mcp gateway SuiteCRM REST API

The bridge is a Node.js plugin that OpenClaw loads. It is silent at startup - no auth prompt, no polling. Authentication is triggered lazily the first time a SuiteCRM tool is actually called. The bridge requests a one-time login URL from the gateway, returns it as the tool response, and polls in the background. Once the user clicks the link and logs in, the token is saved and all subsequent tool calls work silently. No CLI needed on the OpenClaw machine.

Prerequisites

  • A dedicated Ubuntu VM or server for the gateway
  • OpenClaw installed on the client machine (Node.js required)
  • Access to the gateway auth URL in a browser

Installation

1
Install the gateway (gateway machine)

Single entity

bash
sudo python3 install.py --url https://crm.example.com --domain mcp.yourcompany.com --email [email protected]

Multi entity

bash
cp entities.example.json entities.json
# Edit entities.json: add your CRM codes, labels, ports, endpoints, and group names
sudo python3 install.py --config entities.json --domain mcp.yourcompany.com --email [email protected]
HTTPS strongly recommended. The --domain flag enables automatic TLS via Let's Encrypt. Without it, API keys travel unencrypted.
2
Install the bridge (OpenClaw machine)

Single entity

bash
sudo python3 install-bridge.py \
  --gateway https://mcp.yourcompany.com \
  --code mycrm \
  --label "My CRM"

Multi entity

bash
sudo python3 install-bridge.py \
  --gateway https://mcp.yourcompany.com \
  --entities entities.json

Specific users only

bash
sudo python3 install-bridge.py --gateway ... --entities entities.json alice bob

Agent scoping (optional)

bash
# All agents in OpenClaw get access
sudo python3 install-bridge.py --gateway ... --code mycrm --attach all

# Only specific agents (comma-separated names or IDs)
sudo python3 install-bridge.py --gateway ... --code mycrm --attach "Sales Bot,Support Agent"
3
Restart OpenClaw
bash
sudo systemctl restart openclaw-USERNAME

Authentication Flow

Authentication is lazy - nothing happens at startup. The first time a user asks the agent to do something with SuiteCRM:

  1. User invokes a SuiteCRM tool - the bridge detects no token and calls POST /auth/bridge/start.
  2. Gateway returns a one-time login URL tied to a short-lived nonce. The bridge returns this URL as the tool response, visible in Teams chat or wherever the agent runs.
  3. User clicks the link and logs in with their corporate account.
  4. Bridge polls in the background every 3 seconds. Once the gateway resolves the nonce session, the bridge saves the token to ~/.suitecrm-mcp/gateway.token.
  5. Subsequent tool calls work silently. Token is stored per-Linux-user.
  6. On expiry or revocation, the bridge receives HTTP 401, clears the saved token, and sends a fresh login URL on the next tool call. No restart needed.

Verify

OpenClaw should now expose 24 tools per entity: suitecrm_mycrm_search, suitecrm_mycrm_get, suitecrm_mycrm_create, and 21 more.

Test prompt: "List the first 5 accounts in the CRM" - OpenClaw should call suitecrm_mycrm_search automatically.

Re-authenticating

If the token expires (default 30 days) or is revoked: the next tool call returns HTTP 401, the bridge clears the token and calls POST /auth/bridge/start, and returns a fresh login URL. Same flow as first-time auth. No agent restart needed.

Revoking a user's token (operators)

Using mcp-admin (recommended)

bash
mcp-admin revoke --email [email protected]

Using the REST endpoint directly

bash
curl -X POST https://mcp.yourcompany.com/auth/revoke \
  -H "X-Admin-Key: your-admin-key" \
  -H "Content-Type: application/json" \
  -d '{"sub": "[email protected]"}'

Updating the bridge

bash
sudo python3 install-bridge.py --update \
  --gateway https://mcp.yourcompany.com \
  --entities entities.json

Removing the bridge

bash
sudo python3 install-bridge.py \
  --remove alice bob \
  --gateway https://mcp.yourcompany.com \
  --entities entities.json

Troubleshooting

SymptomCauseFix
Tool call returns a login URL instead of CRM dataExpected - first-time auth or token expiredClick the URL and log in; next tool call works normally
Login URL never appears as tool responseBridge plugin not loadedCheck openclaw.json plugins section; re-run installer
Login URL expired before user clicked itNonce TTL (15 min) elapsedCall any SuiteCRM tool again - a fresh URL is generated
Auth polling times out after 15 minUser did not complete loginRe-invoke any SuiteCRM tool to get a new login URL
Token rejected (HTTP 401) in logsToken expired or revokedBridge clears token and sends fresh login URL on next tool call
HTTP 403 ForbiddenUser not in required group for entityAdmin checks group membership in identity provider
Rate limited (HTTP 429) in logsToo many reconnectsWait 15 min; backoff is automatic
Gateway connect failedWrong gateway URL or gateway downCheck --gateway URL; systemctl status suitecrm-mcp-* on gateway
TLS errorSelf-signed cert on CRMAdd --tls-skip during gateway install

Admin Operations

Daily support, session recovery, entity health, credential testing, and emergency controls through the installed mcp-admin command.

The admin tool is installed at /usr/local/bin/mcp-admin. It is the operator surface for Redis-backed profiles, gateway sessions, entity health, credential tests, and emergency revocation. Use it instead of editing Redis or profile data by hand.

Operator standard: support should be able to answer "who has access, is their session alive, and does their CRM password work?" in one minute.

Day-to-day user management

CommandWhen to use it
mcp-admin listFirst thing to run when someone reports a problem. Shows who has what access and whether they have an active session.
mcp-admin whoami --email XFocused view on one user. Use when you need their subject ID or want to see exactly which sessions are alive.
mcp-admin addOnboard a new manual user or update someone's CRM password after it changed.
mcp-admin removeOffboard a user or revoke access to a specific entity.
mcp-admin testAfter adding or updating credentials, confirm the password actually works against the live CRM.
bash
# See everything
mcp-admin list
mcp-admin list --user alice
mcp-admin list --entity crm1
mcp-admin list --show-pass

# Inspect a specific user
mcp-admin whoami --email [email protected]

# Add or update a user's entity credentials
mcp-admin add --email [email protected] --entity crm1 --user alice --pass <plaintext>

# Create a brand-new manual profile
mcp-admin add --email [email protected]

# Remove access
mcp-admin remove --email [email protected] --entity crm1
mcp-admin remove --email [email protected]

# Confirm CRM credentials work
mcp-admin test
mcp-admin test --email [email protected] --entity crm2

When someone cannot log in or has session issues

Start with revocation. It forces a clean login without deleting the user's CRM access profile.

CommandWhen to use it
mcp-admin revoke --email XForces a clean re-login without touching the profile. Try this first.
mcp-admin sessionsLook at what gateway sessions exist and when they expire.
mcp-admin sessions --purge-expiredHousekeeping for dead Redis session keys.
bash
mcp-admin revoke --email [email protected]
mcp-admin sessions
mcp-admin sessions --purge-expired

Ops and infrastructure

These are the commands to run after deployments, restarts, entity changes, or when support needs a live view of the fleet.

CommandWhen to use it
mcp-admin healthQuick sanity check after any deployment or restart.
mcp-admin health-deepWhen health shows a problem, check deeper gateway, Redis, and backend endpoint detail.
mcp-admin entitiesSee user/session counts per entity alongside live health.
mcp-admin restart crm1Restart one gateway instance after config or code changes.
mcp-admin restart --allRestart every gateway instance.
mcp-admin statsQuick Redis memory, profile count, and session count snapshot.
bash
mcp-admin health
mcp-admin health-deep
mcp-admin health --watch

mcp-admin entities
mcp-admin restart crm1
mcp-admin restart --all
mcp-admin stats

Emergency

flush --yes-i-am-sure kills all gateway sessions instantly. Use it if credentials are compromised or everyone must re-authenticate after a major security or identity-provider change.

bash
mcp-admin flush --yes-i-am-sure
Password visibility: mcp-admin list --show-pass unmasks CRM passwords. Treat terminal history, screenshots, and support transcripts as sensitive whenever you use it.

Update server code without reinstalling

bash
git pull
sudo python3 install.py --update --config entities.json --skip-oauth

Add a new entity

bash
# Edit entities.json to add the new entry, then:
sudo python3 install.py --add --config entities.json --skip-oauth

Remove an entity

bash
sudo python3 install.py --remove crm2

Enable HTTPS on an existing plain HTTP install

bash
sudo python3 install.py --config entities.json --domain mcp.yourcompany.com --email [email protected] --skip-oauth

Observability

Prometheus metrics, Grafana dashboards, and Loki log aggregation - shipped in docker-compose.yml and ready to run alongside the gateway.

Starting the stack

The full observability stack (Prometheus + Grafana + Loki + Promtail) is bundled in docker-compose.yml. It starts automatically alongside the gateway:

bash
docker compose up -d

Set GRAFANA_PASSWORD in your environment before starting, then visit http://localhost:3000. Both dashboards are provisioned automatically - no manual import needed.

Systemd installs: the stack runs separately. Start it with docker compose up -d from the repo directory after the gateway is running. Point Prometheus at your gateway's metrics port using monitoring/prometheus.yml.

Prometheus metrics

Two components expose metrics on separate localhost ports. Both are scraped by the bundled Prometheus instance.

Gateway entity metrics (default port 9090)

MetricTypeDescription
suitecrm_mcp_active_connectionsGaugeCurrently open SSE connections
suitecrm_mcp_connections_totalCounterTotal SSE connections established
suitecrm_mcp_tool_calls_totalCounterTool calls by name and status (success/error)
suitecrm_mcp_tool_duration_secondsHistogramTool call latency (p50/p95/p99)
suitecrm_mcp_crm_api_duration_secondsHistogramCRM REST API call latency
suitecrm_mcp_session_renewals_totalCounterCRM session re-authentications
suitecrm_mcp_auth_failures_totalCounterAuthentication failures
suitecrm_mcp_circuit_breaker_stateGauge0 = closed, 1 = half-open, 2 = open
suitecrm_mcp_circuit_breaker_openings_totalCounterCircuit breaker trip events

Auth service metrics (default port 9091)

MetricTypeDescription
suitecrm_auth_logins_totalCounterOAuth2 login completions by result (new/reused/error)
suitecrm_auth_bridge_sessions_totalCounterBridge session events (started/completed/expired)
suitecrm_auth_sessions_activeGaugeNon-expired gateway sessions currently stored

Scrape manually to verify metrics are flowing:

bash
# Gateway entity metrics
curl http://127.0.0.1:9090/metrics

# Auth service metrics
curl http://127.0.0.1:9091/metrics

For systemd (multi-entity), add each entity's metrics port to monitoring/prometheus.yml:

yaml
scrape_configs:
  - job_name: suitecrm-mcp-auth
    static_configs:
      - targets: ['127.0.0.1:9091']
  - job_name: suitecrm-mcp-crm1
    static_configs:
      - targets: ['127.0.0.1:9101']   # port 3101 + 6000
  - job_name: suitecrm-mcp-crm2
    static_configs:
      - targets: ['127.0.0.1:9102']   # port 3102 + 6000

Grafana dashboards

Two dashboards are auto-provisioned from monitoring/grafana/dashboards/:

  • suitecrm-mcp - per-entity view with 33 panels across 5 rows: system health, users/sessions table, CRM backend (latency, error codes, circuit breaker), security (auth failures, rate limits), and tool call breakdown
  • suitecrm-mcp-fleet - multi-entity overview showing all entities at a glance: circuit breaker state, active connections, error rates, and latency per entity

Open http://localhost:3000, log in with admin / $GRAFANA_PASSWORD, and both dashboards appear under Dashboards. No manual import or datasource setup needed.

Tip: use Grafana Explore to run ad-hoc queries against Prometheus and Loki side by side. Switch the datasource dropdown between Prometheus and Loki to correlate a latency spike with the log lines that caused it.

Loki & log queries

Promtail tails Docker JSON logs and ships them to Loki. All gateway log lines are structured JSON (via pino) - every line includes sub (user identity), entity, reqId, and tool where relevant. Sensitive fields (password, token, fields, search_string) are redacted before logging.

Query logs in Grafana Explore (select Loki datasource):

logql
# All logs for a specific user
{job="suitecrm-mcp"} | json | sub="auth0|abc123"

# All tool calls on a specific entity
{job="suitecrm-mcp"} | json | entity="crm1" | msg="tool call"

# Auth failures in the last hour
{job="suitecrm-mcp"} | json | level="warn" | msg=~"auth.*fail|invalid.*key"

# Trace a request by ID
{job="suitecrm-mcp"} | json | reqId="req-xyz"
Note: Loki requires Docker Compose - it does not run automatically on systemd installs. Run docker compose up -d loki promtail grafana separately and point Promtail at the journal or log files.

Alerting rules

Prometheus alerting rules are in monitoring/prometheus/rules.yml and loaded automatically by the bundled Prometheus. The rule set covers:

AlertConditionSeverity
Circuit breaker opensuitecrm_mcp_circuit_breaker_state == 2Critical
Gateway/entity downPrometheus target up == 0Critical
High tool error rateMore than 10% tool-call errors over 5 minutesWarning
High heap or event loop lagNode memory pressure or event loop blockingWarning
Auth or rate-limit spikeElevated auth failures or excessive client trafficWarning
Connection capacityActive SSE connections approaching the gateway capWarning

To wire up notifications (Slack, PagerDuty, email), add an Alertmanager config to docker-compose.yml and point Prometheus at it via alerting.alertmanagers in monitoring/prometheus.yml.

ACL Enforcement - SuiteCRM MCP Gateway

Live permission checking to prevent unauthorized writes to SuiteCRM via the MCP gateway.

1. Overview

What problem this solves

The MCP gateway allows AI agents to call SuiteCRM tools (create, update, delete records) on behalf of authenticated users. SuiteCRM's own ACL system restricts what each user can do in the Web UI - but those restrictions are not checked at the CRM REST API level by default. A user blocked from editing Tasks in the browser could still call suitecrm_crm1_update with module=Tasks and the write would go through.

This feature closes that gap by checking SuiteCRM's live permission tables before forwarding any write to the CRM API.

How it works

Before forwarding any write tool call (create, update, delete, bulk_upsert), the gateway:

  1. Opens a direct read-only MySQL connection to the CRM's own database.
  2. Looks up the CRM user's internal UUID and is_admin flag.
  3. Computes the effective permission = MAX(access_override) across all roles assigned to the user - both directly and via security group membership.
  4. Applies SuiteCRM's "most permissive role wins" semantics to decide allow/deny.

The lookup is live (no cache) so that Web UI permission changes take effect immediately - no restarts or cache flushes needed.

What is and isn't enforced

Enforced (write path):

  • *_create - maps to SuiteCRM ACL action create
  • *_update - maps to SuiteCRM ACL action edit
  • *_delete - maps to SuiteCRM ACL action delete
  • *_bulk_upsert - maps to SuiteCRM ACL action edit
  • *_log_call, *_create_task, *_create_note etc. - mapped to create on their module
  • *_link_records, *_unlink_records - mapped to edit on the primary module

Not enforced:

  • *_search, *_get, *_get_many etc. - read operations pass through; the CRM API enforces read-level visibility natively
  • Field-level ACL - only module-level action restrictions are checked
  • Record-level visibility rules beyond ownership (e.g. security group record assignment)

2. Decision Table

ConditionResult
No DB configured (SUITECRM_DB_HOST not set)allow
Read action (list, view, search, get)allow (not checked)
User is SuiteCRM admin (is_admin = 1)allow
effective = ACL_DENY_ALL (-99)DENY
effective = ACL_ALLOW_DISABLED (-98)DENY
effective = ACL_ALLOW_OWNER (69) + action is createallow (creator owns it)
effective = ACL_ALLOW_OWNER (69) + user owns the recordallow
effective = ACL_ALLOW_OWNER (69) + user does NOT own the recordDENY
effective ≥ ACL_ALLOW_GROUP (79)allow
effective = 0 or no rowsallow (SuiteCRM default)
DB unreachable + write actionDENY (fail-closed)

3. Architecture

Request flow

Authenticated SSE connection (CRM username resolved from user profile)
       │
       ▼
  Tool call arrives (e.g. suitecrm_crm1_update)
       │
       ├─ Read operation (search, get, …)? ──► forward to CRM directly
       │
       ▼ Write operation
  isAclDenied(crmUsername, module, aclAction, recordId)
  [acl-check.mjs]
       │
       ├─ SUITECRM_DB_HOST not set ──► fail-open, proceed to CRM
       │
       ▼
  SELECT id, is_admin FROM users WHERE user_name = ?
       │
       ├─ not found ──► fail-open (let CRM handle unknown user)
       ├─ is_admin = 1 ──► allow immediately
       │
       ▼
  Query A: MAX(access_override) via directly assigned roles
  Query B: MAX(access_override) via security group roles
       │
       ├─ both NULL (no rows) ──► allow
       │
       ▼
  effective = MAX(A, B)
       │
       ├─ effective = -99 or -98 ──► DENY
       ├─ effective = 69 (owner) ──► owner check → allow or DENY
       ├─ effective ≥ 79 (group/all) ──► allow
       └─ effective = 0 ──► allow

Key files

FileRole
server/acl-check.mjsMySQL pool, isAclDenied() with two MAX queries + owner check, initAclDb()
server/index.mjsCalls initAclDb() at startup; calls isAclDenied() in tool call handler
{entity}.envPer-entity config: SUITECRM_DB_* credentials and optional ACL_ALLOW_* overrides

Why two queries?

SuiteCRM has two ways to restrict a user's access: direct role assignment via acl_roles_users (Query A) and security group role assignment via securitygroups_users (Query B). Both must be checked independently - missing Query B silently skips all group-based restrictions.

Why MAX() instead of COUNT(*)?

SuiteCRM's "most permissive role wins" rule means if a user has one role that denies a module and another that allows it, the allow wins. MAX(access_override) across all roles returns the highest (most permissive) value - deny constants only apply if that maximum is still a deny value.


4. Failure Behaviour

ConditionWrite resultWhy
SUITECRM_DB_HOST not setallowACL disabled for this entity
DB unreachableDENYFail-closed: cannot confirm permission
User not found in users tableallowLet CRM enforce; unknown user will fail anyway
Owner check - record not foundallowRecord may be in a related table; let CRM handle
Owner check - no recordId passedallowGateway can't determine ownership without an ID

5. Configuration

Required env vars (per entity .env)

bash
SUITECRM_DB_HOST=<db_server_ip>      # LAN IP, not localhost
SUITECRM_DB_PORT=3306
SUITECRM_DB_NAME=<suitecrm_db_name>
SUITECRM_DB_USER=mcp_acl_reader      # read-only DB user (see Section 6)
SUITECRM_DB_PASS=<password>

Optional ACL constant overrides

Standard SuiteCRM uses 89/79/69/-98 for All/Group/Owner/Disabled. Check your install and set env vars if they differ:

bash
ACL_ALLOW_ALL=89       # explicit allow for all users
ACL_ALLOW_GROUP=79     # allow for group members
ACL_ALLOW_OWNER=69     # allow for record owner only
ACL_ALLOW_DISABLED=-98 # module disabled for this role

ACL_DENY_ALL = -99 is always fixed and not configurable.


6. Setting Up the Read-Only DB User

The gateway connects as a read-only user with SELECT privileges only. It cannot modify CRM data.

Step 1 - Find the correct DB host

Do not assume the DB is on the CRM app server. SuiteCRM stores the DB host in config.php. SSH into the CRM app server and check:

bash
grep 'db_host_name\|db_name' /path/to/suitecrm/public/legacy/config.php

Step 2 - Find the correct grant IP

The MySQL GRANT statement must list the IP that the gateway's connection appears as in MySQL. If there is a DB proxy between the gateway and MySQL, MySQL sees the proxy's IP. Probe it with:

bash
# From the gateway server:
node -e "
  const m = require('/opt/suitecrm-mcp/node_modules/mysql2/promise');
  m.createPool({host:'<db_host>',port:3306,database:'<db>',user:'x',password:'x',connectionLimit:1})
   .query('SELECT 1').catch(e => console.error(e.message));
"

The error will read Access denied for user 'x'@'<IP>' - that IP is what to use in the GRANT statement.

Step 3 - Create the user on the DB server

bash
openssl rand -base64 16   # generate a password

# In MySQL (as root or a privileged user):
CREATE USER 'mcp_acl_reader'@'<connecting_ip>' IDENTIFIED BY '<password>';
GRANT SELECT ON <suitecrm_db_name>.* TO 'mcp_acl_reader'@'<connecting_ip>';
FLUSH PRIVILEGES;

SHOW GRANTS FOR 'mcp_acl_reader'@'<connecting_ip>';   # verify

Verify network access from the gateway

bash
nc -zv <db_server_ip> 3306

If this fails, MySQL is bound to 127.0.0.1. Edit /etc/mysql/mysql.conf.d/mysqld.cnf and set bind-address = 0.0.0.0, then sudo systemctl restart mysql.


7. Adding a New Entity

  1. Find the DB host - grep config.php for db_host_name and db_name.
  2. Verify port reachability: nc -zv <db_host> 3306.
  3. Find the connecting IP via the Step 2 probe above.
  4. Create mcp_acl_reader on the DB server (or add a GRANT if the user already exists for another entity on the same cluster).
  5. Check ACL constants in actiondefs.override.php - add ACL_ALLOW_* env vars if they differ from standard.
  6. Add SUITECRM_DB_* vars to /etc/suitecrm-mcp/<code>.env and set chmod 640.
  7. Restart the entity service and run the verification tests below.

8. Verification Tests

Test A - restricted user write (expect: DENIED)
Find a user with an explicit deny on a module action, call the corresponding write tool. Response must contain "Permission denied" and "isError": true.

Test B - unrestricted user write (expect: SUCCESS or CRM error)
Response must NOT contain "Permission denied".

Test C - any user read (expect: NOT BLOCKED)
Call *_search for any module. Response must never contain "Permission denied".

Test D - live permission change
Restrict a user in SuiteCRM Web UI - next write from that user must be blocked immediately without restarting the service.


9. Checklist

  • DB host sourced from CRM's config.php - not assumed to be the app server IP
  • nc -zv <db_host> 3306 succeeds from the gateway
  • Connecting IP confirmed via the Step 2 probe
  • mcp_acl_reader user exists with SELECT ON <db>.* from the correct IP
  • ACL constants checked; ACL_ALLOW_* env vars added if non-standard
  • SUITECRM_DB_* vars present in the entity .env
  • Service starts clean: no MySQL errors in journalctl -u suitecrm-mcp-<code>
  • Test A: restricted write → "Permission denied"
  • Test B: unrestricted write → not denied
  • Test C: read → not denied
  • Test D: live Web UI change → immediate effect

Nginx Config Reference

The installer generates and writes this config to /etc/nginx/sites-available/suitecrm-mcp. Copy and adapt it for manual or custom deployments.

Single-entity

Used when you have exactly one CRM instance and pass --domain to the installer. There are no path prefixes - the MCP endpoint is served at /messages directly. Certbot rewrites this to HTTPS after the install step.

nginx
# /etc/nginx/sites-available/suitecrm-mcp
# Single-entity config — certbot will add SSL directives automatically.
server {
    listen 80;
    server_name mcp.example.com;    # replace with your domain
    large_client_header_buffers 4 32k;
    client_max_body_size 10m;
    access_log /var/log/nginx/suitecrm-mcp.access.log;
    error_log  /var/log/nginx/suitecrm-mcp.error.log;

    # SSE endpoint — long-lived connection, access_log off reduces noise
    location = /messages {
        access_log off;
        proxy_pass http://127.0.0.1:3101/messages;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }

    # All other routes (tool calls, auth, health)
    location / {
        proxy_pass http://127.0.0.1:3101;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
}

After placing the file, enable it and reload nginx:

bash
ln -s /etc/nginx/sites-available/suitecrm-mcp /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
The installer does this automatically.

Multi-entity

Used when you have two or more CRM instances. All entities share a single nginx server block on port 8080 (plain HTTP) or port 80 when a domain is configured. Each entity gets its own path prefix matching its code (e.g. /crm1/).

nginx
# /etc/nginx/sites-available/suitecrm-mcp
# Generated by install.py — edit to match your entity codes and ports.
server {
    listen 8080;           # change to: listen 80; server_name mcp.example.com; when using a domain
    server_name _;
    large_client_header_buffers 4 32k;
    client_max_body_size 10m;
    access_log /var/log/nginx/suitecrm-mcp.access.log;
    error_log  /var/log/nginx/suitecrm-mcp.error.log;

    # Health check — returns {"gateway":"ok","entities":N}
    location /health {
        default_type application/json;
        return 200 '{"gateway":"ok","entities":2}';
    }

    # OAuth2 login and callback — handled by the auth service (port 3100)
    location /auth/ {
        proxy_pass http://127.0.0.1:3100/auth/;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 60s;
    }
    # Redirect bare root to the login page
    location = / {
        return 302 /auth/login;
    }

    # Entity: crm1 — SSE endpoint (access_log off reduces noise from long-lived connections)
    location = /crm1/messages {
        access_log off;
        proxy_pass http://127.0.0.1:3101/messages;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
    # Entity: crm1 — all other routes
    location /crm1/ {
        proxy_pass http://127.0.0.1:3101/;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }

    # Entity: crm2 — SSE endpoint
    location = /crm2/messages {
        access_log off;
        proxy_pass http://127.0.0.1:3102/messages;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
    # Entity: crm2 — all other routes
    location /crm2/ {
        proxy_pass http://127.0.0.1:3102/;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_pass_request_headers on;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
}
Adding more entities: duplicate the two location blocks for each entity, incrementing the code (e.g. crm3) and port (3103). The installer assigns ports starting at 3101 and increments by one per entity.

File Layout After Install

Where the installer places runtime code, protected environment files, Redis-backed state, systemd units, and nginx routing.

filesystem
/opt/suitecrm-mcp-server/        gateway server code (owned by suitecrm-mcp)
  index.mjs                      SSE gateway + tool handler
  auth.mjs                       OAuth2/OIDC auth service
  acl-check.mjs                  MySQL ACL enforcement (optional)
  redis.mjs                      ioredis singleton (shared connection)
  mcp-admin.mjs                  admin CLI backend (called by mcp-admin wrapper)
  bridges/
    hybrid.mjs                   routing: v8 GraphQL with v4.1 fallback
    graphql.mjs                  SuiteCRM v8 GraphQL bridge
    legacy.mjs                   SuiteCRM v4.1 REST bridge
  scripts/
    migrate_profiles.mjs         one-shot migration from file store to Redis
    update-crm-host.sh           update crm-hosts.json (flock-safe)
    find-suitecrm-config.sh      locate config.php on CRM VM
  package.json
  node_modules/

/etc/suitecrm-mcp/               protected config (mode 700, owned by suitecrm-mcp)
  auth.env                       env vars for auth service (mode 600)
  gateway.env                    env vars for single-entity install (mode 600)
  {code}.env                     env vars per entity for multi-entity install (mode 600)
  entities.json                  entity list used by the server at runtime
  crm-hosts.json                 SSH host map for auto-provisioning (if configured)
  crm-ssh-key                    SSH private key for CRM VM access (mode 600)
  domain                         saved domain for nginx rebuild

/usr/local/bin/
  mcp-admin                      admin CLI shell wrapper

/opt/suitecrm-mcp-monitoring/    observability stack (Docker Compose)
  docker-compose.yml
  .env                           Grafana password + dynamic URLs
  prometheus.yml
  promtail.yml
  grafana/

Redis                            runtime state
  crm:profiles                   per-user CRM credentials and entity access
  auth:session:*                 gateway API sessions (30-day TTL)
  crm:session:*                  cached CRM sessions per user and entity
  auth:bridge:*                  short-lived OpenClaw bridge login handoffs
  rl:*                           shared rate-limit counters
  acl:uid:{code}:{user}          cached CRM user ID for ACL lookups

/etc/systemd/system/
  suitecrm-mcp-auth.service
  suitecrm-mcp.service           single-entity install
  suitecrm-mcp-{code}.service    multi-entity: one per entity

/etc/nginx/sites-available/
  suitecrm-mcp                   nginx config with entity and /auth/ routing
/etc/nginx/sites-enabled/
  suitecrm-mcp -> ../sites-available/suitecrm-mcp

/var/log/nginx/
  suitecrm-mcp.access.log
  suitecrm-mcp.error.log