Skip to main content
Glama
yeisonmbotero

ghl-mcp-server

README.md
# ghl-mcp-server

**An MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor…) operate the parts of
GoHighLevel that the official API doesn't expose**: workflows, number pools, forms, funnels,
custom fields, pipelines and A2P 10DLC status — by talking to GHL's *internal* web-app API.

![Python](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-FastMCP-6E56CF)
![License](https://img.shields.io/badge/license-MIT-green)

> **Unofficial.** This project uses undocumented endpoints of the GoHighLevel web app. Read the
> [Disclaimer](#disclaimer) before using it.

---

## Why

GoHighLevel's public API (and the official LeadConnector MCP) is great for *data* — contacts,
conversations, opportunities. But much of the day-to-day work of a marketing agency is
*configuration*: creating workflows, forms, custom fields, pipelines, checking phone tracking and
A2P registration for every new client sub-account. That lives behind the web app's internal API.

This server was built while working with a GoHighLevel marketing agency, to let an AI agent audit
and set up client sub-accounts instead of clicking through the UI by hand.

## Tools (21)

Every tool accepts an optional `location_id` (the GHL sub-account). If omitted, `GHL_LOCATION_ID`
is used. Every tool returns the same shape:
`{"ok": bool, "status": int, "data": ..., "text": ..., "error": ...}`.

### Read

| Tool | What it does | Internal endpoint |
|---|---|---|
| `ghl_list_workflows` | Workflows and folders of a sub-account | `GET /workflow/{loc}/list` |
| `ghl_get_workflow` | One workflow in full: triggers, actions, branches | `GET /workflow/{loc}/{workflowId}` |
| `ghl_list_number_pools` | Number pools (dynamic call tracking) | `GET /phone-system/number-pools` |
| `ghl_list_phone_numbers` | Phone numbers of the sub-account | `GET /phone-system/numbers/v2/location/{loc}` |
| `ghl_a2p_status` | Phone-system sub-account record incl. A2P state | `GET /phone-system/sub-account/{loc}` |
| `ghl_a2p_registration_status` | A2P 10DLC standard-registration settings (read-only) | `GET /isv_service/compliance/{company}/standard-registration/{loc}/settings` |
| `ghl_list_forms` | Forms | `GET /forms/list` |
| `ghl_list_funnels` | Funnels and websites | `GET /funnels/funnel/list` |
| `ghl_list_custom_fields` | Custom fields (contact + opportunity) with ids and fieldKeys | `GET /locations/{loc}/customFields/search` |
| `ghl_list_pipelines` | Opportunity pipelines and stages | `GET /opportunities/pipelines` |

### Write (contracts verified end-to-end on a test sub-account)

| Tool | What it does | Internal endpoint |
|---|---|---|
| `ghl_create_custom_field` | Create a custom field (12 data types, contact/opportunity) | `POST /locations/{loc}/customFields` |
| `ghl_update_custom_field` | Rename a custom field | `PUT /locations/{loc}/customFields/{id}` |
| `ghl_delete_custom_field` | Delete a custom field | `DELETE /locations/{loc}/customFields/{id}` |
| `ghl_create_custom_value` | Create a custom value (reusable variable) | `POST /locations/{loc}/customValues` |
| `ghl_create_tag` | Create a tag | `POST /locations/{loc}/tags` |
| `ghl_create_pipeline` | Create a pipeline with ordered stages | `POST /opportunities/pipelines` |
| `ghl_create_workflow` | Create an empty draft workflow | `POST /workflow/{loc}` |
| `ghl_delete_workflow` | Delete a workflow | `DELETE /workflow/{loc}/{workflowId}` |
| `ghl_create_form` | Create an empty form | `POST /forms/` |
| `ghl_create_contact` | Create a contact | `POST /contacts/` |

### Generic

| Tool | What it does |
|---|---|
| `ghl_call` | Passthrough to **any** endpoint in [`docs/ENDPOINTS.md`](docs/ENDPOINTS.md) (host restricted to GHL's two API hosts). Non-GET calls count as writes. |

Set `GHL_READ_ONLY=1` to disable every write tool and non-GET passthrough call — recommended
while exploring a production account.

## Architecture

```mermaid
flowchart LR
    A["AI client<br/>(Claude Code / Desktop / Cursor)"] -- "MCP (stdio)" --> S["ghl-mcp-server<br/>FastMCP · 21 tools"]
    S --> T{"Transport<br/>GHL_TRANSPORT"}
    T -- "browser (default)" --> B["Your Chrome, logged in<br/>via CDP :9222<br/>(or headless + your exported session)"]
    B -- "XHR inside the page<br/>+ live bearer captured from the app" --> API
    T -- "token" --> H["httpx<br/>Bearer GHL_API_TOKEN"]
    H --> API["GHL internal API<br/>backend.leadconnectorhq.com<br/>services.leadconnectorhq.com"]
```

- `src/ghl_mcp_server/server.py` — tool definitions, validation, read-only guard.
- `src/ghl_mcp_server/transport.py` — the two transports, with timeouts and normalized errors.
- `src/ghl_mcp_server/config.py` — everything account-specific comes from environment variables.

### Transports

**`browser` (recommended).** GHL's internal API sits behind defenses that plain HTTP clients
struggle with (see below), so by default the server rides on a real browser session:

1. You start Chrome with remote debugging and log into GHL yourself (once):
   ```bash
   # macOS example — use a dedicated profile directory
   "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
     --remote-debugging-port=9222 --user-data-dir="$HOME/.ghl-chrome-profile"
   ```
2. The server attaches over CDP (`GHL_CDP_URL`), opens the sub-account and installs a small
   script that records the `Authorization` header the GHL app itself uses.
3. Requests are sent with `XMLHttpRequest` from inside the page, reusing the browser's TLS
   fingerprint, cookies and the app's live token. The app keeps refreshing the token itself.
4. Because GHL issues **different tokens per service**, each tool first "warms" the matching UI
   module (e.g. *Settings → Custom Fields*) so the right token is available.

Optional headless fallback: point `GHL_STORAGE_STATE` at a Playwright storage-state file you
exported yourself. That file holds live session cookies — keep it outside the repo, `chmod 600`.
The server **never** writes secrets to disk and never closes your own Chrome.

**`token`.** Direct HTTPS with `httpx` and `GHL_API_TOKEN`. Simpler, but the web-app JWT expires
and rotates, and Cloudflare may answer `403 / error 1010` to non-browser clients; the server
reports that case explicitly so you can switch to `browser`.

## Installation

```bash
git clone https://github.com/<your-user>/ghl-mcp-server.git
cd ghl-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[browser]"          # or: pip install -r requirements.txt
python -m playwright install chromium  # only needed for the headless fallback
cp .env.example .env                  # then fill in your values (never commit .env)
```

### Configuration

| Variable | Required | Description |
|---|---|---|
| `GHL_TRANSPORT` | no | `browser` or `token` (default: `token` if `GHL_API_TOKEN` is set, else `browser`) |
| `GHL_LOCATION_ID` | recommended | Default sub-account id |
| `GHL_API_TOKEN` | token transport | Bearer token for the internal API |
| `GHL_APP_URL` | browser transport | The URL you log into (`https://app.gohighlevel.com` or your white-label domain) |
| `GHL_CDP_URL` | no | Chrome DevTools endpoint (default `http://127.0.0.1:9222`) |
| `GHL_STORAGE_STATE` | no | Path to your exported Playwright session (headless fallback) |
| `GHL_COMPANY_ID` | no | Agency id, only for `ghl_a2p_registration_status` |
| `GHL_TIMEOUT` | no | Per-request timeout in seconds (default 30) |
| `GHL_READ_ONLY` | no | `1` disables all writes |

### Claude Code

```bash
claude mcp add ghl-internal \
  -e GHL_TRANSPORT=browser \
  -e GHL_APP_URL=https://app.gohighlevel.com \
  -e GHL_LOCATION_ID=your-location-id \
  -e GHL_READ_ONLY=1 \
  -- /path/to/ghl-mcp-server/.venv/bin/python -m ghl_mcp_server
```

### Claude Desktop / Cursor (`mcpServers` JSON)

```json
{
  "mcpServers": {
    "ghl-internal": {
      "command": "/path/to/ghl-mcp-server/.venv/bin/python",
      "args": ["-m", "ghl_mcp_server"],
      "env": {
        "GHL_TRANSPORT": "browser",
        "GHL_APP_URL": "https://app.gohighlevel.com",
        "GHL_CDP_URL": "http://127.0.0.1:9222",
        "GHL_LOCATION_ID": "your-location-id",
        "GHL_READ_ONLY": "1"
      }
    }
  }
}
```

Then ask things like *"list the workflows of this sub-account and tell me which ones are still
drafts"* or *"create the custom fields Google Ads Click ID and UTM Source as contact fields"*.

### Tests

The offline test suite uses a fake transport — no network, no browser:

```bash
pip install -e ".[dev]" && pytest -q
```

## Reverse-engineering GHL's internal API

The official API didn't cover what the agency needed, so the first step was to map what the web
app itself does.

**1. Capture.** A real Chrome session (logged in manually — Google's login blocks automation)
was driven through every module of a sub-account — dashboard, automation, sites, settings, phone
system, agency view — with Playwright attached over the Chrome DevTools Protocol. Every XHR/fetch
the app made was recorded to JSONL (method, URL, status, request and response shape).
Result: **4,673 captured calls**, all read-only navigation.

**2. Catalog.** A build script normalized URLs into templates (20-character GHL ids, UUIDs and
numbers replaced by placeholders), grouped them by resource and summarized request/response
shapes. Result: **259 unique endpoints** (method + template) in roughly **60 resource groups**,
257 of them on GHL's two API hosts. The generic, data-free version is in
[`docs/ENDPOINTS.md`](docs/ENDPOINTS.md).

**3. Find the defenses.** Plain HTTP didn't work, and figuring out why shaped the transport:

| Defense | Evidence | Workaround |
|---|---|---|
| Cloudflare filters by TLS fingerprint | Python `urllib` → error 1010; browser → OK | Send requests from inside the browser |
| CSP blocks cross-host `fetch()` from the page | `fetch` → "Failed to fetch" | Use `XMLHttpRequest`, which the page allows |
| Bearer JWT rotates in memory | Token in `localStorage` ≠ token actually sent | Hook the app's own XHR/fetch to read the live header |
| Tokens are per service | Workflow token rejected by custom-fields API | "Warm" the matching UI module first |
| Mandatory `Version` header | 401 "version header was not found" | Always send `Version: 2021-07-28` |

**4. Verify writes.** Passive capture only shows reads, so create/update/delete contracts were
probed actively on a **test sub-account** with minimal payloads and a throw-away name prefix,
then cleaned up. 15 contracts (custom fields, custom values, tags, pipelines, workflows, forms,
contacts) returned 200/201 and are marked `✅ verified` in the catalog. End-to-end smoke test
through the MCP: create workflow → it appears in the list → delete it, three 200s.

**Known gaps.** Number-pool creation and A2P submission were not probed (they cost money and
need a provisioned phone system). Workflow triggers are stored separately from the workflow
graph and are not handled by a dedicated tool yet.

### How it was built

Designed, directed and tested by Yeison Munera with AI pair-programming (Claude Code): the
capture strategy, the choice of transports and every live verification were done by hand on
real accounts; much of the code was written with the assistant.

## Disclaimer

- This is **not** an official GoHighLevel / LeadConnector product and is not affiliated with or
  endorsed by them.
- It relies on an **internal, undocumented API** that can change or break at any time without
  notice.
- Use it **only with accounts you own or are explicitly authorized to operate**, and make sure
  your use complies with GoHighLevel's Terms of Service. You are responsible for what an AI agent
  does with write access — start with `GHL_READ_ONLY=1`.
- Never commit tokens, exported sessions, browser profiles or captured traffic: they contain live
  secrets and customer data. The provided `.gitignore` excludes them.

## License

[MIT](LICENSE) © 2026 Yeison Munera

---

Built by **Yeison Munera** · [TechCube](https://techcube-site-pearl.vercel.app)

TDQS

B3.4/5.0

Scored across 21 tools

Disambiguation5/5

Each tool targets a distinct resource and action—custom fields, workflows, forms, contacts, pipelines, tags, phone numbers, etc. Even the two A2P status tools are clearly separated by their scope (sub-account record vs. registration settings). The generic ghl_call is explicitly marked as a passthrough for anything else, so no ambiguity.

Naming Consistency4/5

All tools share the ghl_ prefix and follow a verb_noun pattern (create, update, delete, list, get, call). Minor inconsistencies include plural vs. singular nouns (e.g., ghl_list_number_pools vs. ghl_list_forms) and two non-verb tools (ghl_a2p_status, ghl_a2p_registration_status), but the overall convention is predictable and readable.

Tool Count4/5

With 21 tools, the count is slightly above the ideal range, but it is justified by the breadth of the GoHighLevel domain—covering CRM entities, phone systems, funnels, workflows, and more. Each tool serves a distinct purpose, so there is no redundancy; the count feels appropriate for the scope.

Completeness3/5

The tool surface has notable gaps: contacts only have create (no list/update/delete), tags only have create, custom values only have create, and forms/pipelines lack update/delete. However, the generic ghl_call passthrough provides a fallback for any missing endpoint, so agents are not dead-ended. This makes the set partially complete.

Maintenance

ActivityMaintained
ResponsivenessNo issues