Complete Setup Guide
End-to-end guide for deploying suitecrm-mcp v5.x from scratch.
Overview
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
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
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.
git clone https://github.com/Anirudhx7/suitecrm-mcp.git cd suitecrm-mcp
cp entities.example.json entities.json
Edit entities.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:
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
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):
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
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.
Copy tools/crm-provision-user.sh to each CRM VM and run as root:
# Single user sudo crm-provision-user alice SecurePass123 # Bulk from CSV (username,password) sudo crm-provision-user --csv users.csv
Lets the gateway automatically provision CRM accounts when users first log in via OAuth.
# 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:
{
"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):
CRM_HOSTS_FILE=/etc/suitecrm-mcp/crm-hosts.json
Restart: sudo systemctl restart suitecrm-mcp-crm1
Steps 7-8: Test & Connect
# 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.
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.
- Log into SuiteCRM as admin
- Go to Admin → User Management → (select the user)
- Scroll to "Password" section → tick "Allow API Access" → Save
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:
# 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:
# 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:
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:
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
- Log in to the Auth0 Dashboard
- Go to Applications > Applications > Create Application
- Name it (e.g.
MCP Gateway Client) - Choose Regular Web Application - required for Authorization Code flow
- Click Create
2. Configure the application
On the Settings tab:
| Field | Value |
|---|---|
| Allowed Callback URLs | https://mcp.yourcompany.com/auth/callback |
| Allowed Logout URLs | https://mcp.yourcompany.com |
| Allowed Web Origins | https://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:
| Field | Value |
|---|---|
| Name | MCP Gateway (or any descriptive name) |
| Identifier | https://mcp.yourcompany.com - this becomes your AUTH0_AUDIENCE |
| Signing Algorithm | RS256 |
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.
4. Note your credentials
From the application Settings tab, copy:
- Domain (e.g.
your-tenant.auth0.com) - this is yourAUTH0_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:
- Go to Actions > Library
- Click Create Action > Build from scratch
- Name it (e.g.
Add Custom Claims), set trigger to Login / Post Login, runtime Node 18 - Paste this code and click Deploy:
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);
};
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.- Go to Actions > Triggers > post-login
- 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)
- Go to Authentication > Enterprise > Microsoft Azure AD and click Create Connection
- Fill in your Azure AD tenant domain, client ID, and client secret from the Azure Portal app registration
- Save the connection, then open it and go to its Applications tab
- Toggle your MCP Gateway Client application to enabled
- Users will see "Log in with Microsoft" on the Auth0 login page
Azure AD (direct, without Auth0)
Use this if you want to skip Auth0 and authenticate directly against Azure AD.
1. Register an application
- Go to Azure Portal > App registrations > New registration
- Name:
SuiteCRM MCP Gateway - Supported account types: Accounts in this organizational directory only
- Redirect URI: Web -
https://mcp.yourcompany.com/auth/callback - 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.
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 var | Azure AD value |
|---|---|
AUTH0_DOMAIN | login.microsoftonline.com/YOUR_TENANT_ID/v2.0 |
AUTH0_CLIENT_ID | Application (client) ID |
AUTH0_CLIENT_SECRET | Client secret value |
AUTH0_AUDIENCE | Application (client) ID (same as client ID) |
OAUTH_GROUPS_CLAIM | groups |
Installer Prompts Reference
When you run sudo python3 install.py, the OAuth section asks:
| Prompt | What to enter |
|---|---|
| Auth0 domain | Auth0: your-tenant.auth0.com / Azure: login.microsoftonline.com/TENANT_ID/v2.0 |
| Auth0 client ID | From your app registration |
| Auth0 client secret | From your app registration (keep this secret) |
| Auth0 audience | Auth0: your API identifier / Azure AD: your client ID |
| Gateway public URL | https://mcp.yourcompany.com - must match the registered callback origin |
| JWT groups claim | Auth0 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_DOMAINhas no trailing slash- The
/.well-known/openid-configurationendpoint 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
- Your admin installs the gateway and configures an identity provider
- You visit the gateway URL in your browser and log in with your corporate account
- The success page shows your personal API key - copy it
- 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
- Visit
https://mcp.yourcompany.com/auth/login(or justhttps://mcp.yourcompany.com- it redirects) - Log in with your corporate account
- On the success page, expand Claude Desktop and copy the config block shown
Config file location
| OS | Path |
|---|---|
| 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:
{
"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"
]
}
}
}
"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
- Visit
https://mcp.yourcompany.com/auth/loginagain - a new key is issued automatically - Update
Authorizationin the config file - Restart Claude Desktop
Your admin can also revoke a key immediately via mcp-admin revoke --email [email protected].
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No hammer icon / tools missing | Config file path wrong or JSON syntax error | Validate JSON, check file path |
HTTP 401 Unauthorized | API key invalid or expired | Re-authenticate at /auth/login and update the config |
HTTP 403 Forbidden | Not in the required group for this entity | Ask your admin to check your group membership |
Connection refused | Gateway not running | Check with your admin: systemctl status suitecrm-mcp |
429 Too Many Requests | Rate 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
- Visit the gateway URL in your browser and log in with your corporate account
- The success page shows your personal API key
- Run the
claude mcp addcommand 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
- Visit
https://mcp.yourcompany.com/auth/login - Log in with your corporate account
- On the success page, expand Claude Code and copy the ready-to-run command
Commands
Single entity
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:
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
claude mcp list
Start a session and test:
claude > List the first 5 accounts in the CRM
Claude should call suitecrm_search automatically.
Manage entries
# List all MCP servers claude mcp list # Remove an entry claude mcp remove suitecrm
Rotating your API key
# 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
| Symptom | Cause | Fix |
|---|---|---|
HTTP 401 Unauthorized | API key invalid or expired | Re-authenticate at /auth/login and re-add the entry |
HTTP 403 Forbidden | Not in the required group for this entity | Ask your admin to check your group membership |
Connection refused | Gateway not running | systemctl status suitecrm-mcp on the gateway machine |
429 Too Many Requests | Rate limit hit on /sse (20 req/15 min) | Wait 15 minutes |
| TLS error with self-signed cert | NODE_TLS_REJECT_UNAUTHORIZED not set | Add --tls-skip during gateway install |
Connecting OpenClaw
OpenClaw uses a two-machine architecture: a remote gateway and a local bridge plugin.
Architecture
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
Single entity
sudo python3 install.py --url https://crm.example.com --domain mcp.yourcompany.com --email [email protected]
Multi entity
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]
--domain flag enables automatic TLS via Let's Encrypt. Without it, API keys travel unencrypted.Single entity
sudo python3 install-bridge.py \ --gateway https://mcp.yourcompany.com \ --code mycrm \ --label "My CRM"
Multi entity
sudo python3 install-bridge.py \ --gateway https://mcp.yourcompany.com \ --entities entities.json
Specific users only
sudo python3 install-bridge.py --gateway ... --entities entities.json alice bob
Agent scoping (optional)
# 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"
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:
- User invokes a SuiteCRM tool - the bridge detects no token and calls
POST /auth/bridge/start. - 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.
- User clicks the link and logs in with their corporate account.
- 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. - Subsequent tool calls work silently. Token is stored per-Linux-user.
- 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)
mcp-admin revoke --email [email protected]
Using the REST endpoint directly
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
sudo python3 install-bridge.py --update \ --gateway https://mcp.yourcompany.com \ --entities entities.json
Removing the bridge
sudo python3 install-bridge.py \ --remove alice bob \ --gateway https://mcp.yourcompany.com \ --entities entities.json
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Tool call returns a login URL instead of CRM data | Expected - first-time auth or token expired | Click the URL and log in; next tool call works normally |
| Login URL never appears as tool response | Bridge plugin not loaded | Check openclaw.json plugins section; re-run installer |
| Login URL expired before user clicked it | Nonce TTL (15 min) elapsed | Call any SuiteCRM tool again - a fresh URL is generated |
| Auth polling times out after 15 min | User did not complete login | Re-invoke any SuiteCRM tool to get a new login URL |
Token rejected (HTTP 401) in logs | Token expired or revoked | Bridge clears token and sends fresh login URL on next tool call |
HTTP 403 Forbidden | User not in required group for entity | Admin checks group membership in identity provider |
Rate limited (HTTP 429) in logs | Too many reconnects | Wait 15 min; backoff is automatic |
Gateway connect failed | Wrong gateway URL or gateway down | Check --gateway URL; systemctl status suitecrm-mcp-* on gateway |
| TLS error | Self-signed cert on CRM | Add --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.
Day-to-day user management
| Command | When to use it |
|---|---|
mcp-admin list | First thing to run when someone reports a problem. Shows who has what access and whether they have an active session. |
mcp-admin whoami --email X | Focused view on one user. Use when you need their subject ID or want to see exactly which sessions are alive. |
mcp-admin add | Onboard a new manual user or update someone's CRM password after it changed. |
mcp-admin remove | Offboard a user or revoke access to a specific entity. |
mcp-admin test | After adding or updating credentials, confirm the password actually works against the live CRM. |
# 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.
| Command | When to use it |
|---|---|
mcp-admin revoke --email X | Forces a clean re-login without touching the profile. Try this first. |
mcp-admin sessions | Look at what gateway sessions exist and when they expire. |
mcp-admin sessions --purge-expired | Housekeeping for dead Redis session keys. |
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.
| Command | When to use it |
|---|---|
mcp-admin health | Quick sanity check after any deployment or restart. |
mcp-admin health-deep | When health shows a problem, check deeper gateway, Redis, and backend endpoint detail. |
mcp-admin entities | See user/session counts per entity alongside live health. |
mcp-admin restart crm1 | Restart one gateway instance after config or code changes. |
mcp-admin restart --all | Restart every gateway instance. |
mcp-admin stats | Quick Redis memory, profile count, and session count snapshot. |
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.
mcp-admin flush --yes-i-am-sure
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
git pull sudo python3 install.py --update --config entities.json --skip-oauth
Add a new entity
# Edit entities.json to add the new entry, then:
sudo python3 install.py --add --config entities.json --skip-oauth
Remove an entity
sudo python3 install.py --remove crm2
Enable HTTPS on an existing plain HTTP install
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:
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.
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)
| Metric | Type | Description |
|---|---|---|
suitecrm_mcp_active_connections | Gauge | Currently open SSE connections |
suitecrm_mcp_connections_total | Counter | Total SSE connections established |
suitecrm_mcp_tool_calls_total | Counter | Tool calls by name and status (success/error) |
suitecrm_mcp_tool_duration_seconds | Histogram | Tool call latency (p50/p95/p99) |
suitecrm_mcp_crm_api_duration_seconds | Histogram | CRM REST API call latency |
suitecrm_mcp_session_renewals_total | Counter | CRM session re-authentications |
suitecrm_mcp_auth_failures_total | Counter | Authentication failures |
suitecrm_mcp_circuit_breaker_state | Gauge | 0 = closed, 1 = half-open, 2 = open |
suitecrm_mcp_circuit_breaker_openings_total | Counter | Circuit breaker trip events |
Auth service metrics (default port 9091)
| Metric | Type | Description |
|---|---|---|
suitecrm_auth_logins_total | Counter | OAuth2 login completions by result (new/reused/error) |
suitecrm_auth_bridge_sessions_total | Counter | Bridge session events (started/completed/expired) |
suitecrm_auth_sessions_active | Gauge | Non-expired gateway sessions currently stored |
Scrape manually to verify metrics are flowing:
# 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:
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.
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):
# 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"
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:
| Alert | Condition | Severity |
|---|---|---|
| Circuit breaker open | suitecrm_mcp_circuit_breaker_state == 2 | Critical |
| Gateway/entity down | Prometheus target up == 0 | Critical |
| High tool error rate | More than 10% tool-call errors over 5 minutes | Warning |
| High heap or event loop lag | Node memory pressure or event loop blocking | Warning |
| Auth or rate-limit spike | Elevated auth failures or excessive client traffic | Warning |
| Connection capacity | Active SSE connections approaching the gateway cap | Warning |
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:
- Opens a direct read-only MySQL connection to the CRM's own database.
- Looks up the CRM user's internal UUID and
is_adminflag. - Computes the effective permission =
MAX(access_override)across all roles assigned to the user - both directly and via security group membership. - 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 actioncreate*_update- maps to SuiteCRM ACL actionedit*_delete- maps to SuiteCRM ACL actiondelete*_bulk_upsert- maps to SuiteCRM ACL actionedit*_log_call,*_create_task,*_create_noteetc. - mapped tocreateon their module*_link_records,*_unlink_records- mapped toediton the primary module
Not enforced:
*_search,*_get,*_get_manyetc. - 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
| Condition | Result |
|---|---|
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 create | allow (creator owns it) |
effective = ACL_ALLOW_OWNER (69) + user owns the record | allow |
effective = ACL_ALLOW_OWNER (69) + user does NOT own the record | DENY |
effective ≥ ACL_ALLOW_GROUP (79) | allow |
| effective = 0 or no rows | allow (SuiteCRM default) |
| DB unreachable + write action | DENY (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
| File | Role |
|---|---|
server/acl-check.mjs | MySQL pool, isAclDenied() with two MAX queries + owner check, initAclDb() |
server/index.mjs | Calls initAclDb() at startup; calls isAclDenied() in tool call handler |
{entity}.env | Per-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
| Condition | Write result | Why |
|---|---|---|
SUITECRM_DB_HOST not set | allow | ACL disabled for this entity |
| DB unreachable | DENY | Fail-closed: cannot confirm permission |
User not found in users table | allow | Let CRM enforce; unknown user will fail anyway |
| Owner check - record not found | allow | Record may be in a related table; let CRM handle |
Owner check - no recordId passed | allow | Gateway can't determine ownership without an ID |
5. Configuration
Required env vars (per entity .env)
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:
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:
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:
# 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
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
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
- Find the DB host - grep
config.phpfordb_host_nameanddb_name. - Verify port reachability:
nc -zv <db_host> 3306. - Find the connecting IP via the Step 2 probe above.
- Create
mcp_acl_readeron the DB server (or add a GRANT if the user already exists for another entity on the same cluster). - Check ACL constants in
actiondefs.override.php- addACL_ALLOW_*env vars if they differ from standard. - Add
SUITECRM_DB_*vars to/etc/suitecrm-mcp/<code>.envand setchmod 640. - 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> 3306succeeds from the gateway- Connecting IP confirmed via the Step 2 probe
mcp_acl_readeruser exists withSELECT 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.
# /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:
ln -s /etc/nginx/sites-available/suitecrm-mcp /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx
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/).
# /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; } }
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.
/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